mcpField
T
Testing Strategist

Testing Strategist

github.com/daffy0208测试与质量38173 次安装44 周前更新
代码仓库

关于 Testing Strategist

Testing Strategist 是面向软件开发者的测试策略技能,依据测试金字塔指导单元、集成和端到端测试的选择、编写与维护。

主要功能

  1. 01按测试金字塔分配测试层级
  2. 02指导单元测试与集成测试
  3. 03规划关键用户流程的端到端测试
  4. 04应用 TDD 红绿重构循环
  5. 05管理模拟、覆盖率与测试组织

适用场景

  1. 01为新项目建立测试方案
  2. 02判断功能应在哪个层级测试
  3. 03为复杂逻辑采用 TDD
  4. 04改进覆盖率并处理不稳定测试

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/daffy0208/ai-dev-standards --skill testing-strategist

安装后在 Claude Code 中输入/testing-strategist确认已加载。

附带文件manifest.yamlREADME.md

详细介绍

测试策略

在恰当的层级测试恰当的内容——编写让你有信心发布的测试。

核心原则

测试金字塔: 70% 单元测试、20% 集成测试、10% E2E 测试。

测试应当:

  • 快速——耗时从毫秒级(单元测试)、秒级(集成测试)到分钟级(E2E 测试)
  • 隔离——一次只测试一件事
  • 可重复——相同输入 = 相同输出
  • 自动校验——自动判断通过或失败,无需人工验证
  • 及时——与代码同步编写(或先编写,再采用 TDD)

测试金字塔

           /\
          /  \         E2E Tests (10%)
         /----\        - Slow, brittle, expensive
        /      \       - Test critical user journeys
       /--------\      - Example: "User can complete checkout"
      /          \
     /------------\    Integration Tests (20%)
    /              \   - Medium speed, test components together
   /----------------\  - Example: "API endpoint returns correct data"
  /                  \
 /--------------------\ Unit Tests (70%)
/______________________\ - Fast, isolated, test functions/components
                        - Example: "calculateTotal returns sum"
为什么采用这个比例?
  • 单元测试: 快速反馈,精准定位缺陷,易于维护
  • 集成测试: 确保各组件协同工作,发现接口问题
  • E2E 测试: 验证实际用户流程,发现 UI 缺陷,但速度慢且脆弱

第 1 层:单元测试(70%)

测试内容

隔离测试单个函数、组件或类。

适合测试的内容:

  • ✅ 业务逻辑函数(计算、验证、转换)
  • ✅ 工具函数(formatDate、parseUrl 等)
  • ✅ React 组件(渲染、props、状态)
  • ✅ Hooks(自定义 React hooks)
  • ✅ 纯函数(相同输入 = 相同输出)

跳过:

  • ❌ 第三方库(假定它们正常工作)
  • ❌ 框架内部实现(React、Next.js)
  • ❌ 没有逻辑的简单 getter/setter
单元测试示例
测试业务逻辑(Jest + TypeScript)
// src/lib/pricing.ts
export function calculateTotal(items: { price: number; quantity: number }[]) {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}

export function applyDiscount(total: number, discountPercent: number) {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error('Invalid discount percentage')
  }
  return total * (1 - discountPercent / 100)
}

// src/lib/pricing.test.ts
import { calculateTotal, applyDiscount } from './pricing'

describe('calculateTotal', () => {
  it('calculates total for single item', () => {
    const items = [{ price: 10, quantity: 2 }]
    expect(calculateTotal(items)).toBe(20)
  })

  it('calculates total for multiple items', () => {
    const items = [
      { price: 10, quantity: 2 },
      { price: 5, quantity: 3 }
    ]
    expect(calculateTotal(items)).toBe(35)
  })

  it('returns 0 for empty array', () => {
    expect(calculateTotal([])).toBe(0)
  })
})

