关于 jest-typescript
jest-typescript 是为 TypeScript 项目配置 Jest 测试的技能,涵盖测试模式、React 测试、模拟、快照和覆盖率报告。
主要功能
- 01使用 ts-jest 配置 Jest
- 02编写类型安全的测试
- 03模拟模块、方法和计时器
- 04测试 React 组件与 Hook
- 05生成快照和覆盖率报告
- 06将测试从 Vitest 迁移
适用场景
- 01在 TypeScript 项目中配置 Jest
- 02使用 Testing Library 测试 React 组件
- 03测量覆盖率或替换 Vitest
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ bobmatnyc/ claude-mpm-skills --skill jest-typescript安装后在 Claude Code 中输入/jest-typescript确认已加载。
附带文件metadata.json
详细介绍
Jest + TypeScript - 行业标准测试
概述
Jest 是行业标准测试框架,市场占有率达 70%,为 TypeScript 项目提供成熟且经过实战检验的生态系统。它提供全面的测试能力,内置快照测试、模拟和覆盖率报告功能。
主要特性:
- 🏆 行业标准:市场占有率达 70%,广泛采用
- 📦 一体化:在一个包中提供测试运行器、断言、模拟和覆盖率功能
- 📸 快照测试:内置快照支持,可用于 UI 测试
- 🧪 React 集成:兼容 React Testing Library、enzyme
- 🔧 成熟的生态系统:丰富的插件、工具和社区支持
- 🎯 TypeScript 支持:通过 ts-jest 提供完整的类型安全
- 🔍 覆盖率报告:内置 Istanbul 覆盖率功能
- 🌐 多平台支持:Node.js、浏览器(jsdom)、React Native
安装:
npm install -D jest @types/jest ts-jest
npm install -D @testing-library/react @testing-library/jest-dom # For React
基本设置
1. 初始化 Jest 配置
npx ts-jest config:init
这会创建 jest.config.js:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
2. 手动配置
jest.config.ts(TypeScript 配置):
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.test.{ts,tsx}',
'!src/**/__tests__/**',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
export default config;
3. TypeScript 配置
tsconfig.json:
{
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom"],
"esModuleInterop": true
}
}
tsconfig.test.json(测试专用):
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node", "@testing-library/jest-dom"]
},
"include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/__tests__/**"]
}
4. Package.json 脚本
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --maxWorkers=2"
}
}
核心测试模式
基本测试结构
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
});
afterEach(() => {
// Cleanup
});
it('adds two numbers correctly', () => {
const result = calculator.add(2, 3);
expect(result).toBe(5);
});
it('handles negative numbers', () => {
expect(calculator.add(-5, 3)).toBe(-2);
});
it.each([
[1, 1, 2],
[2, 3, 5],
[10, -5, 5],
])('adds %i + %i to equal %i', (a, b, expected) => {
expect(calculator.add(a, b)).toBe(expected);
});
});
类型安全的 TypeScript 测试
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
describe('User Service', () => {
it('creates user with correct types', () => {
const user: User = {
id: 1,
name: 'Alice',
email: '[email protected]',
role: 'admin',
};
// Type-safe assertions
expect(user.id).toEqual(expect.any(Number));
expect(user.name).toEqual(expect.any(String));
expect(user.role).toMatch(/^(admin|user)$/);
});
it('validates user object shape', () => {
const user = createUser('Bob', '[email protected]');
expect(user).toMatchObject({
id: expect.any(Number),
name: 'Bob',
email: '[email protected]',
});
});
});
使用 TypeScript 进行模拟
使用 jest.mock 模拟模块
import { jest } from '@jest/globals';
import { UserService } from './UserService';
import * as userApi from './api/userApi';
// Mock entire module
jest.mock('./api/userApi');
describe('UserService with Mocks', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches user data', async () => {
const mockUser = { id: 1, name: 'Alice', email: '[email protected]' };
// Type-safe mock
const mockedFetchUser = jest.mocked(userApi.fetchUser);
mockedFetchUser.mockResolvedValue(mockUser);
const service = new UserService();
const user = await service.getUser(1);
expect(mockedFetchUser).toHaveBeenCalledWith(1);
expect(user).toEqual(mockUser);
});
});
使用 jest.spyOn 监视方法
import { jest } from '@jest/globals';
class Logger {
log(message: string): void {
console.log(message);
}
error(message: string): void {
console.error(message);
}
}
describe('Logger Spy', () => {
let logger: Logger;
let logSpy: jest.SpyInstance;
beforeEach(() => {
logger = new Logger();
logSpy = jest.spyOn(logger, 'log');
});
afterEach(() => {
logSpy.mockRestore();
});
it('tracks method calls', () => {
logger.log('Hello');
logger.log('World');
expect(logSpy).toHaveBeenCalledTimes(2);
expect(logSpy).toHaveBeenCalledWith('Hello');
expect(logSpy).toHaveBeenLastCalledWith('World');
});
it('provides custom implementation', () => {
logSpy.mockImplementation((msg: string) => {
console.log(`[CUSTOM] ${msg}`);
});
logger.log('Test');
expect(logSpy).toHaveBeenCalledWith('Test');
});
});
类型安全的模拟函数
import { jest } from '@jest/globals';
interface ApiResponse<T> {
data: T;
status: number;
}
type FetchUserFn = (id: number) => Promise<ApiResponse<User>>;
describe('Type-Safe Mocks', () => {
it('creates typed mock function', async () => {
const mockFetchUser = jest.fn<FetchUserFn>()
.mockResolvedValue({
data: { id: 1, name: 'Alice', email: '[email protected]', role: 'user' },
status: 200,
});
const result = await mockFetchUser(1);
expect(result.data.name).toBe('Alice');
expect(result.status).toBe(200);
expect(mockFetchUser).toHaveBeenCalledWith(1);
});
it('uses mock implementation', () => {
const mockCalculate = jest.fn<(x: number, y: number) => number>()
.mockImplementation((x, y) => x + y);
expect(mockCalculate(5, 3)).toBe(8);
expect(mockCalculate).toHaveBeenCalledWith(5, 3);
});
});
模拟计时器
import { jest } from '@jest/globals';
describe('Timer Mocking', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('fast-forwards time', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
jest.advanceTimersByTime(500);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(1);
});
it('runs all timers', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
setTimeout(callback, 2000);
jest.runAllTimers();
expect(callback).toHaveBeenCalledTimes(2);
});
it('handles intervals', () => {
const callback = jest.fn();
setInterval(callback, 1000);
jest.advanceTimersByTime(3500);
expect(callback).toHaveBeenCalledTimes(3);
jest.clearAllTimers();
});
});
React Testing Library + TypeScript
React 设置
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install -D jest-environment-jsdom
jest.config.ts(React):
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
jsx: 'react-jsx',
},
}],
},
};
export default config;
src/test/setup.ts:
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from '@jest/globals';
afterEach(() => {
cleanup();
});
React 组件测试
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter Component', () => {
it('renders initial count', () => {
render(<Counter initialCount={0} />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
it('increments counter on button click', async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('calls onChange callback with correct value', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<Counter initialCount={5} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(onChange).toHaveBeenCalledWith(6);
expect(onChange).toHaveBeenCalledTimes(1);
});
it('disables button when max count reached', () => {
render(<Counter initialCount={10} maxCount={10} />);
const button = screen.getByRole('button', { name: /increment/i });
expect(button).toBeDisabled();
});
});
测试 Hooks
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
});
it('increments counter', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
测试异步组件
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
import * as api from './api';
jest.mock('./api');
describe('UserProfile Async', () => {
it('loads and displays user data', async () => {
const mockUser = { id: 1, name: 'Alice', email: '[email protected]' };
jest.mocked(api.fetchUser).mockResolvedValue(mockUser);
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('displays error on fetch failure', async () => {
jest.mocked(api.fetchUser).mockRejectedValue(new Error('Network error'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
快照测试
组件快照
import { render } from '@testing-library/react';
import { UserCard } from './UserCard';
describe('UserCard Snapshots', () => {
it('matches snapshot for regular user', () => {
const { container } = render(
<UserCard
name="Alice"
email="[email protected]"
role="user"
/>
);
expect(container.firstChild).toMatchSnapshot();
});
it('matches snapshot for admin user', () => {
const { container } = render(
<UserCard
name="Bob"
email="[email protected]"
role="admin"
/>
);
expect(container.firstChild).toMatchSnapshot();
});
it('uses inline snapshot', () => {
const user = { id: 1, name: 'Charlie', role: 'user' };
expect(user).toMatchInlineSnapshot(`
{
"id": 1,
"name": "Charlie",
"role": "user",
}
`);
});
});
更新快照
# Update all snapshots
jest --updateSnapshot
jest -u
# Update snapshots for specific test file
jest UserCard.test.tsx -u
# Interactive snapshot update
jest --watch
# Press 'u' to update failing snapshots
自定义快照序列化器
// __tests__/serializers/dateSerializer.ts
export default {
test: (val: any) => val instanceof Date,
print: (val: Date) => `Date(${val.toISOString()})`,
};
jest.config.ts:
const config: Config = {
snapshotSerializers: ['<rootDir>/__tests__/serializers/dateSerializer.ts'],
};
异步测试
测试 Promise
import { fetchData, saveData } from './api';
describe('Async Operations', () => {
it('resolves with data', async () => {
const data = await fetchData(1);
expect(data).toBeDefined();
expect(data.id).toBe(1);
});
it('handles promise rejection', async () => {
await expect(fetchData(-1)).rejects.toThrow('Invalid ID');
});
it('uses resolves matcher', async () => {
await expect(fetchData(1)).resolves.toHaveProperty('id', 1);
});
it('tests multiple async operations', async () => {
const [user, posts] = await Promise.all([
fetchUser(1),
fetchPosts(1),
]);
expect(user.id).toBe(1);
expect(posts).toHaveLength(expect.any(Number));
});
});
测试回调
describe('Callback Testing', () => {
it('calls callback with correct arguments', (done) => {
function fetchWithCallback(id: number, callback: (data: any) => void) {
setTimeout(() => {
callback({ id, name: 'Test' });
}, 100);
}
fetchWithCallback(1, (data) => {
try {
expect(data.id).toBe(1);
expect(data.name).toBe('Test');
done();
} catch (error) {
done(error);
}
});
});
});
覆盖率配置
高级覆盖率设置
jest.config.ts:
const config: Config = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageProvider: 'v8', // or 'babel' for compatibility
coverageReporters: ['text', 'lcov', 'html', 'json'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.test.{ts,tsx}',
'!src/**/__tests__/**',
'!src/index.ts',
'!src/types/**',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
'./src/core/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
coveragePathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/__tests__/',
],
};
运行覆盖率
# Generate coverage report
npm test -- --coverage
# Coverage with watch mode
npm test -- --coverage --watch
# Coverage for specific files
npm test -- --coverage --collectCoverageFrom="src/components/**/*.tsx"
# View HTML report
open coverage/lcov-report/index.html
从 Vitest 迁移
主要区别
API 变更:
// Vitest
import { vi } from 'vitest';
const mockFn = vi.fn();
vi.spyOn(obj, 'method');
// Jest
import { jest } from '@jest/globals';
const mockFn = jest.fn();
jest.spyOn(obj, 'method');
迁移检查清单
1. 更新依赖项:
npm uninstall vitest @vitest/ui
npm install -D jest @types/jest ts-jest
2. 更新 package.json:
{
"scripts": {
"test": "jest", // Was: vitest run
"test:watch": "jest --watch" // Was: vitest
}
}
3. 将 vitest.config.ts 替换为 jest.config.ts:
// Old: vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
},
});
// New: jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
globals: {
'ts-jest': {
isolatedModules: true,
},
},
};
export default config;
4. 更新测试文件:
// Change imports
- import { vi } from 'vitest';
+ import { jest } from '@jest/globals';
// Update mocks
- vi.fn()
+ jest.fn()
- vi.spyOn()
+ jest.spyOn()
- vi.mock()
+ jest.mock()
// Timer mocks
- vi.useFakeTimers()
+ jest.useFakeTimers()
- vi.advanceTimersByTime()
+ jest.advanceTimersByTime()
5. 更新 tsconfig.json:
{
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom"] // Was: vitest/globals
}
}
Jest 与 Vitest 对比
性能
Jest:
- 初始启动较慢(没有 HMR)
- 默认顺序执行测试
- 中型项目需要 1-5 秒
Vitest:
- 基于 HMR,可即时执行
- 默认并行执行
- 同类项目需要 100-500ms
生态系统
Jest:
- ✅ 70% 市场占有率
- ✅ 成熟的生态系统(8 年以上)
- ✅ Stack Overflow 上有更多答案
- ✅ 企业支持更好
Vitest:
- ✅ 现代化,采用率不断增长
- ✅ 原生集成 Vite
- ⚠️ 生态系统较小
- ⚠️ 资源较少
TypeScript 支持
Jest:
- 需要配置 ts-jest
- 需要额外的转换步骤
- 编译较慢
Vitest:
- 内置 TypeScript 支持
- 无需配置
- 借助 Vite,速度更快
何时使用 Jest
以下情况请选择 Jest:
- ✅ 已在使用 Jest 的现有项目
- ✅ 要求使用经过验证工具的企业环境
- ✅ 需要丰富生态系统支持的项目
- ✅ 使用 Create React App 的 React 项目
- ✅ 非 Vite 构建系统(Webpack、Rollup)
以下情况请选择 Vitest:
- ✅ 使用现代工具链的新项目
- ✅ 基于 Vite 的应用
- ✅ 对性能要求较高的测试套件
- ✅ ESM 优先的项目
最佳实践
- 使用 TypeScript 配置:类型安全的测试可避免运行时错误
- 模拟外部依赖:网络、文件系统、数据库
- 隔离测试:每项测试都应彼此独立
- 使用 describe 块:按逻辑分组相关测试
- 清除模拟状态:在
beforeEach中使用jest.clearAllMocks() - 测试边界情况:空数组、null、undefined、错误
- 使用 .each 进行数据驱动测试:高效测试多个输入
- 避免测试实现细节:测试行为,而非内部结构
- 保持测试快速:模拟耗时操作,使用并行执行
- 维持覆盖率阈值:在 CI 中强制执行最低覆盖率
常见问题
❌ 测试之间未清除模拟:
// WRONG - mocks leak between tests
it('test 1', () => {
jest.spyOn(api, 'fetch');
// No cleanup!
});
// CORRECT
afterEach(() => {
jest.restoreAllMocks();
});
❌ 忘记等待异步测试完成:
// WRONG - test completes before assertion
it('fetches data', () => {
fetchData().then(data => {
expect(data).toBeDefined(); // Never runs!
});
});
// CORRECT
it('fetches data', async () => {
const data = await fetchData();
expect(data).toBeDefined();
});
❌ 使用了错误的测试环境:
// WRONG - testing DOM without jsdom
// jest.config.ts
testEnvironment: 'node', // Can't test React!
// CORRECT
testEnvironment: 'jsdom',
❌ 未为模拟使用 TypeScript 类型:
// WRONG - no type safety
const mockFn = jest.fn();
// CORRECT
const mockFn = jest.fn<(id: number) => Promise<User>>();
资源
- 文档:https://jestjs.io/docs/getting-started
- TypeScript 指南:https://jestjs.io/docs/getting-started#using-typescript
- ts-jest:https://kulshekhar.github.io/ts-jest/
- React Testing Library:https://testing-library.com/docs/react-testing-library/intro/
- Jest DOM 匹配器:https://github.com/testing-library/jest-dom
相关技能
使用 Jest 时,可考虑以下互补技能:
- typescript-core:高级 TypeScript 模式、tsconfig 优化和类型安全
- react:使用 Testing Library 测试 React 组件的模式
- vitest:基于 Vite 的现代替代方案,性能更快、执行速度更高
TypeScript 类型安全速查参考(内联,便于独立使用)
// Type-safe test helpers with generics
function createMockUser<T extends Partial<User>>(overrides: T): User & T {
return {
id: 1,
name: 'Test User',
email: '[email protected]',
...overrides
};
}
// Usage with type inference
const adminUser = createMockUser({ role: 'admin' });
// Type: User & { role: string }
// Type-safe mock functions
const mockFetch = jest.fn<typeof fetch>();
mockFetch.mockResolvedValue(new Response('{}'));
// Const type parameters for literal types
const createConfig = <const T extends Record<string, unknown>>(config: T): T => config;
const testConfig = createConfig({ environment: 'test', debug: true });
// Type: { environment: "test"; debug: true } (literals preserved)
React 测试模式速查(内联,便于独立使用)
// React Testing Library with Jest
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
// Component testing pattern
describe('UserProfile', () => {
it('should display user information', () => {
const user = { id: 1, name: 'Alice', email: '[email protected]' };
render(<UserProfile user={user} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('should handle user interactions', async () => {
const onSubmit = jest.fn();
render(<UserForm onSubmit={onSubmit} />);
// User interactions
await userEvent.type(screen.getByLabelText('Name'), 'Bob');
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ name: 'Bob' });
});
});
});
// Hook testing
import { renderHook, act } from '@testing-library/react';
test('useCounter hook', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
// Context and Provider testing
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider>{children}</AuthProvider>
);
test('useAuth hook with context', () => {
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toBeDefined();
});
Vitest 快速对比(内联,便于独立使用)
何时选择 Vitest 而非 Jest:
- 新建 Vite/Vite 构建的项目(使用 Turbopack 的 Next.js、SvelteKit)
- 需要更快的测试执行速度(快 10–100 倍)
- 采用 ESM 优先架构
- 需要用于测试的热模块替换
何时继续使用 Jest:
- 已配置 Jest 的大型现有代码库
- 已建立 Jest 工作流的企业环境
- 需要成熟的生态系统和丰富的插件
- 使用 Create React App 的 React 应用(默认配置 Jest)
迁移代码片段(Jest → Vitest):
// Jest: import from '@testing-library/jest-dom'
import '@testing-library/jest-dom';
// Vitest: import from vitest globals
import { expect, test, describe } from 'vitest';
import { screen } from '@testing-library/react';
// Most Jest syntax works in Vitest unchanged
test('component renders', () => {
render(<Component />);
expect(screen.getByText('Hello')).toBeTruthy();
});
[如果同时部署,完整的 TypeScript、React 和 Vitest 模式可在相应技能中找到]
总结
- Jest 是行业标准,市场占有率达 70%
- TypeScript 支持:通过 ts-jest 实现全面的类型安全
- 一体化解决方案:测试运行器、断言、模拟和覆盖率
- 集成 React Testing Library,用于组件测试
- 成熟的生态系统:拥有丰富的工具和支持
- 快照测试:用于 UI 回归测试
- 支持从 Vitest 迁移,提供兼容的 API
- 最适合:现有项目、企业环境、React 应用、旧版项目支持
- 权衡:比 Vitest 慢,但更成熟且支持更广泛