name: jest-advanced user-invocable: false description: 使用高级Jest功能,包括自定义匹配器、使用test.each进行参数化测试、覆盖率配置和性能优化。 allowed-tools: [Read, Write, Edit, Bash, Glob, Grep]
Jest 高级
掌握高级Jest功能,包括自定义匹配器、使用test.each进行参数化测试、覆盖率配置和性能优化。此技能涵盖复杂场景和大型测试套件的复杂测试技术。
自定义匹配器
创建自定义匹配器
// matchers/toBeWithinRange.js
export function toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
if (pass) {
return {
message: () =>
`expected ${received} not to be within range ${floor} - ${ceiling}`,
pass: true
};
} else {
return {
message: () =>
`expected ${received} to be within range ${floor} - ${ceiling}`,
pass: false
};
}
}
// jest.setup.js
import { toBeWithinRange } from './matchers/toBeWithinRange';
expect.extend({
toBeWithinRange
});
// 测试文件
describe('自定义匹配器', () => {
it('应检查数字是否在范围内', () => {
expect(5).toBeWithinRange(1, 10);
expect(15).not.toBeWithinRange(1, 10);
});
});
异步自定义匹配器
// matchers/toResolveWithin.js
export async function toResolveWithin(received, timeout) {
const startTime = Date.now();
try {
await received;
const duration = Date.now() - startTime;
const pass = duration <= timeout;
return {
message: () =>
pass
? `expected promise not to resolve within ${timeout}ms (resolved in ${duration}ms)`
: `expected promise to resolve within ${timeout}ms (took ${duration}ms)`,
pass
};
} catch (error) {
return {
message: () => `expected promise to resolve but it rejected with ${error}`,
pass: false
};
}
}
// 用法
expect.extend({ toResolveWithin });
it('应快速解析', async () => {
await expect(fetchData()).toResolveWithin(1000);
});
类型安全自定义匹配器 (TypeScript)
// matchers/index.ts
interface CustomMatchers<R = unknown> {
toBeWithinRange(floor: number, ceiling: number): R;
toHaveValidEmail(): R;
}
declare global {
namespace jest {
interface Expect extends CustomMatchers {}
interface Matchers<R> extends CustomMatchers<R> {}
interface InverseAsymmetricMatchers extends CustomMatchers {}
}
}
export function toBeWithinRange(
this: jest.MatcherContext,
received: number,
floor: number,
ceiling: number
): jest.CustomMatcherResult {
const pass = received >= floor && received <= ceiling;
return {
message: () =>
pass
? `expected ${received} not to be within range ${floor} - ${ceiling}`
: `expected ${received} to be within range ${floor} - ${ceiling}`,
pass
};
}
export function toHaveValidEmail(
this: jest.MatcherContext,
received: string
): jest.CustomMatcherResult {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const pass = emailRegex.test(received);
return {
message: () =>
pass
? `expected ${received} not to be a valid email`
: `expected ${received} to be a valid email`,
pass
};
}
// jest.setup.ts
import * as matchers from './matchers';
expect.extend(matchers);
参数化测试
使用数组的test.each
describe('加法', () => {
test.each([
[1, 1, 2],
[1, 2, 3],
[2, 1, 3],
])('add(%i, %i) 应等于 %i', (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
});
使用对象的test.each
describe('用户验证', () => {
test.each([
{ email: 'test@example.com', valid: true },
{ email: 'invalid', valid: false },
{ email: 'test@', valid: false },
{ email: '@example.com', valid: false },
])('validateEmail($email) 应返回 $valid', ({ email, valid }) => {
expect(validateEmail(email)).toBe(valid);
});
});
使用模板字面量的test.each
describe('字符串操作', () => {
test.each`
input | method | expected
${'hello'} | ${'upper'} | ${'HELLO'}
${'WORLD'} | ${'lower'} | ${'world'}
${'HeLLo'} | ${'title'} | ${'Hello'}
`('transform $input 使用 $method 应返回 $expected',
({ input, method, expected }) => {
expect(transform(input, method)).toBe(expected);
}
);
});
describe.each用于多个测试套件
describe.each([
{ browser: 'Chrome', version: 90 },
{ browser: 'Firefox', version: 88 },
{ browser: 'Safari', version: 14 },
])('浏览器兼容性 - $browser', ({ browser, version }) => {
it(`应支持 ${browser} 版本 ${version}`, () => {
expect(isSupported(browser, version)).toBe(true);
});
it(`应处理 ${browser} 特定功能`, () => {
expect(getFeatures(browser)).toBeDefined();
});
});
覆盖率配置
高级覆盖率设置
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}',
'!src/**/__tests__/**',
'!src/**/types/**',
'!src/index.{js,ts}',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
'./src/core/': {
branches: 90,
functions: 95,
lines: 95,
statements: 95
},
'./src/utils/': {
branches: 85,
functions: 90,
lines: 90,
statements: 90
}
},
coveragePathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/build/',
'/__mocks__/',
'/coverage/'
]
};
自定义覆盖率报告器
// custom-reporter.js
class CustomCoverageReporter {
constructor(globalConfig, options) {
this._globalConfig = globalConfig;
this._options = options;
}
onRunComplete(contexts, results) {
const { coverageMap } = results;
if (!coverageMap) {
return;
}
const summary = coverageMap.getCoverageSummary();
const metrics = {
lines: summary.lines.pct,
statements: summary.statements.pct,
functions: summary.functions.pct,
branches: summary.branches.pct
};
console.log('
覆盖率总结:');
console.log(`行: ${metrics.lines}%`);
console.log(`语句: ${metrics.statements}%`);
console.log(`函数: ${metrics.functions}%`);
console.log(`分支: ${metrics.branches}%`);
// 发送到外部服务
if (this._options.webhook) {
this.sendToWebhook(metrics, this._options.webhook);
}
}
async sendToWebhook(metrics, url) {
// 实现
}
}
module.exports = CustomCoverageReporter;
// jest.config.js
module.exports = {
coverageReporters: [
'text',
['./custom-reporter.js', { webhook: 'https://example.com/coverage' }]
]
};
性能优化
并行运行测试
// jest.config.js
module.exports = {
maxWorkers: '50%', // 使用可用CPU核心的50%
// 或指定数字
// maxWorkers: 4,
// 在文件内并行化测试
maxConcurrency: 5,
// 缓存目录
cacheDirectory: '.jest-cache',
// 用于调试的串行运行测试
// runInBand: false
};
选择性测试执行
// 只运行更改的测试
// package.json
{
"scripts": {
"test:changed": "jest --onlyChanged",
"test:related": "jest --findRelatedTests src/modified-file.js"
}
}
用于CI的测试分片
# 在多个CI机器上分割测试
jest --shard=1/3 # 运行前三分之一
jest --shard=2/3 # 运行中间三分之一
jest --shard=3/3 # 运行后三分之一
首次失败时停止
// jest.config.js
module.exports = {
bail: 1, // 首次失败后停止
// bail: true, // 任何失败后停止
};
高级模拟
具有不同返回值的模拟实现
describe('复杂模拟', () => {
it('应在连续调用中返回不同值', () => {
const mockFn = jest
.fn()
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(3);
expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);
expect(mockFn()).toBe(3);
expect(mockFn()).toBe(3);
});
it('应实现复杂逻辑', () => {
const mockFn = jest.fn((x) => {
if (x < 0) return 'negative';
if (x === 0) return 'zero';
return 'positive';
});
expect(mockFn(-5)).toBe('negative');
expect(mockFn(0)).toBe('zero');
expect(mockFn(5)).toBe('positive');
});
});
模拟模块工厂
// dynamic-mock.test.js
jest.mock('./api', () => {
const actual = jest.requireActual('./api');
return {
...actual,
fetchUser: jest.fn(),
// 使用工厂获取动态值
getDefaultUser: jest.fn(() => ({
id: Math.random(),
name: 'Test User'
}))
};
});
describe('动态模拟', () => {
it('应生成不同的默认用户', () => {
const user1 = getDefaultUser();
const user2 = getDefaultUser();
expect(user1.id).not.toBe(user2.id);
});
});
模拟ES6类
// Database.js
export class Database {
constructor(config) {
this.config = config;
}
async connect() {
// 实现
}
async query(sql) {
// 实现
}
}
// Database.test.js
import { Database } from './Database';
jest.mock('./Database');
describe('数据库模拟', () => {
beforeEach(() => {
Database.mockClear();
});
it('应模拟类构造函数', () => {
const mockConnect = jest.fn();
const mockQuery = jest.fn().mockResolvedValue([{ id: 1 }]);
Database.mockImplementation(() => ({
connect: mockConnect,
query: mockQuery
}));
const db = new Database({ host: 'localhost' });
expect(Database).toHaveBeenCalledWith({ host: 'localhost' });
db.connect();
expect(mockConnect).toHaveBeenCalled();
});
});
高级断言
非对称匹配器
describe('非对称匹配器', () => {
it('应匹配对象的一部分', () => {
const user = {
id: 1,
name: 'John',
email: 'john@example.com',
createdAt: new Date()
};
expect(user).toEqual({
id: expect.any(Number),
name: 'John',
email: expect.stringContaining('@'),
createdAt: expect.any(Date)
});
});
it('应匹配数组包含', () => {
const arr = [1, 2, 3, 4, 5];
expect(arr).toEqual(expect.arrayContaining([2, 4]));
});
it('应匹配对象包含', () => {
const obj = { a: 1, b: 2, c: 3 };
expect(obj).toEqual(expect.objectContaining({ a: 1, c: 3 }));
});
it('应使用自定义匹配器', () => {
expect({ a: 1, b: 2 }).toEqual({
a: expect.any(Number),
b: expect.any(Number)
});
});
});
复杂断言
describe('复杂断言', () => {
it('应验证复杂数据结构', () => {
const response = {
status: 200,
data: {
users: [
{ id: 1, name: 'John', roles: ['admin'] },
{ id: 2, name: 'Jane', roles: ['user'] }
],
meta: {
total: 2,
page: 1
}
}
};
expect(response).toMatchObject({
status: 200,
data: {
users: expect.arrayContaining([
expect.objectContaining({
name: 'John',
roles: expect.arrayContaining(['admin'])
})
]),
meta: {
total: expect.any(Number)
}
}
});
});
});
测试隔离和清理
重置模块注册表
describe('模块隔离', () => {
beforeEach(() => {
jest.resetModules();
});
it('应加载新模块实例', () => {
const module1 = require('./counter');
module1.increment();
expect(module1.getCount()).toBe(1);
jest.resetModules();
const module2 = require('./counter');
expect(module2.getCount()).toBe(0);
});
});
清除、重置和恢复模拟
describe('模拟清理', () => {
const mockFn = jest.fn();
beforeEach(() => {
mockFn.mockReturnValue(42);
});
afterEach(() => {
// jest.clearAllMocks(); // 清除调用历史
// jest.resetAllMocks(); // 清除调用历史和实现
// jest.restoreAllMocks(); // 恢复原始实现
});
it('应理解模拟清理', () => {
mockFn();
expect(mockFn).toHaveBeenCalledTimes(1);
// clearAllMocks: 移除调用历史但保留实现
jest.clearAllMocks();
expect(mockFn).toHaveBeenCalledTimes(0);
expect(mockFn()).toBe(42); // 实现仍有效
// resetAllMocks: 移除调用历史和实现
jest.resetAllMocks();
expect(mockFn()).toBeUndefined(); // 无实现
// restoreAllMocks: 恢复原始实现(用于间谍)
const obj = { method: () => 'original' };
const spy = jest.spyOn(obj, 'method');
spy.mockReturnValue('mocked');
expect(obj.method()).toBe('mocked');
jest.restoreAllMocks();
expect(obj.method()).toBe('original');
});
});
测试策略
契约测试
// 定义契约
const userContract = {
id: expect.any(Number),
name: expect.any(String),
email: expect.stringMatching(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
roles: expect.arrayContaining([expect.any(String)]),
createdAt: expect.any(String)
};
describe('用户API契约', () => {
it('应匹配用户契约', async () => {
const user = await fetchUser(1);
expect(user).toMatchObject(userContract);
});
it('应匹配用户列表契约', async () => {
const users = await fetchUsers();
expect(users).toEqual(
expect.arrayContaining([
expect.objectContaining(userContract)
])
);
});
});
数据驱动测试
const testCases = require('./test-data.json');
describe.each(testCases)('数据驱动测试', (testCase) => {
it(`应处理 ${testCase.description}`, () => {
const result = processData(testCase.input);
expect(result).toEqual(testCase.expected);
});
});
最佳实践
- 为领域特定断言使用自定义匹配器 - 为常见验证模式创建可重用匹配器
- 利用test.each进行参数化测试 - 减少重复并提高测试覆盖率
- 按目录配置覆盖率阈值 - 为关键代码路径设置更严格的要求
- 使用工作者优化测试执行 - 使用并行执行加快测试运行
- 实现适当的模拟清理 - 理解清除、重置和恢复之间的区别
- 使用非对称匹配器进行灵活断言 - 匹配部分对象和动态数据
- 为CI集成创建自定义报告器 - 将覆盖率数据发送到外部服务
- 使用模块重置隔离测试 - 防止共享模块状态导致的测试污染
- 使用bail获取快速反馈 - 在开发中首次失败时停止
- 实现契约测试 - 确保API响应匹配预期形状
常见陷阱
- 未正确清理模拟 - 使用错误的清理方法导致测试污染
- 过度参数化测试 - 过多test.each案例降低可读性
- 设置不切实际的覆盖率阈值 - 100%覆盖率要求减慢开发速度
- 未适当使用maxWorkers - 过多工作者会使CI系统不堪重负
- 忘记重置模块 - 测试之间的共享状态导致不稳定失败
- 创建过于复杂的自定义匹配器 - 保持匹配器简单和专注
- 在TypeScript中未键入自定义匹配器 - 缺少类型失去IDE支持
- 误用非对称匹配器 - 过于宽松的匹配器错过错误
- 忽略覆盖率报告 - 不根据覆盖率差距采取行动降低测试价值
- 每次更改都运行完整套件 - 使用–onlyChanged获取更快反馈
何时使用此技能
- 为领域特定验证创建自定义匹配器
- 实现具有许多测试用例的参数化测试
- 为大型项目优化测试套件性能
- 为CI/CD设置覆盖率要求
- 实施高级模拟策略
- 使用非对称匹配器测试复杂数据结构
- 为外部集成创建自定义报告器
- 调试测试隔离问题
- 实施契约测试模式
- 提高测试可维护性并减少重复