bookworm-smart-assistant/skills/tester-expert/SKILL.md

10 KiB
Raw Blame History

name: tester-expert description: > 测试专家。当用户需要编写单元测试、集成测试、E2E 端到端测试、TDD 测试驱动开发、 Jest/Vitest/Playwright/Cypress/pytest 测试框架、Mock/Stub、测试覆盖率 或说 "写测试"、"测试用例" 时使用此技能。 allowed-tools: Read, Glob, Grep, Edit, Write, Bash, mcp__playwright, mcp__chrome-devtools, mcp__selenium maturity: stable last-reviewed: 2026-02-18 composable: true enhances: [debugger-expert, zero-defect-guardian]

测试专家 (Tester Expert)

Output Style: 本技能使用内联输出规范

资深测试工程师,精通各种测试策略、测试框架和 TDD 开发方法。

触发关键词

  • core tier: 单元测试, 集成测试, E2E测试, 端到端测试, 写测试, 测试用例, unit test, integration test, write tests, test case, test coverage, testing
  • strong tier: Jest, Vitest, Playwright, Cypress, pytest, TDD, BDD, 测试覆盖率, test suite, test runner, component test, snapshot test, regression test
  • extended tier: 测试驱动, Mock, Stub, 覆盖率, 代码覆盖, 测试方案, testing library, test driven, assertion
  • 排除场景: A/B测试(数据实验)→ 路由至 data-analyst-expertpandas 相关测试 → 路由至 data-analyst-expert

测试金字塔

        /\
       /  \
      / E2E \        少量:关键用户流程
     /______\
    /        \
   /Integration\    中量API和服务交互
  /______________\
 /                \
/   Unit Tests     \   大量:函数和模块
/____________________\

单元测试

Jest / Vitest

// user.service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserService } from './user.service';

describe('UserService', () => {
  let userService: UserService;
  let mockUserRepository: any;

  beforeEach(() => {
    mockUserRepository = {
      findById: vi.fn(),
      save: vi.fn(),
    };
    userService = new UserService(mockUserRepository);
  });

  describe('getUserById', () => {
    it('should return user when found', async () => {
      const mockUser = { id: '1', name: 'John' };
      mockUserRepository.findById.mockResolvedValue(mockUser);

      const result = await userService.getUserById('1');

      expect(result).toEqual(mockUser);
      expect(mockUserRepository.findById).toHaveBeenCalledWith('1');
    });

    it('should return null when user not found', async () => {
      mockUserRepository.findById.mockResolvedValue(null);

      const result = await userService.getUserById('999');

      expect(result).toBeNull();
    });

    it('should throw error on database failure', async () => {
      mockUserRepository.findById.mockRejectedValue(new Error('DB Error'));

      await expect(userService.getUserById('1')).rejects.toThrow('DB Error');
    });
  });
});

React 组件测试

// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('renders with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click</Button>);
    
    fireEvent.click(screen.getByRole('button'));
    
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('shows loading spinner when loading', () => {
    render(<Button loading>Submit</Button>);
    expect(screen.getByRole('progressbar')).toBeInTheDocument();
    expect(screen.queryByText('Submit')).not.toBeInTheDocument();
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Disabled</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

集成测试

// api.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../db';

describe('User API', () => {
  beforeAll(async () => {
    await db.migrate.latest();
    await db.seed.run();
  });

  afterAll(async () => {
    await db.destroy();
  });

  describe('GET /api/users', () => {
    it('should return list of users', async () => {
      const response = await request(app)
        .get('/api/users')
        .expect(200);

      expect(response.body).toHaveProperty('users');
      expect(Array.isArray(response.body.users)).toBe(true);
    });
  });

  describe('POST /api/users', () => {
    it('should create a new user', async () => {
      const newUser = { name: 'Test User', email: 'test@example.com' };

      const response = await request(app)
        .post('/api/users')
        .send(newUser)
        .expect(201);

      expect(response.body.user.name).toBe(newUser.name);
      expect(response.body.user.email).toBe(newUser.email);
    });

    it('should return 400 for invalid data', async () => {
      const invalidUser = { name: '' };

      await request(app)
        .post('/api/users')
        .send(invalidUser)
        .expect(400);
    });
  });
});

E2E 测试

Playwright

// login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login Flow', () => {
  test('should login successfully with valid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="submit"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('[data-testid="welcome-message"]')).toBeVisible();
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[data-testid="email"]', 'wrong@example.com');
    await page.fill('[data-testid="password"]', 'wrongpassword');
    await page.click('[data-testid="submit"]');

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });
});

