这篇文章将会带你了解单元测试的基础概念,为什么它在软件开发中如此重要,以及如何使用Jest来测试JavaScript应用程序。
单元测试是用来测试代码中最小的功能单元,比如函数、代码模块或软件组件。
为什么这很重要呢?
在软件开发中,情况会随着时间而改变。新功能不断加入,漏洞不断被修复,需求变更也很常见。
这就是编写测试以验证代码质量并确保在进行更改时代码正确性至关重要的原因。
- 测试框架
Jest,Jasmine,Mocha & Chai - API 测试
Supertest - 压力测试
压力测试,Load Test,Artillery - 模拟和桩
Sinon - 端到端测试(E2E测试)
WebDriver,Puppeteer,Cypress
要开始第一步,请创建NPM项目。
> npm init -y (初始化一个新的 npm 项目,并使用默认设置)
接着安装这个工具,Jest。
在终端中执行npm安装jest
> npm i jest
现在打开 package.json
文件,并在你要运行的脚本中添加 jest
。我使用的是内置的测试脚本:
{
"名称": "unit-testing",
"版本": "1.0.0",
"描述": "",
"主文件": "index.js",
"脚本": {
"test": "jest"
},
"关键字": [],
"作者": "",
"许可证": "ISC",
"依赖项": {
"jest": "^29.7.0"
}
}
每次你想运行测试时,你都会在终端中键入 npm test
。这将启动 jest,jest 会查找以 .test.js
结尾的文件并运行测试。
首先,你需要一个代码来测试一下。
接下来,你可以试着创建一个简单的数学函数,
// 计算两个数的和
function sum(a, b) {
// 返回两个数的和
return a + b;
}
// 导出 sum 函数以便其他模块使用
module.exports = sum;
然后,创建一个测试文件,然后编写你的测试内容。
// 这是测试求和函数的代码
test('应该将数字相加起来', () => {
const firstNumber = 2;
const secondNumber = 3;
const addition = sum(firstNumber, secondNumber);
expect(addition).toBe(5);
});
现在运行测试命令试试,看看是否有效吧。
> npm test
通过了 ./sum.test.js
√ 数字相加成功 (3 ms)
测试套件:1 通过,总共 1
测试: 1 通过,总共 1
快照: 总共 0
时间: 0.489 s,估计 1 秒
说明:
test()
是测试框架(如 Jest)识别为单元测试的关键词。应该将数字相加
是测试描述。每个测试都必须有一个描述,通常描述会以 "应该" 开头。() => {…}
是一个回调函数,用于调用你想要测试的函数或模块。expect()
函数用于断言结果是否符合预期(如toBe()
)。例如,expect(apple).toBe(fruit)
。
你可以在同一个测试中包含多个 expect()
函数,并且可以有不同的匹配方式。
toBe()
是一个用来检查两个值是否相等的匹配器。
test('比较名称应', () => {
const name1 = 'Armin'
const name2 = 'Armin'
expect(name1).toBe(name2); // 通过了
});
如果在比较对象或数组时,这个函数可能会失败: toBe()
test('应该比较对象是否相等', () => {
const user1 = { name: 'Mirza' }
const user2 = { name: 'Mirza' }
expect(user1).toBe(user2); // 期望 user1 等于 user2
});
> 运行 npm test
测试套件:1个失败,1个总共
测试:1个失败,1个总共
在比较中,使用 toEqual()
更合适。
test('比较对象应该是', () => {
const user1 = { name: 'Mirza' }
const user2 = { name: 'Mirza' }
expect(user1).toEqual(user2); // 通过测试
});
大于() 和 小于()
这些匹配器是用来比较数字值的。
测试('应比较年龄', () => {
const childAge = 10;
const adultAge = 20;
expect(childAge).toBeLessThan(adultAge); // 成功通过
expect(adultAge).toBeGreaterThan(childAge); // 成功通过
});
不是
的否定修饰词可以用来测试相反的情况:
测试('应该不相等', () => {
const name1 = 'Sead'
const name2 = 'Alen'
expect(name1).not.toBe(name2); // 通过测试
});
真值断言() & 假值断言() (toBeTruthy() & toBeFalsy())
这些匹配器用于验证值是否为真。
test('当字符串为空时应返回 true', () => {
const name = ''
const isEmpty = name.length === 0; // 结果为 true
expect(isEmpty).toBeTruthy(); // 通过了
});
test('当字符串不为空时不应返回 true', () => {
const name = 'Faruk'
const isEmpty = name.length === 0; // 结果为 false
expect(isEmpty).toBeFalsy(); // 通过了
});
抛出异常()
如果除数是零,代码会抛出错误,在这个例子中。
function division(a, b) {
if (b === 0) {
throw new Error('不能除以零')
}
return a / b;
}
toThrow()
断言用于验证是否会抛出异常。
test('除数为零时应该抛出错误', () => {
const firstNumber = 5;
const secondNumber = 0;
expect(() => division(firstNumber, secondNumber)).toThrow('ZeroDivisionError');
// 通过了
});
此外,您还可以测试是否如预期那样返回错误消息:
测试('除以零应该抛出错误', () => {
const firstNumber = 5;
const secondNumber = 0;
const errorMessage = '不能除以零!';
expect(() => division(firstNumber, secondNumber)).toThrow(); // 成功
expect(() => division(firstNumber, secondNumber)).toThrow(errorMessage); // 成功
});
可以参考官方Jest 文档了解所有的匹配器方法。
测试集编写测试时,一个常见的做法是将它们组织成测试套件。通常,这是通过使用describe
块来实现的。describe
块包含多个测试,每个测试。
describe('加法操作', () => {
test('应能正确相加两个数字', () => {
const firstNumber = 2;
const secondNumber = 3;
expect(firstNumber + secondNumber).toBe(5);
});
})
describe('乘法操作', () => {
test('应能正确相乘两个数字', () => {
const firstNumber = 2;
const secondNumber = 3;
expect(firstNumber * secondNumber).toBe(6);
});
})
和其他的describe
块
describe('加法运算', () => {
describe('涉及正数的加法', () => {
测试('应将两个数相加', () => {
const firstNumber = 2;
const secondNumber = 3;
expect(firstNumber + secondNumber).toBe(5);
});
测试('应将三个数相加', () => {
const firstNumber = 2;
const secondNumber = 3;
const thirdNumber = 4;
expect(firstNumber + secondNumber + thirdNumber).toBe(9);
});
});
describe('涉及负数的加法', () => {
测试('应将两个数相加', () => {
const firstNumber = -2;
const secondNumber = 3;
expect(firstNumber + secondNumber).toBe(1);
});
})
});
测试中的组织
我们将测试分为三个部分,这种做法很常见。
- 准备测试数据和预期结果
这是在测试中准备测试数据和预期结果的地方。 - 执行步骤
在执行步骤中,我们通过使用之前准备的数据来调用要测试的函数。 - 断言
在最后一步,我们需要确认实际结果是否与预期结果一致。
test('数字相加测试', () => {
// 给定
const firstNumber = 2;
const secondNumber = 3;
const expected = 5;
// 执行
const actual = sum(firstNumber, secondNumber);
// 验证
expect(actual).toBe(expected);
});
这种模式也被称为 三A测试。
前后对比你也可以使用前置和后置钩子来准备测试所需的数据。
beforeEach
钩子(在每个测试之前执行)在测试套件中的每个测试之前执行。
describe('乘法测试', () => {
let firstNumber;
let secondNumber;
// 为每个测试设置默认值
beforeEach(() => {
firstNumber = 5;
secondNumber = 3;
});
test('应该将两个数字相乘', () => {
expect(firstNumber * secondNumber).toBe(15);
});
test('任何数与零相乘结果都为零', () => {
secondNumber = 0;
expect(firstNumber * secondNumber).toBe(0);
});
});
这是一个为每个测试设置默认值的好地方,正如你所见,在beforeEach
代码块中设置的值可以在测试中被更改或覆盖。
同样的道理适用于 beforeAll
代码块,它会在所有测试运行之前执行一次:这段代码块会在测试套件中的所有测试运行一次之前执行。
describe('乘法运算', () => {
let firstNumber;
let secondNumber;
// 在所有测试之前运行一次
初始化(() => {
// 初始化变量
firstNumber = 5;
secondNumber = 3;
});
afterEach
和 afterAll
块,正如它们的名字所表明的,会在每个测试用例或所有测试用例之后运行。
describe('乘法运算', () => {
let firstNumber;
let secondNumber;
// 在所有测试完成后执行
afterAll(() => {
/* 清理资源 */
});
// 在每个测试执行后
afterEach(() => {
/* 重置数值 */
});
这些在以下情况有用:
- 重置测试中修改的全局变量。
- 释放资源(如,关闭数据库连接)。
- 在现有测试套件完成后重新运行等操作。
在测试过程中,您可以选择运行某些测试而跳过其他测试。
test.only('应为真', () => {
const condition = 100 > 10;
expect(condition).toBe(true);
});
test.only
仅运行此测试,而不运行其他测试。并且可以在套件中多次使用该指令(即test.only
):
test.only('应该为真', () => {
condition = 100 > 10;
expect(condition).为真();
});
test.only('应该为假', () => {
condition = 100 > 1000_000;
expect(condition).为假();
});
另一种避免测试运行的方法是故意跳过这些测试,使用带有 x
前缀的标记。
xtest('此测试将被略过', () => {
const condition = 100 > 10;
expect(condition).toBeTruthy();
});
测试异步编程的编写
这里事情就变得棘手了。与同步代码逐行执行不同,在异步环境中,代码在后台执行不会暂停程序的执行,导致不可预测的行为或结果。
我准备用来测试的函数比之前用到的示例稍微复杂一点。
// transaction.js
async function mapTransactionAsync(transaction) {
let mappedTransaction = {};
if (transaction.transferType === 'international') {
// 国际转账
mappedTransaction.account = transaction.account;
mappedTransaction.SWIFT = await getSWIFTCode(transaction.currency); // 获取SWIFT代码
mappedTransaction.currency = transaction.currency;
const transferFee = 1.015; // 转账手续费
mappedTransaction.amount = transaction.amount * transferFee; // 计算包含手续费的转账金额
} else {
// 国内转账
mappedTransaction.account = transaction.account;
mappedTransaction.SWIFT = null;
mappedTransaction.currency = 'BAM';
mappedTransaction.amount = transaction.amount;
}
return mappedTransaction;
}
module.exports = {
mapTransactionAsync,
}
// swift.js
async function getSWIFTCode(currency) {
if (currency === 'BAM') {
return Promise.reject('无法获取SWIFT代码! ')
}
return Promise.resolve('AAAA-BB-CC-789')
}
module.exports = { getSWIFTCode }
根据你提供的交易信息,这段代码。
- 创建一个新的交易
- 生成一个SWIFT电汇代码
- 在金额中加上费用
因为被测试的函数返回了Promise,所以测试需要使用 async & await 来等待 Promise 被解决。
// transaction.test.js
test('应将本地交易进行映射', async () => {
// 准备阶段
const transaction = {
account: '98765',
currency: 'BAM',
amount: 500,
transferType: 'local',
};
const expected = {
account: '98765',
currency: 'BAM',
amount: 500,
SWIFT: null
}
// 执行阶段
const actual = await mapTransactionAsync(transaction);
// 断言
expect(actual).toEqual(expected); // 通过,
// 断言SWIFT字段未生成
expect(actual.SWIFT).toBeNull(); // 通过,
});
异步的跨境交易测试
当涉及国际交易时,预期的结果中应该包含SWIFT代码,并且金额中应加上相应的费用。
// transaction.test.js
test('应映射国际交易', async () => {
// 设置
const transaction = {
account: '98765',
currency: 'USD',
amount: 500,
transferType: 'international',
};
const expectedSWIFT = 'AAAA-BB-CC-789';
const expectedAmount = transaction.amount * 1.015; // 1.015倍(手续费)
// 执行
const actual = await mapTransactionAsync(transaction);
// 断言
expect(actual.SWIFT).toEqual(expectedSWIFT); // 通过验证
expect(actual.amount).toEqual(expectedAmount); // 通过验证
});
异步测试中的异常处理
我们应该随时准备好应对可能出现的异常情况。比如说,让我们来模拟一下SWIFT创建失败时的情景:
// swift.js
async function getSWIFTCode(currency) {
if (currency === 'BAM') {
return Promise.reject('无法获取SWIFT代码!')
}
return Promise.resolve('AAAA-BB-CC-789')
}
module.exports = { getSWIFTCode }
// swift.test.js
const { getSWIFTCode } = require('./swift');
test('生成BAM货币的SWIFT代码应失败', async () => {
// 设置
const transaction = {
account: '98765',
currency: 'BAM',
amount: 500,
transferType: 'local',
};
// 执行
try {
await getSWIFTCode(transaction);
} catch (error) {
// 验证
expect(error).toBe('无法为BAM生成SWIFT代码!');
}
});
Jest还支持假计时器,可以在测试中模拟时间的流逝。更多详情请参考文档。
在嘲弄的测试单元测试应当单独测试代码,因此不应产生任何副作用,例如:
- 生成实体对象
- 将数据插入到数据库中
- 调用API接口
- 给用户发送邮件等操作
当你测试的代码 (mapTransactionAsync()
) 依赖于会产生副作用的功能 (getSWIFTCode()
) 时,推荐使用模拟技术来处理这种情况。
Mocks 帮助我们模拟期望的行为而不实际执行代码,比如模拟生成 SWIFT 代码的行为。现在,让我们来模拟 getSWIFTCode()
函数,使其始终返回成功。
// 交易测试文件 (transaction.test.js)
const { 映射交易异步函数 } = require('./transaction');
jest.mock('./swift', () => ({
获取SWIFT码: jest.fn().mockResolvedValue('模拟的SWIFT代码'),
}));
注释:此处模拟了./swift
模块中的getSWIFTCode
函数,返回值为模拟的SWIFT代码。
这样设置后,在测试中调用 getSWIFTCode()
函数时,它不会生成实际的 SWIFT 代码,而是始终返回模拟的返回值。
test('应映射国际交易', async () => {
// 安排
const transaction = {
account: '98765',
currency: 'USD',
amount: 500,
transferType: '国际',
};
// 这里需要调整以与模拟数据匹配
const expectedSWIFT = '模拟的SWIFT码';
const expectedAmount = transaction.amount * 1.015;
// 执行操作
const actual = await mapTransactionAsync(transaction);
// 断言
expect(actual.SWIFT).toEqual(expectedSWIFT); // 通过测试
expect(actual.amount).toEqual(expectedAmount); // 通过测试
});
// 你也可以让这个函数一直失败:
jest.mock('./swift', () => ({
getSWIFTCode: jest.fn().mockRejectedValue('Error!'),
}));
Jest 支持多种类型的 mocks 和 spies。请更多详情请参阅 Jest 文档。
测试覆盖率测试另一个在测试时有用的特性是测试覆盖率,它衡量代码在应用程序中被测试的百分比,这通常用来
- 更好的质量
- 数据分析
- 批准合并请求
为了得到代码覆盖率,请将 coverage 脚本加入到 package.json 文件里:
"scripts": {
"test": "jest", // 运行 Jest 测试
"test-coverage": "jest --coverage" // 生成 Jest 测试覆盖率报告
},
现在来运行脚本吧:
> npm run test-coverage
----------------|---------|----------|---------|---------|-------------------
文件 | % 语句 | % 分支 | % 函数 | % 行 | 未覆盖的行号
----------------|---------|----------|---------|---------|-------------------
全部文件 | 百分之九十 | 百分之七十五 | 百分之六十六点六六 | 百分之九十 |
文件名 sum.js | 百分之五十 | 百分之一百 | 百分之零 | 百分之五十 | 2
文件名 swift.js | 百分之七十五 | 百分之五十 | 百分之一百 | 百分之七十五 | 4
文件名 transaction.js| 百分之一百 | 百分之一百 | 百分之一百 | 百分之一百 |
----------------|---------|----------|---------|---------|-------------------
这也可以在网页中查看。
展开根文件夹中新建的覆盖率目录,然后在浏览器中打开index.html文件。
这应该打开整个应用的测试覆盖页面。
您可以点击每个部分来查看内容。例如,查看 swift.js 文件时,红色标记的代码表示未被测试覆盖。
这篇文章解释了软件测试如何帮助保证代码质量。
单元测试位于测试金字塔的底部,因为它用来测试独立的功能。
单元测试可以用来测试这些组件,这些组件类似于 React、Angular 或 Vue 这样的框架中的 web 组件。
它们也可以利用 GitHub Actions 在每次推送或拉取时,于 CI 管道中运行测试。
我就说这么多。记得关注我哦,更多精彩内容等着你。
共同学习,写下你的评论
评论加载中...
作者其他优质文章