describe('applyDiscount', () => {
  it('applies discount correctly', () => {
    expect(applyDiscount(100, 20)).toBe(80)
  })

  it('throws error for invalid discount', () => {
    expect(() => applyDiscount(100, -10)).toThrow('Invalid discount')
    expect(() => applyDiscount(100, 150)).toThrow('Invalid discount')
  })
})
测试 React 组件(Jest + React Testing Library)
// src/components/Button.tsx
export function Button({
  children,
  variant = 'primary',
  onClick
}: {
  children: React.ReactNode
  variant?: 'primary' | 'secondary'
  onClick?: () => void
}) {
  return (
    <button
      className={`btn btn-${variant}`}
      onClick={onClick}
    >
      {children}
    </button>
  )
}

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

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

  it('applies primary variant by default', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByRole('button')).toHaveClass('btn-primary')
  })

  it('applies secondary variant when specified', () => {
    render(<Button variant="secondary">Click me</Button>)
    expect(screen.getByRole('button')).toHaveClass('btn-secondary')
  })

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn()
    render(<Button onClick={handleClick}>Click me</Button>)

    fireEvent.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })
})
测试自定义 Hooks
// src/hooks/useCounter.ts
import { useState } from 'react'

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)

  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(initialValue)

  return { count, increment, decrement, reset }
}

// src/hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  it('increments count', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('decrements count', () => {
    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()
      result.current.reset()
    })

    expect(result.current.count).toBe(10)
  })
})
单元测试最佳实践

✅ 建议:

  • 测试行为,而非实现
  • 使用描述性测试名称(应当……)
  • 遵循 AAA 模式:准备(Arrange)、执行(Act)、断言(Assert)
  • 测试边界情况(空数组、null、负数)
  • 保持测试简单易读

❌ 避免:

  • 直接测试私有方法
  • 过度使用模拟(会使测试脆弱)
  • 测试框架内部实现
  • 编写依赖其他测试的测试

第 2 层:集成测试(20%)

测试内容

测试多个单元协同工作——通常涉及 API 路由、数据库操作或服务集成。

适合测试的内容:

  • ✅ API 端点(请求 → 控制器 → 数据库 → 响应)
  • ✅ 数据库操作(查询、事务)
  • ✅ 第三方集成(Stripe、SendGrid)
  • ✅ 身份验证流程
  • ✅ 文件上传/下载
集成测试示例
测试 API 路由(Next.js + Supertest)
// app/api/posts/route.ts
export async function GET() {
  const posts = await db.post.findMany({
    include: { author: true },
    orderBy: { createdAt: 'desc' }
  })
  return Response.json(posts)
}

export async function POST(request: Request) {
  const body = await request.json()

  // Validate
  const result = PostSchema.safeParse(body)
  if (!result.success) {
    return Response.json({ errors: result.error.issues }, { status: 400 })
  }

  // Create post
  const post = await db.post.create({
    data: {
      title: result.data.title,
      content: result.data.content,
      authorId: request.user.id
    }
  })

  return Response.json(post, { status: 201 })
}

// app/api/posts/route.test.ts
import { testClient } from '@/lib/test-utils'

describe('POST /api/posts', () => {
  beforeEach(async () => {
    // Clean database before each test
    await db.post.deleteMany()
  })

  it('creates a new post', async () => {
    const response = await testClient
      .post('/api/posts')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        title: 'Test Post',
        content: 'This is a test post'
      })

    expect(response.status).toBe(201)
    expect(response.body).toMatchObject({
      title: 'Test Post',
      content: 'This is a test post'
    })

    // Verify in database
    const posts = await db.post.findMany()
    expect(posts).toHaveLength(1)
    expect(posts[0].title).toBe('Test Post')
  })

  it('returns 400 for invalid data', async () => {
    const response = await testClient
      .post('/api/posts')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        title: '' // Invalid: empty title
      })

    expect(response.status).toBe(400)
    expect(response.body.errors).toBeDefined()
  })

  it('returns 401 for unauthenticated request', async () => {
    const response = await testClient.post('/api/posts').send({
      title: 'Test',
      content: 'Test'
    })

    expect(response.status).toBe(401)
  })
})

describe('GET /api/posts', () => {
  beforeEach(async () => {
    await db.post.deleteMany()

    // Seed test data
    await db.post.createMany({
      data: [
        { title: 'Post 1', content: 'Content 1', authorId: user.id },
        { title: 'Post 2', content: 'Content 2', authorId: user.id }
      ]
    })
  })

  it('returns all posts', async () => {
    const response = await testClient.get('/api/posts')

    expect(response.status).toBe(200)
    expect(response.body).toHaveLength(2)
    expect(response.body[0].author).toBeDefined()
  })
})
测试数据库操作
// src/lib/repositories/userRepository.test.ts
import { db } from '@/lib/db'
import { createUser, findUserByEmail, updateUser } from './userRepository'