TDD 流程

1. Red: 写一个失败的测试
2. Green: 写最少代码让测试通过
3. Refactor: 重构代码,保持测试通过

测试覆盖率配置

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['node_modules/', 'test/'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});

测试命名规范

// 格式: should [expected behavior] when [condition]
it('should return empty array when no users exist');
it('should throw error when id is invalid');
it('should update user name when valid name provided');

输出规范

  • 测试代码完整可运行
  • 覆盖正常情况和边界情况
  • 使用清晰的测试描述
  • 遵循 AAA 模式 (Arrange, Act, Assert)

并发测试与竞态检测

竞态条件测试模板

// race-condition.test.ts — 并发读写竞态检测
import { describe, it, expect } from 'vitest';

describe('Concurrency Safety', () => {
  it('should handle concurrent writes without data loss', async () => {
    const results: number[] = [];
    const counter = { value: 0 };

    // 模拟 N 个并发写入
    const N = 100;
    const promises = Array.from({ length: N }, (_, i) =>
      Promise.resolve().then(() => {
        const current = counter.value;
        // 模拟异步间隙 (竞态窗口)
        counter.value = current + 1;
        results.push(counter.value);
      })
    );
    await Promise.all(promises);

    // 如果存在竞态counter.value < N
    expect(counter.value).toBe(N);
  });

  it('should not deadlock under concurrent lock acquisition', async () => {
    const timeout = new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Deadlock detected: timeout')), 5000)
    );
    const operation = runConcurrentLockTest(); // 被测函数

    // 5 秒内必须完成,否则判定为死锁
    await expect(Promise.race([operation, timeout])).resolves.toBeDefined();
  });
});

文件锁竞态测试

// file-lock-race.test.ts
it('concurrent file writes should not corrupt JSON', async () => {
  const file = path.join(tmpDir, 'test.json');
  fs.writeFileSync(file, JSON.stringify({ count: 0 }));

  // 10 个并发进程同时 read-modify-write
  const workers = Array.from({ length: 10 }, () =>
    new Promise<void>((resolve) => {
      const data = JSON.parse(fs.readFileSync(file, 'utf8'));
      data.count++;
      const tmp = file + '.tmp.' + Math.random();
      fs.writeFileSync(tmp, JSON.stringify(data));
      fs.renameSync(tmp, file);
      resolve();
    })
  );
  await Promise.all(workers);

  // 验证: 无锁时 count < 10 (竞态丢失)
  const final = JSON.parse(fs.readFileSync(file, 'utf8'));
  // 有锁保护时应 === 10
  expect(final.count).toBeLessThanOrEqual(10);
});

并发测试检查清单

  • 共享状态读写是否有锁保护?
  • Promise.all 中的操作是否互相独立?
  • 数据库事务隔离级别是否足够?
  • 文件操作是否使用 temp+rename 原子写入?
  • 计数器/ID 生成是否原子操作?
  • 缓存失效时的 thundering herd 是否处理?

禁止事项

  • 不要测试实现细节
  • 不要忽略边界情况
  • 不要写互相依赖的测试
  • 不要忽略异步错误处理
  • 不要跳过并发场景测试 (多用户/多进程操作同一资源)

突变测试 (Mutation Testing)

概念

通过微小修改源代码 (突变体) 验证现有测试是否能检测到变化。如果测试仍通过 → 测试无效。

手动突变检查清单

对关键函数,逐一验证以下突变是否被测试捕获:

  • > 改为 >= (边界条件)
  • && 改为 || (逻辑反转)
  • +1 改为 -1 (off-by-one)
  • return true 改为 return false (返回值反转)
  • 删除一个 if 分支 (条件删除)
  • 交换两个参数顺序 (参数置换)

Stryker (JS/TS 突变测试工具)

# 安装
npx stryker init

# 运行
npx stryker run

# 报告解读
# Mutation Score = killed / (killed + survived)
# 目标: >80% (核心逻辑 >90%)

突变测试适用场景

  • 金额计算、权限判断、状态机转换等高风险逻辑
  • 测试覆盖率高但信心不足时
  • 不适用于 UI 组件、配置文件等