TDD测试驱动开发强制执行器 tdd-enforcer

TDD测试驱动开发强制执行器是一个用于软件开发测试阶段的专业工具,强制实施测试驱动开发(TDD)工作流程。它确保开发人员遵循先写测试再写实现的正确顺序,强制执行AAA模式(Arrange-Act-Assert),提供测试覆盖率要求,并指导编写高质量的单元测试、集成测试、组件测试和E2E测试。适用于JavaScript/TypeScript项目,帮助团队建立规范的测试体系,提高代码质量和可维护性。

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

name: tdd-enforcer description: 在实现新功能时使用。强制执行TDD工作流程 - 先写测试,再写实现。确保AAA模式、适当的覆盖率和高质量的测试设计。 allowed-tools: Read, Grep, Bash

TDD工作流程强制执行器

何时使用

  • 实现新功能
  • 添加功能
  • 修复bug
  • 重构代码

TDD流程(强制)

1. 先写测试(RED阶段)

  • 通过测试定义行为
  • 使用AAA模式(Arrange, Act, Assert)
  • 测试最初必须失败
  • 清晰的测试名称描述预期行为

2. 验证测试失败(确认)

  • 运行测试:npm test
  • 确认因正确原因失败
  • 测试应因功能不存在而失败,而不是语法错误

3. 编写实现(GREEN阶段)

  • 编写最少的代码使测试通过
  • 不要过度设计或添加额外功能
  • 专注于使测试通过

4. 验证测试通过(验证)

  • 运行测试:npm test
  • 所有新测试必须通过
  • 所有现有测试必须仍然通过

5. 重构(REFACTOR阶段)

  • 提高代码质量
  • 消除重复
  • 增强可读性
  • 重构过程中测试保持通过

覆盖率要求

  • 总体:75%+
  • 业务逻辑(src/services/):90%+
  • 工具类(src/utils/):90%+
  • UI组件:60%+
  • 关键用户流程的E2E测试

AAA模式(Arrange, Act, Assert)

describe('AuthService', () => {
  describe('register', () => {
    it('应该使用哈希密码创建用户', async () => {
      // ARRANGE: 设置测试数据
      const userData = {
        email: 'test@example.com',
        password: 'Pass123!',
      }

      // ACT: 执行行为
      const result = await authService.register(userData)

      // ASSERT: 验证结果
      expect(result.id).toBeDefined()
      expect(result.email).toBe(userData.email)
      expect(result).not.toHaveProperty('password') // 永不返回密码
    })

    it('应该拒绝弱密码', async () => {
      // ARRANGE
      const userData = {
        email: 'test@example.com',
        password: '123', // 太弱
      }

      // ACT & ASSERT
      await expect(authService.register(userData)).rejects.toThrow(
        '密码必须至少8个字符'
      )
    })
  })
})

测试结构

Describe块

// ✅ 正确做法:按模块/类组织
describe('UserService', () => {
  // ✅ 正确做法:按方法组织
  describe('findById', () => {
    it('找到时应返回用户', () => {})
    it('未找到时应返回null', () => {})
    it('无效ID时应抛出错误', () => {})
  })

  describe('create', () => {
    it('应该用有效数据创建用户', () => {})
    it('应该验证邮箱格式', () => {})
    it('应该在保存前哈希密码', () => {})
  })
})

测试名称

// ✅ 正确做法:描述性测试名称
it('邮箱无效时应返回400', () => {})
it('应该在保存前用bcrypt哈希密码', () => {})
it('注册后应发送欢迎邮件', () => {})

// ❌ 错误做法:模糊的测试名称
it('有效', () => {})
it('测试用户创建', () => {})
it('应该正确工作', () => {})

测试不同层级

单元测试(业务逻辑)

// src/services/auth.service.test.ts
import { AuthService } from './auth.service'
import { prismaMock } from '../test/prisma-mock'
import bcrypt from 'bcrypt'