describe('userRepository', () => {
  beforeEach(async () => {
    await db.user.deleteMany()
  })

  afterAll(async () => {
    await db.$disconnect()
  })

  describe('createUser', () => {
    it('creates user with hashed password', async () => {
      const user = await createUser({
        email: '[email protected]',
        password: 'password123'
      })

      expect(user.email).toBe('[email protected]')
      expect(user.password).not.toBe('password123') // Should be hashed
      expect(user.password).toMatch(/^\$2[aby]/) // bcrypt hash format
    })

    it('throws error for duplicate email', async () => {
      await createUser({ email: '[email protected]', password: 'pass' })

      await expect(createUser({ email: '[email protected]', password: 'pass' })).rejects.toThrow()
    })
  })

  describe('findUserByEmail', () => {
    it('finds existing user', async () => {
      await createUser({ email: '[email protected]', password: 'pass' })

      const user = await findUserByEmail('[email protected]')
      expect(user).toBeDefined()
      expect(user?.email).toBe('[email protected]')
    })

    it('returns null for non-existent user', async () => {
      const user = await findUserByEmail('[email protected]')
      expect(user).toBeNull()
    })
  })
})
集成测试最佳实践

✅ 建议:

  • 使用测试数据库(与开发/生产数据库分开)
  • 清理测试数据(beforeEach/afterEach)
  • 测试正常流程和错误情况
  • 测试身份验证/授权
  • 使用工厂函数/固定数据生成测试数据

❌ 避免:

  • 对生产数据库进行测试
  • 留下测试数据
  • 模拟数据库(这会失去集成测试的意义)
  • 依赖外部服务(模拟外部 API)

第 3 层:E2E 测试(10%)

测试内容

通过实际 UI 测试完整的用户旅程。

适合测试的内容:

  • ✅ 关键用户流程(注册、登录、结账)
  • ✅ 核心业务流程
  • ✅ 多步骤工作流

跳过:

  • ❌ 所有可能的 UI 交互(太慢且脆弱)
  • ❌ 边界情况(通过单元测试/集成测试覆盖)
E2E 测试示例(Playwright)
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Authentication', () => {
  test('user can sign up and log in', async ({ page }) => {
    // Navigate to signup
    await page.goto('/signup')

    // Fill signup form
    await page.fill('input[name="email"]', '[email protected]')
    await page.fill('input[name="password"]', 'SecurePass123!')
    await page.fill('input[name="confirmPassword"]', 'SecurePass123!')

    // Submit form
    await page.click('button[type="submit"]')

    // Should redirect to dashboard
    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('h1')).toContainText('Welcome')

    // Logout
    await page.click('[data-testid="logout-button"]')

    // Should redirect to login
    await expect(page).toHaveURL('/login')

    // Login again
    await page.fill('input[name="email"]', '[email protected]')
    await page.fill('input[name="password"]', 'SecurePass123!')
    await page.click('button[type="submit"]')

    // Should be back at dashboard
    await expect(page).toHaveURL('/dashboard')
  })

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

    await page.fill('input[name="email"]', '[email protected]')
    await page.fill('input[name="password"]', 'wrongpassword')
    await page.click('button[type="submit"]')

    // Should show error message
    await expect(page.locator('[role="alert"]')).toContainText('Invalid credentials')

    // Should stay on login page
    await expect(page).toHaveURL('/login')
  })
})

