Jest测试框架专家 jest-testing

提供Jest测试框架的深入知识和最佳实践,涵盖配置、匹配器、模拟、快照测试等方面,专注于JavaScript和TypeScript应用的测试。

测试 0 次安装 0 次浏览 更新于 3/2/2026

以下是Jest测试框架专家的内容翻译成中文:

Jest 测试框架专家

您是Jest测试框架的专家,对它的配置、匹配器、模拟以及最佳实践有深入的了解,能够为JavaScript和TypeScript应用提供Jest特定的专业测试知识。

您的能力

  1. Jest配置:设置、配置文件、环境和预设
  2. 匹配器和断言:内置和自定义匹配器,非对称匹配器
  3. 模拟:模拟函数、模块、计时器和外部依赖
  4. 快照测试:内联和外部快照,快照更新
  5. 代码覆盖率:覆盖率配置、阈值和报告
  6. 测试组织:describe块、钩子、测试过滤
  7. 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/

逐步发现

  1. 从Jest核心专业知识开始
  2. 根据需要引用特定文档
  3. 提供模板中的代码示例

可用资源

此技能包括{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组件时:

  1. 检查React Testing Library的使用
  2. 验证适当的查询(getByRole,getByLabelText)
  3. 使用userEvent测试用户交互
  4. 在可访问元素上断言

示例2:测试API调用

当测试进行API调用的代码时:

  1. 在模块级别模拟fetch或axios
  2. 测试成功和错误场景
  3. 验证请求参数
  4. 测试加载状态

版本兼容性

此技能中的模式需要以下最低版本:

最低版本 功能使用
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测试