describe('AuthService', () => {
  describe('login', () => {
    it('应该为有效凭证返回用户和令牌', async () => {
      // ARRANGE
      const hashedPassword = await bcrypt.hash('password123', 10)
      const mockUser = {
        id: '1',
        email: 'user@test.com',
        password: hashedPassword,
      }
      prismaMock.user.findUnique.mockResolvedValue(mockUser)

      // ACT
      const result = await authService.login({
        email: 'user@test.com',
        password: 'password123',
      })

      // ASSERT
      expect(result.user.email).toBe('user@test.com')
      expect(result.token).toBeDefined()
      expect(result.user).not.toHaveProperty('password')
    })

    it('密码错误时应抛出错误', async () => {
      // ARRANGE
      const hashedPassword = await bcrypt.hash('password123', 10)
      const mockUser = {
        id: '1',
        email: 'user@test.com',
        password: hashedPassword,
      }
      prismaMock.user.findUnique.mockResolvedValue(mockUser)

      // ACT & ASSERT
      await expect(
        authService.login({
          email: 'user@test.com',
          password: 'wrongpassword',
        })
      ).rejects.toThrow('无效凭证')
    })
  })
})

集成测试(API路由)

// src/app/api/auth/register/route.test.ts
import { POST } from './route'

describe('POST /api/auth/register', () => {
  it('应该创建用户并返回201', async () => {
    // ARRANGE
    const request = new Request('http://localhost/api/auth/register', {
      method: 'POST',
      body: JSON.stringify({
        email: 'newuser@test.com',
        password: 'SecurePass123!',
        name: 'Test User',
      }),
    })

    // ACT
    const response = await POST(request)
    const data = await response.json()

    // ASSERT
    expect(response.status).toBe(201)
    expect(data.user.email).toBe('newuser@test.com')
    expect(data.token).toBeDefined()
    expect(data.user).not.toHaveProperty('password')
  })

  it('无效邮箱时应返回400', async () => {
    // ARRANGE
    const request = new Request('http://localhost/api/auth/register', {
      method: 'POST',
      body: JSON.stringify({
        email: 'invalid-email',
        password: 'SecurePass123!',
      }),
    })

    // ACT
    const response = await POST(request)
    const data = await response.json()

    // ASSERT
    expect(response.status).toBe(400)
    expect(data.error).toContain('email')
  })
})

组件测试(UI)

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

describe('LoginForm', () => {
  it('应该用邮箱和密码调用onSubmit', async () => {
    // ARRANGE
    const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
    render(<LoginForm onSubmit={mockOnSubmit} />)

    // ACT
    fireEvent.change(screen.getByLabelText(/email/i), {
      target: { value: 'user@test.com' },
    })
    fireEvent.change(screen.getByLabelText(/password/i), {
      target: { value: 'password123' },
    })
    fireEvent.click(screen.getByRole('button', { name: /login/i }))

    // ASSERT
    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith({
        email: 'user@test.com',
        password: 'password123',
      })
    })
  })

  it('登录失败时应显示错误消息', async () => {
    // ARRANGE
    const mockOnSubmit = vi
      .fn()
      .mockRejectedValue(new Error('无效凭证'))
    render(<LoginForm onSubmit={mockOnSubmit} />)

    // ACT
    fireEvent.change(screen.getByLabelText(/email/i), {
      target: { value: 'user@test.com' },
    })
    fireEvent.change(screen.getByLabelText(/password/i), {
      target: { value: 'wrongpassword' },
    })
    fireEvent.click(screen.getByRole('button', { name: /login/i }))

    // ASSERT
    await waitFor(() => {
      expect(screen.getByText(/无效凭证/i)).toBeInTheDocument()
    })
  })

  it('加载时应禁用提交按钮', async () => {
    // ARRANGE
    const mockOnSubmit = vi
      .fn()
      .mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
    render(<LoginForm onSubmit={mockOnSubmit} />)

    // ACT
    fireEvent.change(screen.getByLabelText(/email/i), {
      target: { value: 'user@test.com' },
    })
    fireEvent.change(screen.getByLabelText(/password/i), {
      target: { value: 'password123' },
    })
    const submitButton = screen.getByRole('button', { name: /login/i })
    fireEvent.click(submitButton)

    // ASSERT
    expect(submitButton).toBeDisabled()
    await waitFor(() => {
      expect(submitButton).not.toBeDisabled()
    })
  })
})

E2E测试(关键流程)

// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('认证流程', () => {
  test('用户可以注册和登录', async ({ page }) => {
    // ARRANGE
    const email = `test-${Date.now()}@example.com`
    const password = 'SecurePass123!'

    // ACT: 注册
    await page.goto('/register')
    await page.fill('[name="email"]', email)
    await page.fill('[name="password"]', password)
    await page.fill('[name="confirmPassword"]', password)
    await page.click('button[type="submit"]')

    // ASSERT: 重定向到仪表板
    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('h1')).toContainText('Dashboard')

    // ACT: 登出
    await page.click('[data-testid="user-menu"]')
    await page.click('text=Logout')

    // ASSERT: 重定向到登录
    await expect(page).toHaveURL('/login')

    // ACT: 登录
    await page.fill('[name="email"]', email)
    await page.fill('[name="password"]', password)
    await page.click('button[type="submit"]')

    // ASSERT: 返回仪表板
    await expect(page).toHaveURL('/dashboard')
  })
})

测试质量要求

✅ 正确做法:测试行为,而不是实现

// ✅ 正确做法
it('登录失败时应显示错误消息', async () => {
  // 测试用户看到的内容
  await expect(screen.getByText(/无效凭证/i)).toBeInTheDocument()
})

// ❌ 错误做法
it('应该用"无效凭证"调用setError', async () => {
  // 测试实现细节
  expect(setError).toHaveBeenCalledWith('无效凭证')
})

✅ 正确做法:测试边界情况

it('应该处理空输入', () => {})
it('应该处理超长输入(> 1000字符)', () => {})
it('应该处理邮箱中的特殊字符', () => {})
it('应该处理并发请求', () => {})

✅ 正确做法:测试错误条件

it('应该处理数据库连接失败', () => {})
it('应该处理网络超时', () => {})
it('应该处理无效JSON响应', () => {})

✅ 正确做法:使用测试数据构建器

// 测试数据构建器用于更清晰的测试
const userBuilder = {
  default: () => ({
    email: 'test@example.com',
    password: 'Pass123!',
    name: 'Test User',
  }),
  withEmail: (email: string) => ({
    ...userBuilder.default(),
    email,
  }),
  withoutName: () => ({
    email: 'test@example.com',
    password: 'Pass123!',
  }),
}

it('应该用默认数据创建用户', () => {
  const user = userBuilder.default()
  // ...
})

it('应该创建没有名字的用户', () => {
  const user = userBuilder.withoutName()
  // ...
})

覆盖率验证

# 运行带覆盖率的测试
npm run test:coverage

# 检查覆盖率阈值
npm test -- --coverage --coverageThreshold='{"global":{"lines":75,"functions":75,"branches":75}}'

常见TDD错误

❌ 错误做法:先写实现

// 错误顺序
1. 写函数
2. 写测试
3. 测试通过(或修复测试使其通过)

✅ 正确做法:先写测试

// 正确顺序(TDD)
1. 写测试(RED)
2. 验证测试失败
3. 写最少的实现(GREEN)
4. 验证测试通过
5. 重构(REFACTOR)

❌ 错误做法:测试实现细节

// 错误:测试内部状态
expect(component.state.loading).toBe(true)

// 正确:测试可观察行为
expect(screen.getByTestId('spinner')).toBeInTheDocument()

❌ 错误做法:写一个巨大的测试

// 错误:一个测试做所有事情
it('应该处理整个用户流程', () => {
  // 100行测试代码
})

// 正确:拆分为专注的测试
it('应该验证邮箱格式', () => {})
it('应该哈希密码', () => {})
it('应该在数据库中创建用户', () => {})
it('应该发送欢迎邮件', () => {})

提交前检查清单

  • [ ] 所有新功能都先写了测试
  • [ ] 测试最初失败(RED)
  • [ ] 实现使测试通过(GREEN)
  • [ ] 代码已重构以提高质量(REFACTOR)
  • [ ] 达到覆盖率阈值(总体75%+,业务逻辑90%+)
  • [ ] 所有测试使用AAA模式
  • [ ] 测试名称具有描述性
  • [ ] 测试了边界情况
  • [ ] 测试了错误条件
  • [ ] 关键用户流程有E2E测试