// tests/e2e/checkout.spec.ts
test.describe('Checkout Flow', () => {
  test('user can complete purchase', async ({ page }) => {
    // Login first
    await page.goto('/login')
    await page.fill('input[name="email"]', '[email protected]')
    await page.fill('input[name="password"]', 'password')
    await page.click('button[type="submit"]')

    // Add product to cart
    await page.goto('/products')
    await page.click('[data-testid="product-1"] button:text("Add to Cart")')

    // Verify cart badge
    await expect(page.locator('[data-testid="cart-badge"]')).toContainText('1')

    // Go to checkout
    await page.click('[data-testid="cart-button"]')
    await page.click('button:text("Checkout")')

    // Fill shipping info
    await page.fill('input[name="address"]', '123 Main St')
    await page.fill('input[name="city"]', 'San Francisco')
    await page.fill('input[name="zip"]', '94103')

    // Fill payment info (test mode)
    await page.fill('input[name="cardNumber"]', '4242424242424242')
    await page.fill('input[name="expiry"]', '12/25')
    await page.fill('input[name="cvc"]', '123')

    // Submit order
    await page.click('button:text("Place Order")')

    // Should see confirmation
    await expect(page).toHaveURL(/\/orders\/\d+/)
    await expect(page.locator('h1')).toContainText('Order Confirmed')
  })
})
E2E 测试最佳实践

✅ 建议:

  • 只测试关键路径(少于 20 个测试)
  • 使用 data-testid 属性(稳定的选择器)
  • 在 CI/CD 流水线中运行
  • 跨浏览器测试(Chrome、Firefox、Safari)
  • 失败时截取屏幕截图

❌ 避免:

  • 测试每种 UI 变体
  • 使用脆弱的选择器(文本内容、nth-child)
  • 每次提交都运行 E2E 测试(太慢)
  • 忽视不稳定的测试(修复或删除)

测试驱动开发(TDD)

Red-Green-Refactor 循环
  1. Red(红): 编写一个失败的测试
  2. Green(绿): 编写最少量的代码使测试通过
  3. Refactor(重构): 在保持测试通过的同时改进代码
TDD 示例
// 1. RED: Write failing test first
describe('formatCurrency', () => {
  it('formats number as USD currency', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56')
  })
})

// Run test: FAILS (formatCurrency doesn't exist)

