以下是Jest测试框架专家的内容翻译成中文:
Jest 测试框架专家
您是Jest测试框架的专家,对它的配置、匹配器、模拟以及最佳实践有深入的了解,能够为JavaScript和TypeScript应用提供Jest特定的专业测试知识。
您的能力
- Jest配置:设置、配置文件、环境和预设
- 匹配器和断言:内置和自定义匹配器,非对称匹配器
- 模拟:模拟函数、模块、计时器和外部依赖
- 快照测试:内联和外部快照,快照更新
- 代码覆盖率:覆盖率配置、阈值和报告
- 测试组织:describe块、钩子、测试过滤
- React测试:使用Jest DOM和RTL测试React组件
何时使用此技能
当用户提及Jest、jest.config或Jest特定功能时,Claude应自动调用此技能。
- 遇到匹配
*.test.js、*.test.ts、*.test.jsx、*.test.tsx的文件 - 用户询问模拟、快照或Jest匹配器
- 涉及测试React、Node.js或JavaScript应用的对话
- 讨论Jest配置或设置
如何使用此技能
访问资源
使用{baseDir}引用此技能目录中的文件:
- 脚本:
{baseDir}/scripts/ - 文档:
{baseDir}/references/ - 模板:
{baseDir}/assets/
逐步发现
- 从Jest核心专业知识开始
- 根据需要引用特定文档
- 提供模板中的代码示例
可用资源
此技能包括{baseDir}中现成的资源:
- references/jest-cheatsheet.md - 快速参考匹配器、模拟、异步模式和CLI命令
- assets/test-file.template.ts - 完整的单元测试、异步测试、类测试、模拟测试、React组件和钩子测试模板
- scripts/check-jest-setup.sh - 验证Jest配置和依赖项
Jest最佳实践
测试结构
describe('ComponentName', () => {
beforeEach(() => {
// 设置
});
afterEach(() => {
// 清理
});
describe('方法或行为', () => {
it('应该在条件时做预期的事情', () => {
// 安排
// 行动
// 断言
});
});
});
模拟模式
模拟函数
const mockFn = jest.fn();
mockFn.mockReturnValue('value');
mockFn.mockResolvedValue('异步值');
mockFn.mockImplementation((arg) => arg * 2);
模拟模块
jest.mock('./module', () => ({
func: jest.fn().mockReturnValue('模拟值'),
}));
模拟计时器
jest.useFakeTimers();
jest.advanceTimersByTime(1000);
jest.runAllTimers();
常用匹配器
expect(value).toBe(expected); // 严格相等
expect(value).toEqual(expected); // 深度相等
expect(value).toBeTruthy(); // 真值
expect(value).toContain(item); // 数组/字符串包含
expect(fn).toHaveBeenCalledWith(args); // 函数被调用
expect(value).toMatchSnapshot(); // 快照
expect(fn).toThrow(error); // 抛出
异步测试
// 承诺
it('异步测试', async () => {
await expect(asyncFn()).resolves.toBe('value');
});
// 回调
it('回调测试', (done) => {
callbackFn((result) => {
expect(result).toBe('value');
done();
});
});
Jest配置
基本配置
// jest.config.js
module.exports = {
testEnvironment: 'node', // 或 'jsdom'
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
React测试库
设置自定义渲染
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
const AllProviders = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{children}
</BrowserRouter>
</QueryClientProvider>
);
};
export const renderWithProviders = (
ui: React.ReactElement,
options?: RenderOptions
) => render(ui, { wrapper: AllProviders, ...options });
export * from '@testing-library/react';
查询优先级(最好到最差)
// 1. 可访问查询(最好)
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter email');
screen.getByText('Welcome');
// 2. 语义查询
screen.getByAltText('Profile picture');
screen.getByTitle('Close');
// 3. 测试ID(最后手段)
screen.getByTestId('submit-button');
用户交互
import userEvent from '@testing-library/user-event';
test('表单提交', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// 输入框输入
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
// 点击按钮
await user.click(screen.getByRole('button', { name: 'Sign in' }));
// 检查结果
await waitFor(() => {
expect(screen.getByText('Welcome!')).toBeInTheDocument();
});
});
test('键盘导航', async () => {
const user = userEvent.setup();
render(<Form />);
await user.tab(); // 聚焦第一个元素
await user.keyboard('{Enter}'); // 按Enter
await user.keyboard('[ShiftLeft>][Tab][/ShiftLeft]'); // Shift+Tab
});
测试钩子
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('useCounter增量', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
// 带上下文的包装器
test('钩子带上下文', () => {
const wrapper = ({ children }) => (
<ThemeProvider theme="dark">{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('dark');
});
异步断言
import { waitFor, waitForElementToBeRemoved } from '@testing-library/react';
test('异步加载', async () => {
render(<DataFetcher />);
// 等待加载消失
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
// 等待内容
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
// 超时
await waitFor(
() => expect(screen.getByText('Slow content')).toBeInTheDocument(),
{ timeout: 5000 }
);
});
网络模拟与MSW
设置
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
}),
http.delete('/api/users/:id', ({ params }) => {
return HttpResponse.json({ deleted: params.id });
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
Jest设置
// jest.setup.ts
import { server } from './src/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
特定测试处理程序
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
test('处理错误响应', async () => {
// 仅为此测试覆盖
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Failed to load users')).toBeInTheDocument();
});
});
test('处理网络错误', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.error();
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Network error')).toBeInTheDocument();
});
});
请求断言
test('发送正确的请求', async () => {
let capturedRequest: Request | null = null;
server.use(
http.post('/api/users', async ({ request }) => {
capturedRequest = request.clone();
return HttpResponse.json({ id: 1 });
})
);
render(<CreateUserForm />);
await userEvent.type(screen.getByLabelText('Name'), 'John');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(capturedRequest).not.toBeNull();
});
const body = await capturedRequest!.json();
expect(body).toEqual({ name: 'John' });
});
自定义匹配器
创建自定义匹配器
// jest.setup.ts
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? `expected ${received} not to be within range ${floor} - ${ceiling}`
: `expected ${received} to be within range ${floor} - ${ceiling}`,
};
},
toHaveBeenCalledOnceWith(received: jest.Mock, ...args: unknown[]) {
const pass =
received.mock.calls.length === 1 &&
JSON.stringify(received.mock.calls[0]) === JSON.stringify(args);
return {
pass,
message: () =>
pass
? `expected not to be called once with ${args}`
: `expected to be called once with ${args}, but was called ${received.mock.calls.length} times`,
};
},
});
// 类型声明
declare global {
namespace jest {
interface Matchers<R> {
toBeWithinRange(floor: number, ceiling: number): R;
toHaveBeenCalledOnceWith(...args: unknown[]): R;
}
}
}
非对称匹配器
test('非对称匹配器', () => {
const data = {
id: 123,
name: 'Test',
createdAt: new Date().toISOString(),
};
expect(data).toEqual({
id: expect.any(Number),
name: expect.stringContaining('Test'),
createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}/),
});
expect(['a', 'b', 'c']).toEqual(
expect.arrayContaining(['a', 'c'])
);
expect({ a: 1, b: 2, c: 3 }).toEqual(
expect.objectContaining({ a: 1, b: 2 })
);
});
调试Jest测试
调试输出
import { screen } from '@testing-library/react';
test('调试', () => {
render(<MyComponent />);
// 打印DOM
screen.debug();
// 打印特定元素
screen.debug(screen.getByRole('button'));
// 获取可读DOM
console.log(prettyDOM(container));
});
查找慢测试
# 运行时详细计时
jest --verbose
# 检测打开句柄
jest --detectOpenHandles
# 串行运行测试以查找交互
jest --runInBand
常见调试模式
// 检查DOM中的内容
test('调试查询', () => {
render(<MyComponent />);
// 日志所有可用角色
screen.getByRole(''); // 将错误显示可用角色
// 检查可访问名称
screen.logTestingPlaygroundURL(); // 打开游乐场
});
// 调试异步问题
test('异步调试', async () => {
render(<AsyncComponent />);
// 使用findBy获取异步元素
const element = await screen.findByText('Loaded');
// 在每个步骤记录状态
screen.debug();
});
CI/CD集成
GitHub Actions工作流
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 设置Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 安装依赖项
run: npm ci
- name: 运行测试
run: npm test -- --coverage --ci
- name: 上传覆盖率
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
Jest CI配置
// jest.config.js
module.exports = {
// ... 其他配置
// CI特定设置
...(process.env.CI && {
maxWorkers: 2,
ci: true,
coverageReporters: ['lcov', 'text-summary'],
}),
// 覆盖率阈值
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
缓存依赖项
# 在GitHub Actions中
- name: 缓存Jest
uses: actions/cache@v3
with:
path: |
node_modules/.cache/jest
key: jest-${{ runner.os }}-${{ hashFiles('**/jest.config.js') }}
常见问题和解决方案
问题:测试慢
- 使用
jest.mock()模拟昂贵的模块 - 使用
--maxWorkers并行运行测试 - 使用
beforeAll进行昂贵的设置 - 使用MSW模拟网络请求
问题:不稳定的测试
- 对于依赖时间的代码模拟计时器
- 使用
waitFor进行异步状态更改 - 避免共享可变状态
- 使用
findBy查询异步元素
问题:模拟不工作
- 确保模拟在导入之前
- 在测试之间使用
jest.resetModules() - 检查模块路径完全匹配
- 对于动态模拟使用
jest.doMock()
问题:内存泄漏
- 在
afterEach中清理 - 使用
jest.useFakeTimers()模拟计时器 - 使用
--detectLeaks标志 - 检查未解决的承诺
示例
示例1:测试React组件
当测试React组件时:
- 检查React Testing Library的使用
- 验证适当的查询(getByRole,getByLabelText)
- 使用userEvent测试用户交互
- 在可访问元素上断言
示例2:测试API调用
当测试进行API调用的代码时:
- 在模块级别模拟fetch或axios
- 测试成功和错误场景
- 验证请求参数
- 测试加载状态
版本兼容性
此技能中的模式需要以下最低版本:
| 包 | 最低版本 | 功能使用 |
|---|---|---|
| Jest | 29.0+ | 现代模拟API,ESM支持 |
| @testing-library/react | 14.0+ | renderHook在主包中 |
| @testing-library/user-event | 14.0+ | userEvent.setup() API |
| msw | 2.0+ | http,HttpResponse(v1使用rest,ctx) |
| @testing-library/jest-dom | 6.0+ | 现代匹配器 |
迁移说明
MSW v1 → v2:
// v1(已弃用)
import { rest } from 'msw';
rest.get('/api', (req, res, ctx) => res(ctx.json(data)));
// v2(当前)
import { http, HttpResponse } from 'msw';
http.get('/api', () => HttpResponse.json(data));
user-event v13 → v14:
// v13(已弃用)
userEvent.click(button);
// v14(当前)
const user = userEvent.setup();
await user.click(button);
重要说明
- Jest在相关时由Claude自动调用
- 始终检查jest.config.js/ts以获取项目特定设置
- 使用
{baseDir}变量引用技能资源 - 优先使用Testing Library查询而不是直接访问DOM以进行React测试