目前最流行的前端测试框架是Jest
。
Jest 是 Facebook 开发的 Javascript 测试框架,用于创建、运行和编写测试的 JavaScript 库。
Jest 开箱即用,具有强大的模拟功能,能并行执行测试,加快速度,是目前前端最流行的测试库之一。
安装依赖
javascript
npm install --save-dev jest
或
javascript
pnpm add --save-dev jest
简单示例
新建一个sum.js文件
javascript
function sum(a, b) {
return a + b;
}
module.exports = sum;
并新建sun.test.js文件,这特殊的后缀是 Jest
的约定,便于查找所有的测试文件。
javascript
const sum = require("./sum");
test("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3);
});
在package.json
文件添加
javascript
"scripts": {
"test": "jest"
},
运行命令,jest
会查找所有测试文件(".test.js"后缀)并做测试
javascript
npm run test
结果如下,可以发现测试通过
![image-20240823162633972](/Users/chenkai/Desktop/:Users:chenkai:Library:Application Support:typora-user-images:image-20240823162633972.png)
ES6 module支持
Jest 默认不支持ES6
模块,因此直接使用 Jest 运行测试时会抛出此错误。
如果需要支持ES6
模块,则必须添加 babel。
安装babel-jest
和@babel/preset-env
依赖
javascript
pnpm add --save-dev babel-jest @babel/preset-env
package.json
文件添加
javascript
"type": "module",
创建jest.config.js
配置文件
javascript
export default {
transform: {
"^.+\\.[t|j]sx?$": "babel-jest",
},
};
创建babel.config.json
配置文件
javascript
{
"presets": ["@babel/preset-env"]
}
匹配器
Jest 使用"匹配器"让您以不同的方式测试值。
toBe
上面的例子中,.toBe(3)
是匹配器,判断"期望"值是否完全相等。
明显单这个匹配器无法满足所有测试需求,jest
还提供了很多其他匹配器。
toBe
并不是用===
测试精确相等性,而是基于Object.is
,其二者有以下区别
javascript
console.log(+0 === -0); // true
console.log(Object.is(+0, -0)); // false
console.log(NaN === NaN); // false
console.log(Object.is(NaN, NaN)); // true
以下测试会通过
javascript
test("toBe", () => {
expect(NaN).toEqual(NaN);
});
以下测试会失败
javascript
test("toBe", () => {
expect(+0).toEqual(-0);
});
not
可以用not测试匹配器的对立面
javascript
test('adding positive numbers is not zero', () => {
for (let a = 1; a < 10; a++) {
for (let b = 1; b < 10; b++) {
expect(a + b).not.toBe(0);
}
}
});
toEqual
如果是检测对象或数组的值,会用到toEqual
, toEqual
会递归检查对象或数组的每个字段。
javascript
test("object assignment", () => {
const data = { a: 1 };
data["b"] = 2;
expect(data).toEqual({ a: 1, b: 2 });
});
其实检测普通类型的值也可以用toEqual
javascript
test("toEqual", () => {
const n = 2 + 2;
const v = "a";
const a = false;
expect(n).toEqual(4);
expect(v).toEqual("a");
expect(a).toEqual(false);
});
toBeNull
仅匹配null
javascript
test("toBeNull", () => {
const n = null;
expect(n).toBeNull();
});
toBeUndefined
仅匹配undefined
javascript
test("toBeUndefined", () => {
const n = undefined;
expect(n).toBeUndefined();
});
toBeDefined
相反的toBeUndefined
javascript
test("toBeDefined", () => {
const a = 1;
const b = "b";
const c = false;
const d = {};
const e = [];
const f = undefined;
expect(a).toBeDefined();
expect(b).toBeDefined();
expect(c).toBeDefined();
expect(d).toBeDefined();
expect(e).toBeDefined();
expect(f).not.toBeDefined();
});
toBeTruthy
匹配if
视为true的任何内容
javascript
test("toBeTruthy", () => {
const a = 1;
const b = [];
const c = false;
const d = undefined;
expect(a).toBeTruthy();
expect(b).toBeTruthy();
expect(c).not.toBeTruthy();
expect(d).not.toBeTruthy();
});
toBeFalsy
匹配if
视为false的任何内容
javascript
test("toBeFalsy", () => {
const a = 1;
const b = [];
const c = false;
const d = undefined;
expect(a).not.toBeFalsy();
expect(b).not.toBeFalsy();
expect(c).toBeFalsy();
expect(d).toBeFalsy();
});
toBeGreaterThan
测试"期待"的数字大于指定的值
javascript
test("toBeGreaterThan", () => {
const value = 1 + 3;
expect(value).toBeGreaterThan(3);
expect(value).not.toBeGreaterThan(5);
});
toBeGreaterThanOrEqual
测试"期待"的数字大于或等于指定的值
javascript
test("toBeGreaterThanOrEqual", () => {
const value = 1 + 3;
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeGreaterThanOrEqual(4);
});
toBeLessThan
测试"期待"的数字小于指定的值
javascript
test("toBeLessThan", () => {
const value = 1 + 3;
expect(value).toBeLessThan(5);
expect(value).not.toBeLessThan(4);
});
toBeLessThanOrEqual
测试"期待"的数字小于或等于指定的值
javascript
test("toBeLessThanOrEqual", () => {
const value = 1 + 3;
expect(value).toBeLessThanOrEqual(5);
expect(value).toBeLessThanOrEqual(4);
});
toBeCloseTo
测试浮点数相等
JavaScript 存在浮点数运算精度丢失的问题。(由于计算机内部使用二进制表示浮点数,而二进制无法精确表示所有的十进制小数,这就导致了所谓的"精度丢失")
javascript
console.log(0.1 + 0.2 === 0.3); // false
所以对于浮点数相等,我们应该用toBeCloseTo
,而不是toBe
或toEqual
javascript
test("toBeCloseTo", () => {
const value = 0.1 + 0.2;
expect(value).not.toBe(0.3);
expect(value).not.toEqual(0.3);
expect(value).toBeCloseTo(0.3);
});
toContainEqual
检查具有特定结构和值的项是否包含在数组中。为了测试数组中的项,此匹配器会递归检查所有字段的相等性。
javascript
test("toContainEqual", () => {
const list = [1, 2];
expect(list).toContainEqual(2);
});
测试异步代码
Promise
在测试中返回一个Promise,Jest 将等待该Promise的状态变为resolve或reject。
javascript
const fun = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve("a");
}, 200);
});
test("the data is a", () => {
return fun().then((data) => expect(data).toBe("a"));
});
test("the data is a", () => {
return expect(fun()).resolves.toBe("a");
});
javascript
const fun = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
reject("error");
}, 200);
});
test("error", () => {
return fun().catch((data) => expect(data).toBe("error"));
});
test("error", () => {
return expect(fun()).rejects.toBe("error");
});
Async/Await
javascript
const fun = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("a");
}, 200);
});
test("the data is a", async () => {
const data = await fun;
expect(data).toBe("a");
});
test("the data is a", async () => {
await expect(fun).resolves.toBe("a");
});
回调
也可以使用回调来测试异步代码,使用回调测试异步代码时,需要使用一个名为done
的参数。done
是一个回调函数,Jest
将等到done
回调被调用后才完成测试。
javascript
const fetchData = (callback) => {
setTimeout(() => {
callback("a");
}, 200);
};
test("callback", (done) => {
function callback(data) {
try {
expect(data).toBe("a");
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
钩子函数
beforeEach
在当前文件中的每一个测试开始之前会调用
javascript
let count = 0;
beforeEach(() => {
count++;
});
test("count is one", () => {
expect(count).toBe(1);
}); // 测试通过
test("count is two", () => {
expect(count).toBe(2);
}); // 测试通过
test("count is three", () => {
expect(count).toBe(3);
}); // 测试通过
afterEach
在当前文件中的每一个测试结束之后会调用
javascript
let count = 0;
afterEach(() => {
count++;
});
test("count is one", () => {
expect(count).toBe(0);
}); // 测试通过
test("count is two", () => {
expect(count).toBe(1);
}); // 测试通过
test("count is three", () => {
expect(count).toBe(2);
}); // 测试通过
beforeAll
在当前文件中的全部测试开始之前进行一次调用,只调用一次
javascript
let count = 0;
beforeAll(() => {
count++;
});
test("count is one", () => {
expect(count).toBe(1);
}); // 测试通过
test("count is two", () => {
expect(count).not.toBe(2);
}); // 测试通过
afterAll
在当前文件中的全部测试结束之后进行一次调用,只调用一次
javascript
let count = 0;
afterAll(() => {
count++;
});
test("count is one", () => {
expect(count).toBe(0);
}); // 测试通过
test("count is two", () => {
expect(count).not.toBe(1);
}); // 测试通过
describe
顶层的钩子函数适用于文件中的每个测试。在describe
块内声明的钩子函数仅适用于该块内的测试。
javascript
let count = "a";
beforeEach(() => {
count = "b";
});
describe("describe", () => {
beforeEach(() => {
count = "c";
});
test("the data is c", () => {
expect(count).toBe("c");
});
});
test("the data is b", () => {
expect(count).toBe("b");
});
顶层的钩子函数会在describe
块内的钩子函数之前执行。
javascript
test("", () => console.log("test--1"));
describe("Scoped / Nested block", () => {
beforeAll(() => console.log("describe - beforeAll"));
afterAll(() => console.log("describe - afterAll"));
beforeEach(() => console.log("describe - beforeEach"));
afterEach(() => console.log("describe - afterEach"));
test("", () => console.log("describe - test"));
});
beforeEach(() => console.log("beforeEach"));
afterEach(() => console.log("afterEach"));
beforeAll(() => console.log("beforeAll"));
afterAll(() => console.log("afterAll"));
test("", () => console.log("test--2"));
// beforeAll
// beforeEach
// test--1
// afterEach
// describe - beforeAll
// beforeEach
// describe - beforeEach
// describe - test
// describe - afterEach
// afterEach
// describe - afterAll
// beforeEach
// test--2
// afterEach
// afterAll
模拟函数
模拟函数也称为"间谍",因为它们允许您监视由其他代码间接调用的函数的行为,而不仅仅是测试输出。通过jest.fn
创建模拟函数,可以捕获函数的调用、参数等数据。如果没有给出实现,则模拟函数将undefined
在调用时返回。
创建模拟函数
创建模拟函数可以通过jest.fn()
实现。
javascript
const mockFn = jest.fn((num) => 7 + num);
console.log(mockFn(1)); // 8
console.log(mockFn(2)); // 9
mockFn.mock
所有模拟函数都有这个特殊.mock
属性,其中保存了有关函数如何被调用以及函数返回的数据等等。该.mock
属性还跟踪每次调用的值this
,因此也可以检查它。
mockFn.mock.calls
包含对此模拟函数进行的所有调用的调用参数的数组
javascript
test("fn", () => {
const myJestFn = jest.fn((a, b) => a + b);
const result = myJestFn(1, 2);
myJestFn(result, 3);
console.log(myJestFn.mock.calls); // [ [ 1, 2 ], [ 3, 3 ] ]
// 第一次调用传递的参数是1和2,第二次调用传递的参数是3和3
});
mockFn.mock.lastCall
包含对此模拟函数进行的最后一次调用的调用参数的数组。
javascript
test("fn", () => {
const myJestFn = jest.fn((a, b) => a + b);
const result = myJestFn(1, 2);
myJestFn(result, 3);
// 包含对此模拟函数进行的最后一次调用的调用参数的数组。
console.log(myJestFn.mock.lastCall); // [3, 3]
});
myJestFn.mock.results
包含对此 mock 函数进行的所有调用的结果的数组。
数组的每一项是一个对象,对象包括
- type:type有三个值
- return:表示调用正常返回,完成。
- throw:表示调用正常返回,完成。
- incomplete:表示调用尚未完成。
- value:表示返回的值
type为'return'
javascript
test("fn", () => {
const myJestFn = jest.fn((a, b) => a + b);
const result = myJestFn(1, 2);
myJestFn(result, 3);
console.log(myJestFn.mock.results); // [ { type: 'return', value: 3 }, { type: 'return', value: 6 } ]
});
type为'throw'
javascript
test("fn", () => {
const myJestFn = jest.fn(() => {
throw new Error("This is a mock error");
});
// 捕获异常以避免测试直接失败
try {
myJestFn();
} catch (e) {
// 忽略异常
}
console.log(myJestFn.mock.results);
/*
[
{
type: 'throw',
value: Error: This is a mock error
at ...
...
...
}
]
*/
});
mockFn.mock.contexts
包含模拟函数所有调用的上下文的数组。上下文是this
函数调用时接收的值。
可以使用Function.prototype.bind
、Function.prototype.call
或来设置上下文Function.prototype.apply
。
javascript
test("fn", () => {
const AContext = { name: "A" };
const BContext = { name: "B" };
const CContext = { name: "C" };
const mockFn = jest.fn((a) => this.name + a);
mockFn();
mockFn.bind(AContext)("a");
mockFn.call(BContext, "b");
mockFn.apply(CContext, ["c"]);
console.log(mockFn.mock.contexts);
// [ undefined, { name: 'A' }, { name: 'B' }, { name: 'C' } ]
});
mockFn.mock.instances
包含使用此模拟函数实例化的所有对象实例的数组new
。
javascript
test("fn", () => {
const mockFn = jest.fn();
const A = new mockFn();
const B = new mockFn();
expect(mockFn.mock.instances[0]).toBe(A);
expect(mockFn.mock.instances[1]).toBe(B);
});
模拟返回值
mockFn.mockReturnValue()
可以使用mockFn.mockReturnValue()
设置模拟函数调用时返回的固定值。
javascript
test("mockReturnValue", () => {
const mockFn = jest.fn(() => 10);
console.log(mockFn()); // 10
mockFn.mockReturnValue(17);
console.log(mockFn()); // 17
console.log(mockFn()); // 17
});
mockFn.mockReturnValueOnce()
如果想要多次调用返回不同的模拟返回值,则可以使用mockFn.mockReturnValueOnce()
javascript
test("mockReturnValueOnce", () => {
const mockFn = jest
.fn(() => 10)
.mockReturnValueOnce(17)
.mockReturnValueOnce(18)
.mockReturnValueOnce(19);
console.log(mockFn()); // 17
console.log(mockFn()); // 18
console.log(mockFn()); // 19
console.log(mockFn()); // 10
console.log(mockFn()); // 10
});
如上面的例子所示,当没有更多的"mockReturnValueOnce"值可用后,将返回原先的返回值。
模拟返回值在实际测试中十分有用,例如当我们在模拟时需要依赖另一个函数,可以使用"mockReturnValue"来模拟依赖函数的返回,从而隔离测试逻辑;或在模拟第三方库时,有时需要模拟第三方库的函数或方法,从而避免实际调用库的逻辑等等
假如有个需要使用axios
调用API获取用户数据的逻辑,为了不实际访问API,我们可以使用jest.mock...
函数自动模拟axios模块,然后用mockReturnValue
返回我们想要测试断言的数据,也就是说我们只需要一个假的响应。
javascript
import axios from "axios";
import { fetchUser } from "./userService";
jest.mock("axios");
test("fetchUser returns mock user data", async () => {
axios.get.mockReturnValue(Promise.resolve({ data: { id: 1, name: "John" } })); // 模拟返回值
const user = await fetchUser(1);
expect(user).toEqual({ id: 1, name: "John" });
});
jest.mock 模拟模块
上面例子中用到了jest.mock()
,jest.mock()
是 Jest 中用于创建模块或文件的模拟(mock)的函数。它允许你在测试中用自定义实现或伪造模块替换实际的依赖模块,方便隔离测试逻辑,控制模块行为。
jest.mock(moduleName, factory, options)
- moduleName: 要模拟的模块的名称或路径。
- factory (可选): 一个返回模块模拟实现的函数。
- options (可选): 配置模拟的选项,例如是否自动清除模拟。
默认情况下,jest.mock()
会为指定模块创建一个空的自动模拟对象。
javascript
jest.mock('axios'); // 自动创建模拟
import axios from 'axios';
可以通过工厂函数为模拟模块提供自定义实现。
javascript
// foo.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
javascript
import foo from "./foo.js";
jest.mock("./foo.js", () => ({
add: jest.fn(() => 42),
subtract: jest.fn(() => -1),
}));
test("mock", () => {
expect(foo.add(1, 2)).toBe(42);
expect(foo.subtract(3, 2)).toBe(-1);
});
也可以只部分模拟部分
javascript
// foo.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export default () => "foo";
javascript
import foo, { add, subtract } from "./foo.js";
jest.mock("./foo.js", () => {
const actual = jest.requireActual("./foo.js"); // 通过jest.requireActual 引用实际模块
return {
__esModule: true,
...actual,
default: jest.fn(() => "mocked foo"),
subtract: jest.fn(() => -1),
};
});
test("mock", () => {
expect(foo()).toBe("mocked foo");
expect(add(1, 2)).toBe(3);
expect(subtract(3, 2)).toBe(-1);
});
模拟实现
除了上面的模拟返回值,有些情况下,超越指定返回值的能力并完全替换模拟函数的实现是有用的。
mockFn.mockImplementation(fn)
mockFn.mockImplementation(fn),接受一个应该用作模拟实现的函数。模拟本身仍将记录进入它的所有调用和来自它自身的实例。
javascript
test("mockFn.mockImplementation", () => {
const mockFn = jest.fn((num) => {
return 7 + num;
});
console.log(mockFn(1)); // 8
console.log(mockFn(2)); // 9
mockFn.mockImplementation((num) => 17 + num);
console.log(mockFn(1)); // 18
console.log(mockFn(2)); // 19
});
当你需要定义从另一个模块创建的模拟函数的默认实现时,该方法很有用。
javascript
// foo.js
module.exports = function () {
return "foo";
};
javascript
test("foo.mockImplementation", () => {
jest.mock("./foo");
const foo = require("./foo");
foo.mockImplementation(() => "bar");
console.log(foo()); // bar
});
mockFn.mockImplementationOnce(fn)
当你需要重新创建模拟函数的复杂行为,使得多个函数调用产生不同的结果时,可以使用mockFn.mockImplementationOnce(fn)
,其接受一个函数,该函数将用作对模拟函数的一次调用的模拟实现。可以链接起来,以便多个函数调用产生不同的结果。
javascript
test("mockFn.mockImplementationOnce", () => {
const myMockFn = jest
.fn(() => "default")
.mockImplementationOnce(() => "first call")
.mockImplementationOnce(() => "second call");
console.log(myMockFn()); // first call
console.log(myMockFn()); // second call
console.log(myMockFn()); // default
});
匹配器
为了减少断言模拟函数如何被调用的难度,jest为模拟函数提供了一些方便的匹配器
toHaveBeenCalled
用于确保模拟函数至少被调用一次
javascript
test("toHaveBeenCalled", () => {
const myMockFn = jest.fn((a, b) => a + b);
expect(myMockFn).not.toHaveBeenCalled();
myMockFn(1, 2);
expect(myMockFn).toHaveBeenCalled();
// 也可以用以下的简化版(.mock)写法,效果一样
expect(myMockFn.mock.calls.length).toBeGreaterThan(0);
});
toHaveBeenCalledWith
用于确保模拟函数使用指定的参数至少调用模拟函数一次
javascript
test("toHaveBeenCalledWith", () => {
const myMockFn = jest.fn((a, b) => a + b);
myMockFn(1, 2);
expect(myMockFn).not.toHaveBeenCalledWith(1, 3);
expect(myMockFn).toHaveBeenCalledWith(1, 2);
// 也可以用以下的简化版(.mock)写法,效果一样
expect(myMockFn.mock.calls).toContainEqual([1, 2]);
});
toHaveBeenLastCalledWith
用于确保对mock函数的最后一次调用是使用指定的参数调用的
javascript
test("toHaveBeenLastCalledWith", () => {
const myMockFn = jest.fn((a, b) => a + b);
myMockFn(1, 2);
myMockFn(3, 4);
expect(myMockFn).toHaveBeenLastCalledWith(3, 4);
// 也可以用以下的简化版(.mock)写法,效果一样
expect(myMockFn.mock.calls[myMockFn.mock.calls.length - 1]).toEqual([3, 4]);
});
持续监听
为了提高效率,可以在jest命令添加启动参数的方式让jest持续监听文件的修改而触发执行测试用例,这样就不用每次修改完再重新执行测试用例了。
只需要修改以下命令
javascript
"scripts": {
"test": "jest --watchAll"
},
这样运行后,就会
生成测试覆盖率报告
单元测试覆盖率(Unit Test Coverage)是一个指标,用来衡量你的代码在单元测试中被测试的程度。它帮助开发者了解在项目中哪些代码已经被测试过,哪些代码可能还存在未测试的风险区域。
测试覆盖率通常以百分比表示。它基于代码的不同维度来计算,例如:语句覆盖率、分支覆盖率、函数覆盖率等。
其中最基础的计算方式为 单元测试覆盖率 = 被测代码行数 / 参测代码总行数 * 100%
只需要在jest.config.js
中添加显示覆盖率报告的配置即可
javascript
module.exports = {
collectCoverage: true,
};
运行后如下
图中每个参数的含义如下
- % Stmts:语句覆盖率,确保每个语句都执行了
- % Branch:分支覆盖率,确保每个 if 代码块都执行了
- % Funcs:函数覆盖率,确保每个函数都调用了
- % Lines :行覆盖率,确保每一行都执行了
也可以在jest.config.js
中设置每个参数的覆盖率阀值,来提示用户是否测试质量达标。
javascript
module.exports = {
collectCoverage: true,
coverageThreshold: {
global: {
statements: 90, // 保证所有语句至少执行了90%
functions: 90, // 保证所有函数至少调用了90%
branches: 90, // 保证所有分支至少执行了90%
lines: 90, // 保证所有代码行至少执行了90%
},
},
};
运行结果如下