JavaScript 测试简介
今天,我们将讨论 Javascript 中的测试,并帮助您开始理解和掌握它的旅程。
测试是软件开发中最重要的主题之一,但很多开发人员仍然回避它。本文旨在改变这种现状。
本文的主要目的是清晰地概述 JavaScript 测试的整个领域,并使其易于理解。即使您之前没有任何测试经验,本文也将是您测试之旅的完美起点。
所以,不要再浪费时间了,让我们开始吧。
为什么测试很重要
在深入研究软件测试的各种类型和概念之前,您首先应该清楚地了解为什么您首先应该关心自动化测试。
建立对你的代码的信心:
要确保代码按计划运行,需要进行某种形式的测试。手动测试适用于大多数小型应用程序,但无法提供自动化测试所带来的安全性和可信度。
自动化测试可以轻松测试几乎所有可能的情况,并允许您在更改代码时运行它们。
识别每个成功和失败的情况并为它们编写测试将确保您对部署用于生产的代码充满信心。
编写最少的代码:
测试还能帮助你减少为特定功能编写的代码量。测试之后,你的主要目标是编写通过测试所需的最少代码。这种在实际实现之前先编写测试的编码风格也称为 TDD(测试驱动开发)。
测试成功后,您可以专注于使用尽可能少的代码编写干净的实现。
摆脱回归错误:
您是否有过这样的感受:您刚刚完成了应用程序的一个新功能,并准备将其发布到生产环境,突然发现一个旧功能不再起作用了?您完全不知道为什么会发生这种情况,并且可能会浪费大量时间来查找问题所在。
如果您测试了旧功能,这种情况就不会发生。您可以频繁运行这些测试,检查应用程序是否仍按预期运行。这些测试还能让您更好地了解哪些功能不再正常工作,因为相应的测试用例会失败。
测试类型
测试有几种不同的类型,了解它们之间的区别至关重要。大多数申请都需要你编写多种测试才能获得最佳结果。
单元测试:
单元测试的目的是验证一个相对较小软件的功能,使其独立于其他部分。单元测试的范围较窄,这使得我们能够涵盖所有情况,确保每个部分都能正常工作。
它们规模小,高度集中,由于执行速度快,可以在本地机器上高效执行。在开发过程中,你会有数百甚至数千个这样的测试,并定期运行它们。
这些类型的测试的唯一缺点是它们不在真实设备上执行,因此保真度比其他类型的测试低。
集成测试:
集成测试可以证明应用程序的各个部分在实际生产环境中的协同工作能力。它们验证两个独立的模块或组件是否以应有的方式协同工作。
这些测试规模适中,执行时间比单元测试长得多。它们执行频率较低,但对于检查应用程序的健康状况仍然至关重要。它们的保真度也更高,因为它们在真实设备上运行,并验证应用程序各个组件之间的实际交互。
端到端测试:
端到端测试可以端到端地验证复杂的场景,通常需要外部资源,例如数据库或 Web 服务器。假设您有一个包含多个步骤的注册流程的应用程序,并且您想测试整个流程,那么端到端测试就派上用场了。
E2E 测试也将像集成测试一样在真实设备上运行,因此执行速度也会相当慢。
这些测试的唯一缺点是,由于其范围太广,调试它们以及在特定测试失败时找出问题所在变得非常困难。
概念
在开始为代码编写测试之前,首先需要熟悉最重要的测试概念以及何时需要使用它们。这些概念将影响应用程序的架构以及代码编写方式,我们将在后面的部分中详细介绍。
匹配器:
匹配器让您以不同的方式验证测试的结果和值,并用于确保测试结果符合您的期望。
假设你有一个函数,用于计算某个阶乘的结果。然后可以使用 expect() 函数和一个简单的匹配器来测试该函数,该匹配器用于检查函数的结果是否与预期值匹配。
test('factorial of 2', () => {
expect(factorial(2)).toBe(2);
});
该expect()
函数检查结果是否满足匹配器定义的条件。本指南的后面部分将使用 Jest 测试框架中的不同匹配器。
嘲讽:
测试中的对象可能依赖于其他对象或服务。为了隔离对象的行为,您需要用模拟真实对象行为的 mock 对象替换与其交互的其他对象。
模拟可以帮助你的测试避免测试不可靠性(不稳定性)并提高测试速度。当真实对象不切实际地无法纳入测试时,模拟也非常有用。
简而言之,模拟就是创建模拟真实对象(例如数据库)行为的对象或服务。
生命周期:
在测试时,您通常会连续执行多个测试,并且在测试运行之前需要进行一些设置工作。大多数框架都提供了辅助函数来处理这些情况。
这是 Jest 测试框架中生命周期方法的一个示例。
beforeEach(() => {
// Initialize objects
});
afterEach(() => {
// Tear down objects
});
可测试的架构
在开始编写代码测试之前,首先需要确保应用程序的架构是可测试的。如果不可测试,则需要了解原因以及可以采取的措施。
不可测试的架构可能是许多人觉得测试繁琐且困难的最常见原因。如果你的代码结构不合理,你肯定会发现编写测试非常困难。
让我们探讨一下在谈论可测试架构时应该知道的一些重要概念。
依赖注入:
依赖注入是指一个对象提供另一个对象的依赖关系。你不需要每次创建新对象时都使用 new 关键字,而是只需要请求另一个对象提供你想要的实例即可。
当你需要更改某个对象的实现时,例如,当你为某个特定测试模拟它时,这个概念会很有帮助。许多现代框架,例如 Angular 和 Nest.js,已经内置了依赖注入,但了解它在底层是如何运作的仍然是有益的。
有关依赖注入的更多信息,可以访问以下文章。
SRP(单一职责原则):
单一职责原则(也称为 SRP)是SOLID 原则之一,它定义了一个函数应该具有单一用途。这使得测试每个函数是否正确执行其功能变得更加容易。
如果您的功能或服务执行多项职责,那么现在是时候确定这些职责并将其分离为单独的功能了。
避免副作用:
你的函数依赖于外部变量和服务,你必须在测试函数之前设置这些变量或服务。你还必须确保正在运行的任何其他代码不会更改这些变量和状态。
这就是为什么你应该避免编写会改变任何外部状态的函数(例如写入文件或将值保存到数据库)。这可以避免副作用,并让你能够自信地测试代码。
迪米特法则:
迪米特法则,又称“最少知识原则”,指出特定单元对其协作的其他单元应该拥有有限的知识。你的代码越依赖于与其交互的对象的内部细节,为它们编写测试就会越困难。
您可以在此处找到有关迪米特法则的更多信息。
不同测试工具概述
现在您已经了解了测试领域的基本概念以及何时需要使用它们,让我们继续简要介绍一下当今可用的不同 Javascript 测试工具。
注意:我不会介绍所有的工具,而是只介绍最重要的工具,以便让您快速了解它们的优点和缺点。
玩笑:
Jest 是由 Facebook 创建的开源测试框架,专注于简化测试。Jest 包含所有开箱即用的功能,无需任何配置,让 JavaScript 测试的编写变得更快、更轻松。Jest 还可以并行运行测试,从而提供更流畅、更快速的测试运行。
摩卡:
Mocha 是一个灵活的 JavaScript 测试库,旨在使异步测试变得简单有趣。它为开发人员提供了一个基本的测试框架,并允许他们选择要使用的断言、模拟和间谍库。
它需要一些额外的设置和配置,但可以让您完全控制测试框架。
柏树:
Cypress 是一款一体化测试工具,致力于简化端到端测试,使其更加现代化。他们的测试在浏览器中执行,因此执行速度更快,并且不会出现网络延迟。
Cypress 用于处理在现代 JavaScript 栈上运行的复杂 UI。通过使用其框架和断言库,可以轻松验证 UI 中的状态。Cypress 会自动等待您的应用程序达到此状态后再继续执行。
Cypress 是一种比 Jest 和 Mocha 更新、更现代的工具,对于初学者和端到端测试来说是一个很好的起点。
Jest 简介
如上所述,本指南将重点介绍 Jest 测试框架,因为它是目前最流行的框架。但大多数概念适用于所有测试框架,无论您使用哪种技术,它都能发挥作用。
Jest 是由 Facebook 维护的一个开源项目,特别适合单元测试和集成测试。它的优势如下:
- 它简单又快速
- 它提供了开箱即用的一切,因此不需要配置(尽管您可以选择更改配置)
- 它可以执行快照测试
现在我们将探讨一些实际的例子,以便您可以将知识付诸实践。
安装
Jest 可以使用 npm 或 yarn 安装:
yarn add --dev jest
# or
npm install --save-dev jest
请注意,这将把 Jest 安装为当前项目中 package.json 文件的一部分,并作为开发依赖项。您也可以选择全局安装。
yarn global add jest
# or
npm install jest -g
您还可以将此行添加到您的 package.json 中以使用 test 命令运行测试。
{
"scripts": {
"test": "jest"
}
}
你的第一次测试
现在我们已经安装了 Jest,终于可以编写第一个测试了。在此之前,我们先来编写一些可以测试的基本代码。
为此,我们将创建两个文件以便开始。
touch maths.js
touch maths.spec.js
我们将使用以下函数计算阶乘数来编写我们的第一个测试。
function factorialize(num) {
if (num < 0) return -1;
else if (num == 0) return 1;
else {
return num * factorialize(num - 1);
}
}
module.exports = { factorialize }
以下是针对这个小功能的一些非常基本的测试用例。
const { factorialize } = require("./maths");
test("factorial of 3", () => {
expect(factorialize(3)).toBe(6);
});
test("factorial of 5", () => {
expect(factorialize(5)).toBe(120);
});
在终端中运行 yarn test 命令应该会得到以下输出:
匹配器
正如上面所说,匹配器允许您以不同的方式验证测试的结果和值。
它们最常用于将 expect() 函数的结果与作为参数传递给匹配器的值进行比较(这也是我们上面所做的)。
以下是最常见的匹配器的列表:
- toBe - 比较严格相等性(例如 ===)
- toEqual - 比较两个变量/对象的值
- toBeNull - 检查值是否为空
- toBeDefined - 检查值是否已定义
- toBeUndefined - 检查值是否未定义
- toBeTruthy - 检查值是否为真(类似于 if 语句)
- toBeFalsy - 检查值是否为假(类似于 if 语句)
- toBeGreaterThan - 检查 expect() 函数的结果是否大于参数
- toContain - 检查 expect() 的结果是否包含一个值
- toHaveProperty - 检查对象是否具有属性,并可选地检查其值
- toBeInstanceOf - 检查对象是否是类的实例
这些匹配器也可以使用 not 语句来否定:
test("factorial of 3 is not 5", () => {
expect(factorialize(3)).not.toBe(5);
});
您还可以使用由 Jest 社区维护的其他匹配器。
安装和拆卸
通常在编写测试时,您必须进行某种设置,例如在测试运行之前初始化变量以及在测试完成后执行某种操作。
Jest 提供了两种不同的方法来实现这一点。
一次性设置:
在某些情况下,您只需在测试文件的开头进行一次设置。在这种情况下,您可以使用beforeAll()
和afterAll()
辅助函数,它们将在测试开始之前和所有测试结束后执行。
beforeAll(() => {
return initializeDatabase();
});
afterAll(() => {
return clearDatabase();
});
test('query from database', () => {
expect(database.getObject('Florida')).toBeTruthy();
});
为每个测试重复设置:
如果您有一个需要在每次测试之前运行的设置过程,那么您应该使用 beforeEach() 和 afterEach() 函数。
beforeEach(() => {
initializeDatabase();
});
afterEach(() => {
clearDatabase();
});
test('query from database', () => {
expect(database.getObject('Florida')).toBeTruthy();
});
注意:在某些情况下,您需要同时使用这两个设置过程才能获得最佳结果。
分组测试
您还可以将相关的测试分组,以便隔离设置和拆卸功能。分组测试还可以帮助您更好地概览不同的测试用例。
describe('testing factorial function', () => {
beforeAll(() => {
//do something
})
afterAll(() => {
//do something
})
test("factorial of 3", () => {
expect(factorialize(3)).toBe(6);
});
test("factorial of 5", () => {
expect(factorialize(5)).toBe(120);
});
test("factorial of 3 is not 5", () => {
expect(factorialize(3)).not.toBe(5);
});
})
测试异步函数
JavaScript 代码通常使用 Promise 或回调来异步运行。测试异步代码的难点在于,如何才能知道被测试代码何时真正完成。Jest 提供了多种方法来解决这个问题。
承诺:
在 Jest 中测试 Promise 非常简单。只需返回 Promise,Jest 就会等待 Promise 的 resolve 状态。如果 Promise 失败,测试也会自动失败。
// string.js
const reverseString = str => {
return new Promise((resolve, reject) => {
if (!str) {
reject("Empty string");
return;
}
resolve(str.split("").reverse().join(""));
});
};
module.exports = reverseString;
// string.spec.js
const reverseString = require("./string");
test(`reverseString 'String' to equal 'gnirtS'`, () => {
return reverseString("String").then(str => {
expect(str).toBe("gnirtS");
});
});
您还可以使用 catch() 函数捕获被拒绝的承诺。
test(`reverseString '' to reject promise`, () => {
return reverseString("String").catch(error => {
expect(e).toMatch("Empty string");
});
});
异步等待:
或者,我们可以使用 async 和 await 来测试承诺。
const reverseString = require("./string");
test(`reverseString 'String' to equal 'gnirtS' using await`, async () => {
const str = await reverseString("String")
expect(str).toBe("gnirtS");
});
注意:您需要使测试函数异步才能使用异步和等待。
回调:
默认情况下,Jest 测试在执行结束后即完成,这意味着测试将在回调函数调用之前完成。可以通过向测试函数传递一个名为 done 的参数来解决此问题。Jest 会等到 done 回调函数调用后才完成测试。
// string.js
function reverseStringCallback(str, callback) {
callback(str.split("").reverse().join(""))
}
module.exports = {reverseStringCallback};
// string.spec.js
const {reverseStringCallback} = require("./string");
test(`reverseStringCallback 'string' to equal 'gnirts'`, (done) => {
reverseStringCallback('string', (str) => {
expect(str).toBe('gnirts')
done()
})
})
如果done()
从未调用,测试将因超时错误而失败。
嘲讽
模拟是指创建模拟真实对象行为的对象或服务,在测试中起着至关重要的作用。模拟对象或函数的目的是用我们能控制的东西替换我们无法控制的东西(例如外部服务),因此,用具备我们所需所有功能的东西替换它至关重要。
使用模拟还可以帮助您检查有关代码的信息,例如是否已经调用了某个函数以及使用了哪些参数。
将 Mocks 传递给函数:
使用 Mock 函数的常见方法之一是将其作为参数传递给正在测试的函数。这样,你就可以在运行测试时无需导入实际应用程序中需要传递的真实依赖项和对象。
const multiplyNumbers = (a, b, callback) => {
callback(a * b);
};
test("calls callback with arguments added", () => {
const mockCallback = jest.fn();
multiplyNumbers(1, 2, mockCallback);
expect(mockCallback).toHaveBeenCalledWith(2);
});
这种策略很棒,但需要你的代码支持依赖注入。如果不支持,你就需要模拟现有的模块或函数。
模拟单个函数:
您可以使用 Jest.fn() 模拟单个函数:
const lodash = require('lodash')
lodash.chunk = jest.fn(() => 'test')
test(`Test lodash chunk function`, () => {
const result = lodash.chunk(['a', 'b', 'c', 'd'], 2)
expect(result).toBe('test')
expect(lodash.chunk).toHaveBeenCalled()
expect(lodash.chunk).toHaveBeenCalledWith(['a', 'b', 'c', 'd'], 2)
})
在这里我创建了lodash.chunk函数的模拟并测试它是否被调用以及参数是否正确。
模拟模块:
如果您只使用包或库中的一两个函数,那么模拟单个函数效果很好,但当您需要模块中的更多函数时,就会变得非常混乱。这里我们使用 jest.mock 自动设置整个模块的导出,而不是手动模拟模块。
jest.mock('lodash');
test(`Test lodash chunk function`, () => {
const result = lodash.chunk(['a', 'b', 'c', 'd'], 2)
expect(lodash.chunk).toHaveBeenCalled()
expect(lodash.chunk).toHaveBeenCalledWith(['a', 'b', 'c', 'd'], 2)
const concatResult = lodash.concat(2, [3], [[4]]);
expect(lodash.concat).toHaveBeenCalled()
expect(lodash.concat).toHaveBeenCalledWith(2, [3], [[4]])
})
如您所见,我现在可以将 lodash 库的所有函数作为模拟对象进行调用。
此策略的唯一缺点是难以访问模块的原始实现。对于这些用例,可以使用spyOn
函数代替。
监视包:
你也可以在不创建 mock 的情况下监视某个包。这可以通过 Jest 提供的 spyOn() 函数来实现。
const lodash = require('lodash')
test(`Test lodash chunk function`, () => {
const spy = jest.spyOn(lodash, 'chunk')
const result = lodash.chunk(['a', 'b', 'c', 'd'], 2)
expect(lodash.chunk).toHaveBeenCalled()
expect(lodash.chunk).toHaveBeenCalledWith(['a', 'b', 'c', 'd'], 2)
})
您应该关注的重要事项
在掌握 JavaScript 测试的过程中,你肯定还需要学习一些其他概念。但我强烈建议你先学习基础知识,然后在你的应用程序中运用它们。
- 快照测试——用于测试应用程序的 UI
- CI(持续集成)——将来自多个贡献者的代码更改自动集成到单个软件项目中的实践
- CD(持续部署)——是一种软件发布流程,使用自动化测试来验证代码库的更改是否正确
- 自动依赖项更新
在自己的应用程序中进行测试练习至关重要。如果你练习的时间足够长,你就能掌握测试的技巧,并使你的应用程序更加稳定、安全。
资料来源:
以下是我为本文使用的资料来源列表:
结论
你终于坚持读到了最后!希望本文能帮助你理解 JavaScript 自动化测试的基础知识。
如果您觉得本文有用,请考虑推荐并分享给其他开发者。如果您有任何问题或反馈,请通过我的联系表单或在Twitter上联系我。
文章来源:https://dev.to/gabrieltanner/an-introduction-to-testing-in-javascript-54jc