像专业人士一样进行 Jest 测试 - 技巧和窍门
并发
每(桌)
描述(通常关于测试名称)
测试结构
异步
误报、toThrow 和 expect.assertion
异步和 toThrow
模拟
toMatchObject 和属性匹配器
待办事项
仅有的
失败
使用调试器
记住要让你的测试失败!
我们最近在 React 和 Node 应用程序中编写单元测试和集成测试时,切换到了 Jest 框架。我们多年前使用过Mocha ,也用过AVA几年。
一旦你习惯了编写测试(或者更好地习惯了 TDD),更换框架就不是什么大问题了。
每个框架(以及每种编程语言)的基本原理都相同,但也存在一些细微差别。Jest
的文档非常详尽,我强烈建议你阅读它,并在每次编写稍微复杂的测试或断言时回顾它。但我想在这里分享的是一些技巧和窍门的回顾,希望能帮你节省一些时间,避免一些麻烦。
并发
这实际上是我喜欢 AVA 的主要原因之一,测试默认是并发运行的,而且理由充分!
测试不应该依赖外部 API/服务,也不应该依赖全局变量或其他跨不同测试持久化的对象,所以既然它们可以而且应该同时运行(如果工作线程和线程允许),为什么不应该一个接一个地运行——而且运行速度非常慢呢?
如果出于某种原因(通常这种情况只发生在集成测试中),我们需要保持一定的顺序,那么我们可以按顺序/串行运行它们。
在 Jest 中则相反。你需要明确指定测试是否需要并发运行。参见此处
每(桌)
在某些情况下,您会进行一些基本相同但略有变化的测试。
您可以创建单独的测试,也可以使用each(table)
,它基本上会在您的表/数组上运行循环/映射,并使用该特定有效负载运行测试(最终对该特定预期结果运行断言)。
这是一个非常有趣的功能,但我会小心,因为很容易被过度“重用和优化”所迷惑,使测试变得比需要的更复杂,或者最终导致许多不必要的重复测试。
假设您想要测试您的 sum 方法:
const sum = (a, b) => a+b
test.each([
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
])('.add(%i, %i)', (a, b, expected) => {
expect(sum(a, b)).toBe(expected);
});
恕我直言,尽管这段代码很好地解释了如何使用每个表,但我们不应该编写这样的测试。
对于这种事情,多个输入不会有任何价值。除非我们的方法有一些奇怪的逻辑——比如在某个和之上总是返回预先定义的最大值。
const cappedSum = (a, b) => {
const cap = 10
const tot = a + b
if (tot > cap) {
return cap
} else {
return tot
}
}
test.each([
[1, 2, 3],
[2, 4, 6],
[5, 5, 10],
[8, 7, 10],
[45, 95, 10]
])('.add(%i, %i)', (a, b, expected) => {
expect(cappedSum(a, b)).toBe(expected);
});
尽管如此,在这种情况下,我可能会编写两个简单的测试,使这种特定的行为更加突出。
test("Two integers are added if total is below the cap [10]", () => {
expect(cappedSum(2, 4)).toBe(6);
})
test("Cap [10] is always returned if sum of two integers is higher", () => {
expect(cappedSum(5, 6)).toBe(10);
})
我宁愿在这里重复说明,以强调该方法的具体细节。这样,万一测试失败,也能更清晰地说明。
想象一下,有人改变了上限的值,你表中的测试开始失败,
结果你会发现:
operation › .add(45, 95)
expect(received).toBe(expected) // Object.is equality
Expected: 10
Received: 50
这没有多大意义,因为 45 + 95 得出的结果是 140,而预期的 10 或收到的 50 都不匹配,您会盯着错误消息想“这到底是怎么回事……!?”
相反,阅读:
operation › Cap [10] is always returned if sum of two integers is higher
expect(received).toBe(expected) // Object.is equality
Expected: 10
Received: 50
清楚地让你弄清楚上限出了问题,并且在这个特定情况下,断言和标题没有随着更新的代码而更新。
描述(通常关于测试名称)
当您运行 Jest 时,测试将按文件运行,在该文件中,您可以拥有相关测试的组,并将其放在 Describe 块下。
尽管在每个例子中都看到过,describe
但这不是强制性的,所以如果你有一个只有一堆测试的小文件,那么你实际上并不需要它。
但在许多情况下,对共享相同测试方法但输入和断言不同的测试进行分组是有益的。
正确的分组和命名常常被低估。你必须记住,测试套件应该成功,它们可能包含数百或数千个测试。
当本地或持续集成管道出现问题时,你希望能够立即了解问题所在:测试失败消息中包含的信息越多越好。
读者应该无需阅读任何其他代码就能理解你的测试。
测试的命名要清晰易懂,以便其他人仅凭名称就能诊断出错误。
describe("UserCreation", ()=> {
it("returns new user when creation is successful")
it("throws an InvalidPayload error if name is undefined")
// etc
文件名 + 描述 + 测试名称的连接,以及预期值和接收值之间的差异。(假设您写了足够具体的断言)将允许您立即发现问题,并在几秒钟内进行手术干预。
想象一下,您最初的创建用户的实现以这种格式返回一个新用户:
{
name: "john",
surname: "doe",
id: 123
}
你的测试将断言这 3 个属性
it("returns new user when creation is successful ", () => {
const expected = {
id: expect.any(Number),
name: expect.any(String),
surname: expect.any(String)
}
const result = create()
expect(result).toMatchObject(expected)
})
读到这样的失败信息:
user-manager › UserCreation.returns new user when creation is successful
expect(received).toMatchObject(expected)

将清楚地让任何人明白,您的方法现在返回的对象仅包含新用户的 ID,而不是其所有数据。
我知道命名很难,但务必确保测试文件、测试套件和单个测试的命名和分组准确、规范。这样,当你需要调试失败的测试时,它就会派上用场。
关于这个话题,我强烈建议你阅读一篇非常有趣的文章,它涉及编写测试的许多方面和陷阱,以及为什么编写测试与生产编码有着根本的不同:
为什么优秀的开发人员会写出糟糕的测试
测试结构
无论您是否使用 Jest,测试结构都应该清晰且明确。
用 AAA 风格编写测试,也就是Arrange Act Assert
安排
设置模拟或数据库连接或服务实例等
定义输入
定义期望
行为
运行测试代码并传递输入
断言
在结果和期望之间运行断言
最重要的是让读者关注测试(遵循上述文章中的提示)。
不必担心冗余或辅助方法。
请记住,只有当测试开始失败时,人们才会重新开始测试,此时,重要的是测试的目的、设置和错误是可以理解的,并且可以快速调试,而无需点击许多其他变量或辅助方法。
异步
如果你要测试的方法是异步的,无论是回调、promise 还是 async,Jest 都不会有问题。
我想提醒你最大的陷阱是,当你运行期望值时,忘记 await 或添加一个return
。 这会导致你的测试通过,即使异步方法失败了(因为 Jest 不会等待,当测试失败时,通知它已经太晚了)。
如果您从一个同步测试跳转到另一个同步测试,这种情况经常发生;请考虑以下示例:
it('loads some data', async () => {
const data = await fetchData();
expect(data).toBe('loaded');
});
it('loads some data', () => {
return expect(fetchData()).toBe('loaded');
});
它们很相似,作用相同。
在第一个例子中,我们告诉 Jest 测试是什么,async
并要求awaiting
该方法返回已加载的数据,然后运行断言。
在第二个例子中,我们只返回 Expect。
如果您忘记了 async / await 或 return,测试将在数据加载之前立即退出,并且不会进行任何断言。
这非常危险,因为它可能会导致误报。
误报、toThrow 和 expect.assertion
在某些情况下,在测试中添加一个特殊的断言很有用,您可以告诉 jest 进行计数并确保运行并通过一定数量的期望。
在我上面提到的情况下,这一点非常重要——如果你忘记在异步方法中返回 expect 或 await。
但如果你的测试在 try/catches 或 then/catch 中包含断言,它也很有用。我不建议
在catch
代码块中使用断言,最好使用 resolve/rejects 或其他方法,但有时我发现这样做是必要的,例如下面的示例:
it('validates payload', () => {
const p = {
// some payload that should fail
}
const throwingFunc = () => validate(p)
expect(throwingFunc).toThrow(ValidationError)
})
如果我需要对抛出的错误做出更多断言,除了检查它的类型/类之外 - 例如确保错误包含一些特定的内部属性或通过正则表达式表达其详细消息 - 并且我不希望该方法被执行多次,我们需要捕获错误并直接在其上运行断言:
it('validates payload', () => {
const p = {
// some payload that should fail
}
expect.assertions(2)
try {
validate(p)
} catch (error) {
expect(error).toBeInstanceOf(ValidationError)
expect(error).toHaveProperty("details", [
"should have required property 'username'",
'email should match pattern "^\\S+@\\S+$"'
])
}
})
如果我不输入expect.assertions(2)
,然后由于某种原因,逻辑是验证发生了变化(以便有效载荷通过,或者返回 true|false 而不是错误),测试就会默默地通过,只是因为 jest 不知道有一些断言要运行。
没有断言运行,意味着测试成功
异步和 toThrow
只是为了给错误断言增加一点趣味,只要记住当你的方法是异步的时候expect
语法会有点不同。
当然,您仍然可以依赖 catch 块 - 但仍然记住await
和expect.assertions(1)
,但首选的方法是使用rejects
:
it('throws USER_ALREADY_EXISTS when primary key is already in use', async () => {
const p = {
// some payload whose Id is already in use
}
const throwingFunc = () => createItem(p)
await expect(throwingFunc).rejects.toThrow(new RegExp(Errors.USER_ALREADY_EXISTS))
})
关于使用resolve/rejects测试 Promises 和 Async 代码的更多信息请点击此处
模拟
测试中的模拟本身就是一个章节,我对此有着复杂的感受。
我见过太多过度设计的抽象,包含大量的类和方法,并带有依赖注入,却要通过极其复杂的测试来测试,其中所有内容都被模拟和存根。
代码覆盖率很高,CI 管道中的所有内容都正常,结果却看到生产环境崩溃,因为模拟结果与实际情况并不相符。
这也是为什么,尤其是在无服务器环境下,我更喜欢尽可能进行集成测试——这样才能测试到真正的服务,而不是某些古怪的 dockerized aws 服务模拟器。
这并不意味着我们从未使用过aws-sdk-mock - 还没有尝试过SDK v3的这个版本- 查看这篇文章了解更多信息 - 但总的来说,我尝试编写非常简单的单元测试和非常简单的集成测试,将模拟保持在最低限度。
如果您是 100% Mock 倡导者,我真的建议您阅读Eric Elliot撰写的《Mocking 是一种代码异味》,几年前,这本书确实让我大吃一惊。
回到 Jest 中的 Mocks。
如果您刚刚开始使用 Mocks,您可能会查看文档,然后盯着代码并问自己:“嗯?!为什么?有什么意义?!?”
你应该怎么用它?对模拟方法进行断言毫无意义……
因此,这将带给我们一个更广泛的话题,可以引导我们进行依赖注入和控制反转。
使用模拟可能很困难且棘手,因为通常我们的方法耦合得太紧密,并且您无法访问正在使用的内部方法。
想象一种方法,它验证一些数据,创建一个有效负载并将其传递给 api 来创建一个用户,然后映射结果或捕获错误并返回它。
const createUser = (data)=> {
// validate data
// build payload
// call api
// then map result to our needs
// catch and handle results from api
}
如果您想为此创建测试,您不想调用真正的 API 并真正创建用户(由于多种原因,测试可能变得不稳定并取决于网络问题或 API 可用性,您不想不必要地创建用户,之后您将不得不拆除/删除,您不想使用无效有效负载“垃圾邮件”端点来测试所有可能的错误)。
这时,模拟就派上用场了。但是……
如何访问调用 API 的内部方法呢?
有些人可能会使用Rewire来访问模块的内部并覆盖它们,或者您可以在不同的模块中公开这些特定的方法,然后模拟它们的实现,或者您可以依靠依赖注入和闭包来解耦行为,然后轻松地模拟它而没有太多的麻烦。
const createUser = (api)=>(data) {
// validate data
// build payload
api.call(payload) <-- now this can be the real thing or a mock we don't know and don't care
// then map result to our needs
// catch and handle results from api
}
要使用它,你首先要部分应用注入 api 类的方法
const api = new 3rdPartyApi()
const userCreatorFunc = (api)
然后使用仅需要有效载荷的真实创建者函数(即您正在测试的原始方法)
userCreatorFunc(myData)
那么如何模拟你的 api?
const input = {username: "john"}
const response = {
ts: Date.now(),
id: 999,
name: "john",
}
const apiMock = {
create: jest.fn().mockReturnValue(Promise.resolve(response)),
}
const createdUser = await createUser(apiMock)(input)
const objToMatch = {
id: expect.any(Number),
userName: expect.any(String)
registrationDate: expect.any(Date),
// some other formatting and properties or data manipulation done in our method when we get the response
}
expect(createdUser).toMatchObject(objToMatch)
})
从这里您可以轻松模拟错误的响应,并确保正确处理所有事情,而无需依赖网络,也无需打扰真正的 API。
模拟可以更深入、更进一步,您可以断言模拟的方法被调用以及使用哪些参数(想象一下您的方法中有一些条件,根据某些输入可能会或可能不会调用 api)等等。
说实话,出于以上原因,我不太喜欢这种 mocking 方式。
保持平衡,保持简单。
当你发现它变得过于复杂时,你可能做错了。而且很可能你应该首先重构你的方法。
保持简单也是 TDD 如此优秀的原因之一,从小处着手,进行测试,代码自然会以简单、合乎逻辑、解耦且易于测试的方式发展。
这里只是提供了一个 Axios 调用的模拟示例,如果您不想或不能重构代码以在执行网络调用时注入外部依赖项,这可能会很有用。
import axios from "axios";
test('should throw an error if received a status code other than 200', async () => {
// @ts-ignore
axios.post.mockImplementationOnce(() => Promise.resolve({
status: 400,
statusText: 'Bad Request',
data: {},
config: {},
headers: {},
} as AxiosResponse)
)
const user = await createUser(input)
在这个例子中,如果您的 createUser 方法使用 axios 来调用 api,那么您就是在完全模拟 axios,这样就不会发出请求,但会触发您的模拟响应。
toMatchObject 和属性匹配器
我们经常想断言我们的方法返回一个特定的对象,但我们不想在设置/断言中硬编码大量的值。
或者我们不想断言返回对象中的每个属性。
想象一下一些动态值,比如 ID、日期/时间戳等等。
在这种情况下,断言相等将导致错误失败。
toMatchObject在这里非常方便。
const result =createRandomUser('davide')
const expected = {
"name": "davide",
"email": expect.stringContaining("@"),
"isVerified": expect.any(Boolean),
"id": expect.any(Number),
"lastUpdated": expect.any(Date),
"countryCode": expect.stringMatching(/[A-Z]{2}/)
// result might contain some other props we are not intersted in asserting
}
expect(result).toMatchObject(expected)
将 toMatchObject 与其他预期全局变量(如ANY)结合使用非常强大,可以进行足够通用的测试,但仍然可以验证返回对象的“类型”。
待办事项
当您记下可能的测试场景的想法时,或者当您为您正在指导的初级开发人员或实习生准备测试列表时,或者只是留下可能的改进/技术债务的痕迹时,将测试标记为TODO非常方便。
仅有的
仅可在调试测试时使用。
完成后提交时务必小心。您可能会搞砸整个构建流程,甚至冒着将某些损坏的代码投入生产的风险,因为您实际运行的测试,实际上就是那些标记为.only的测试!
为了避免此类问题,您可以使用 git hook(检查Husky
和DotOnlyHunter),它会扫描您的测试,确保您没有推送任何忘记删除.only 的测试。
失败
这实际上是我在 jest 中缺少的一个功能(AVA 中提供了
此功能 )有时测试失败,但由于某种原因,您想保留它而不是跳过它。当/如果实现已修复,则会通知您失败的测试现在成功了。
我无法告诉您何时以及为什么使用它,但我发现它非常有用,而且显然我不是唯一一个,因为有关于它的 github 问题。在问题解决之前,我们必须使用简单的skip。
使用调试器
这基本上适用于开发过程的每个步骤。放下那些 _console.log 文件,开始使用Debugger 和 Breakpoints,无论你的 IDE 是什么(这里以 VisualStudioCode 为例),这允许你中断正在运行的代码,获取 props 和方法,并逐步推进执行。这是一种非常有用、快速且实用的方法,可以帮助你了解实际情况。
记住要让你的测试失败!

无论您正在进行 TDD(测试驱动开发 - 即在编写实现之前/编写实现时编写测试)还是为刚编写的代码或重构编写测试,都没有关系。
确保你的测试失败!
如果你编写了一个测试并且它通过了,不要只是继续前进,想当然地认为/希望一切顺利。也许你的断言有问题,也许 Jest 没有等待你的结果,也许你认为你正在测试的极端情况实际上并没有出现在你实现的代码中。
通过先让测试失败(即传递错误的负载)来证明你的断言有效,并且你的假设是正确的,然后调整测试的 Arrange 部分使其再次有效。
Kent C. Dodds 有一个非常清晰的视频解释如何做到这一点。
希望以上内容对您有所帮助。如果您还有其他建议,欢迎在下方留言!
鏂囩珷鏉ユ簮锛�https://dev.to/dvddpl/jest-testing-like-a-pro-tips-and-tricks-4o6f