// 2. GREEN: Write minimal implementation
export function formatCurrency(amount: number): string {
  return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`
}

// Run test: PASSES

// 3. REFACTOR: Improve implementation
export function formatCurrency(
  amount: number,
  currency: string = 'USD',
  locale: string = 'en-US'
): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency
  }).format(amount)
}

// Add more tests
it('formats EUR currency', () => {
  expect(formatCurrency(1234.56, 'EUR', 'de-DE')).toBe('1.234,56 €')
})

it('handles negative amounts', () => {
  expect(formatCurrency(-100)).toBe('-$100.00')
})
何时使用 TDD

适用于:

  • ✅ 复杂的业务逻辑
  • ✅ 修复 Bug(先编写能够复现 Bug 的测试)
  • ✅ 需求明确
  • ✅ 关键算法

不适用于:

  • ❌ 探索性编码(尚不清楚需求)
  • ❌ 一次性原型
  • ❌ 简单的 CRUD 操作

模拟策略

何时使用模拟
  • ✅ 外部 API(速度慢、不可靠或需要付费)
  • ✅ 时间/随机性(让测试结果确定)
  • ✅ 文件系统操作
  • ✅ 数据库(仅限单元测试)
模拟示例(Jest)
// Mock external API
import { fetchUserData } from '@/lib/api'

jest.mock('@/lib/api')
const mockFetchUserData = fetchUserData as jest.MockedFunction<typeof fetchUserData>

it('displays user data', async () => {
  mockFetchUserData.mockResolvedValue({
    id: '1',
    name: 'John Doe',
    email: '[email protected]'
  })

  render(<UserProfile userId="1" />)

  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument()
  })
})

// Mock Date
beforeAll(() => {
  jest.useFakeTimers()
  jest.setSystemTime(new Date('2024-01-01'))
})

afterAll(() => {
  jest.useRealTimers()
})

it('shows correct date', () => {
  expect(getCurrentDate()).toBe('2024-01-01')
})

// Mock Math.random
const mockRandom = jest.spyOn(Math, 'random')
mockRandom.mockReturnValue(0.5)

expect(generateRandomId()).toBe('expected-id-with-0.5-random')

mockRandom.mockRestore()
模拟最佳实践

✅ 建议:

  • 在边界处模拟(API、文件系统)
  • 测试后恢复模拟
  • 让模拟贴近实际(数据结构与真实数据一致)

❌ 不建议:

  • 过度模拟(会让测试变得脆弱)
  • 模拟自己的代码(应测试真实行为)
  • 模拟不属于自己的内容(外部依赖除外)

代码覆盖率

覆盖率目标
  • 最低 70%——低于此值,说明遗漏了重要测试
  • 80% 较好——对关键路径有扎实的覆盖
  • 90% 以上收益递减——追求 100% 往往不值得
重点关注

高优先级(覆盖率必须达到 90% 以上):

  • 业务逻辑
  • 身份验证/授权
  • 支付处理
  • 数据验证

中优先级(目标为 70% 以上):

  • API 路由
  • 数据库查询
  • 实用函数

低优先级(可以跳过):

  • UI 组件(测试行为,而非实现)
  • 配置文件
  • 类型定义
  • 第三方集成(更适合进行集成测试)
检查覆盖率
# Jest
npm test -- --coverage

# View HTML report
open coverage/lcov-report/index.html
覆盖率配置(jest.config.js)
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx',
    '!src/types/**'
  ],
  coverageThresholds: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70
    },
    // Critical paths need higher coverage
    './src/lib/auth/**': {
      branches: 90,
      functions: 90,
      lines: 90
    }
  }
}

不同框架的测试策略

Next.js(React)
  • 单元测试:Jest + React Testing Library
  • 集成测试:Supertest(API 路由)
  • 端到端测试:Playwright
Express API
  • 单元测试:Jest
  • 集成测试:Supertest
  • 端到端测试:Playwright(如果有 UI)
FastAPI(Python)
  • 单元测试:pytest
  • 集成测试:pytest + TestClient
  • 端到端测试:Playwright

常见测试模式

测试异步代码
// Using async/await
it('fetches user data', async () => {
  const user = await fetchUser('123')
  expect(user.name).toBe('John')
})

// Using waitFor (React Testing Library)
it('shows loading then data', async () => {
  render(<UserProfile userId="123" />)

  expect(screen.getByText('Loading...')).toBeInTheDocument()

  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument()
  })
})
测试错误处理
it('handles errors gracefully', async () => {
  mockFetchUser.mockRejectedValue(new Error('Network error'))

  render(<UserProfile userId="123" />)

  await waitFor(() => {
    expect(screen.getByText(/error/i)).toBeInTheDocument()
  })
})
测试表单
it('submits form with valid data', async () => {
  const handleSubmit = jest.fn()
  render(<LoginForm onSubmit={handleSubmit} />)

  await userEvent.type(screen.getByLabelText('Email'), '[email protected]')
  await userEvent.type(screen.getByLabelText('Password'), 'password123')
  await userEvent.click(screen.getByRole('button', { name: 'Login' }))

  await waitFor(() => {
    expect(handleSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'password123'
    })
  })
})

测试组织

文件结构
src/
├── components/
│   ├── Button.tsx
│   └── Button.test.tsx         # Co-located with component
├── lib/
│   ├── utils.ts
│   └── utils.test.ts           # Co-located with module
└── __tests__/
    ├── integration/            # Integration tests
    │   └── api.test.ts
    └── e2e/                    # E2E tests
        └── checkout.spec.ts
命名约定
  • 单元/集成测试:*.test.ts 或 *.spec.ts
  • 端到端测试:*.e2e.ts 或 *.spec.ts(位于 tests/e2e/)
  • 测试名称:it('should do X when Y') 或 it('does X')

何时使用此技能

在以下情况下使用 testing-strategist 技能:

  • ✅ 为新项目配置测试
  • ✅ 选择测试框架
  • ✅ 决定测试内容和测试级别
  • ✅ 实施 TDD
  • ✅ 提高代码覆盖率
  • ✅ 修复不稳定的测试

相关资源

技能:

  • security-engineer - 安全测试
  • api-designer - API 测试策略
  • frontend-builder - React 测试模式

模式:

  • /STANDARDS/best-practices/testing-best-practices.md
  • /TEMPLATES/testing/jest-nextjs-setup.md
  • /TEMPLATES/testing/playwright-e2e-setup.md

外部资源:


优秀的测试能让你放心发布。 ✅

评论

支持 Markdown