Skip to content

TDD 与 BDD

测试驱动开发(TDD)和行为驱动开发(BDD)是两种以测试为核心的软件开发方法论。它们不仅仅是"写测试的方式",更是一种思维方式和设计工具。TDD 强调从最小单元的失败测试出发驱动实现;BDD 则站在用户行为的角度描述系统应有的表现。理解它们的原理、差异和适用场景,是前端工程师进阶的必经之路。

本文将从底层原理出发,结合 ASCII 图示、完整代码示例和实战演练,系统性拆解 TDD 与 BDD 的方方面面,并延伸到前端测试完整策略。


一、TDD(测试驱动开发)

1.1 什么是 TDD

TDD(Test-Driven Development)是 Kent Beck 在《Test-Driven Development: By Example》中系统提出的开发方法论。它的核心思想极其简单:先写测试,再写实现,最后重构

TDD 不是一种测试技术,而是一种设计技术。它通过测试来驱动代码的设计,让你在编码之前就思考接口、边界和职责。

1.2 TDD 核心循环:Red → Green → Refactor

TDD 的工作方式是一个不断重复的三步循环:

  1. Red(红灯):编写一个失败的测试用例。这个测试描述了你期望代码具备的下一个行为。此时测试一定是失败的,因为你还没有写实现。
  2. Green(绿灯):用最简单、最直接的方式让测试通过。不要过度设计,不要考虑优雅——仅仅让测试通过
  3. Refactor(重构):在测试通过的保护下,优化代码结构。消除重复、提取抽象、改善命名——测试确保重构不会破坏功能。
TDD 核心循环:

    ┌──────────────────────────────────────────────┐
    │                                              │
    │   ┌───────────┐                              │
    │   │           │                              │
    │   │  1. RED   │  编写一个失败的测试            │
    │   │  (红灯)   │  测试应该明确、具体            │
    │   │           │                              │
    │   └─────┬─────┘                              │
    │         │                                    │
    │         ▼                                    │
    │   ┌───────────┐                              │
    │   │           │                              │
    │   │ 2. GREEN  │  用最简单的方式让测试通过       │
    │   │  (绿灯)   │  不追求完美,只求通过          │
    │   │           │                              │
    │   └─────┬─────┘                              │
    │         │                                    │
    │         ▼                                    │
    │   ┌───────────┐                              │
    │   │           │                              │
    │   │3.REFACTOR │  在绿灯保护下重构代码          │
    │   │  (重构)   │  消除重复、优化设计            │
    │   │           │                              │
    │   └─────┬─────┘                              │
    │         │                                    │
    │         └────────────────────────────────────┘
    │                   循环往复

每一轮循环都非常小——通常在几分钟之内完成。这种快速的反馈循环让你始终保持在一个"可工作"的状态。

1.3 TDD 的三条规则

Uncle Bob(Robert C. Martin)将 TDD 提炼为三条严格的规则:

  1. 在编写一个失败的单元测试之前,不允许编写任何生产代码
  2. 只允许编写刚好导致测试失败的测试代码(编译失败也算失败)
  3. 只允许编写刚好让当前失败测试通过的生产代码

这三条规则看起来严苛,但它们的目的是确保你始终保持在 Red-Green-Refactor 的节奏中,不会跳跃式地编写大量未经验证的代码。

Uncle Bob 的 TDD 三规则在实践中的节奏:

  时间线 ──────────────────────────────────────────────────→

  测试代码:  ████░░░░████░░░░████░░░░████░░░░████░░░░
  生产代码:  ░░░░████░░░░████░░░░████░░░░████░░░░████
  重构:      ░░░░░░░░░░░░░░░░████░░░░░░░░░░░░░░░░████

  ████ = 正在编写     ░░░░ = 等待

  每次切换间隔: 1-5 分钟(保持极短的反馈循环)

1.4 TDD 的核心好处

驱动设计(Design Pressure)

TDD 最大的价值不在于测试本身,而在于它倒逼你思考设计。当你先写测试时,你不得不思考:

  • 这个函数的接口应该是什么样的?
  • 它应该接收什么参数,返回什么结果?
  • 它的职责是否单一?
  • 它是否容易被测试?(如果不容易测试,往往意味着设计有问题)

难以测试的代码 = 设计有问题。TDD 通过"可测试性"这把尺子,持续度量代码的设计质量。

高测试覆盖率

因为每一行生产代码都是为了让某个测试通过而编写的,所以 TDD 天然能达到非常高的测试覆盖率。

重构信心

有了全面的测试保护网,你可以大胆地进行重构——改变内部实现而不改变外部行为。测试全绿,重构成功。

活文档

测试用例本身就是代码行为的精确描述。新人阅读测试文件,就能快速理解每个函数的输入输出契约和边界行为。

快速反馈

每几分钟就能知道代码是否正确,而不是写了几百行代码后才发现某处有 Bug。

1.5 TDD 的挑战

学习曲线陡峭

TDD 需要思维方式的根本转变——从"先实现后验证"转向"先验证后实现"。很多开发者初学 TDD 时会觉得"不知道该先写什么测试"。

初期速度看似变慢

刚开始实践 TDD 时,开发速度确实会变慢。但随着经验积累,TDD 会显著减少调试时间和后期修复 Bug 的时间,总体效率反而更高。

TDD 的投资回报曲线:

  开发效率

    │                                    ╭──── TDD
    │                                 ╭──╯
    │                              ╭──╯
    │          ╭───────────────────╯
    │       ╭──╯
    │    ╭──╯
    │ ╭──╯
    ├─╯─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  无 TDD
    │╱

    └──────────────────────────────────────→ 时间
         ↑                    ↑
      初期较慢            长期收益显现
     (学习成本)         (Bug 少、重构快)

需要良好的测试基础设施

TDD 依赖快速的测试运行环境。如果测试执行缓慢(超过几秒),TDD 的快速反馈循环就会被打破,体验大打折扣。

对遗留代码困难

在没有测试的遗留代码中实践 TDD 非常困难——你需要先建立测试基础设施,抽取可测试的接口,这本身就是一项巨大的工程。

过度测试的风险

严格的 TDD 可能导致编写大量针对实现细节的测试,当重构时这些测试反而成为负担。需要有意识地在测试中关注行为而非实现。

1.6 TDD 实战:用 TDD 开发 Stack 数据结构

让我们用 TDD 方式从零开发一个 Stack(栈)数据结构,逐步展示 Red-Green-Refactor 的完整过程。

第一轮:创建空栈

Red —— 编写失败测试:

typescript
import { describe, it, expect } from 'vitest'
import { Stack } from './stack'

describe('Stack', () => {
  it('should create an empty stack', () => {
    const stack = new Stack()
    expect(stack.isEmpty()).toBe(true)
    expect(stack.size()).toBe(0)
  })
})

此时运行测试会失败,因为 Stack 类还不存在。

Green —— 最简实现:

typescript
export class Stack {
  private items: number[] = []

  isEmpty(): boolean {
    return true
  }

  size(): number {
    return 0
  }
}

测试通过。注意我们用了最简单的"硬编码"方式——isEmpty 直接返回 truesize 直接返回 0。这在 TDD 中是合法的,因为当前只有一个测试,这个实现足以让它通过。

Refactor —— 暂不需要重构。

第二轮:push 元素后栈不为空

Red:

typescript
it('should not be empty after pushing an element', () => {
  const stack = new Stack()
  stack.push(1)
  expect(stack.isEmpty()).toBe(false)
  expect(stack.size()).toBe(1)
})

测试失败,因为 push 方法不存在,且 isEmpty 硬编码返回 true

Green:

typescript
export class Stack {
  private items: number[] = []

  push(item: number): void {
    this.items.push(item)
  }

  isEmpty(): boolean {
    return this.items.length === 0
  }

  size(): number {
    return this.items.length
  }
}

现在我们不得不修改 isEmptysize 的实现,让两个测试都通过。

Refactor —— 暂不需要重构。

第三轮:pop 元素

Red:

typescript
it('should return the last pushed element when popping', () => {
  const stack = new Stack()
  stack.push(42)
  expect(stack.pop()).toBe(42)
  expect(stack.isEmpty()).toBe(true)
})

Green:

typescript
export class Stack {
  private items: number[] = []

  push(item: number): void {
    this.items.push(item)
  }

  pop(): number | undefined {
    return this.items.pop()
  }

  isEmpty(): boolean {
    return this.items.length === 0
  }

  size(): number {
    return this.items.length
  }
}

Refactor —— 暂不需要重构。

第四轮:LIFO 顺序

Red:

typescript
it('should follow LIFO order', () => {
  const stack = new Stack()
  stack.push(1)
  stack.push(2)
  stack.push(3)
  expect(stack.pop()).toBe(3)
  expect(stack.pop()).toBe(2)
  expect(stack.pop()).toBe(1)
})

Green —— 已经通过! 因为我们使用了数组的 push/pop,天然就是 LIFO。这说明有时候测试是对已有行为的确认,它同样有价值。

第五轮:peek 不移除元素

Red:

typescript
it('should peek at the top element without removing it', () => {
  const stack = new Stack()
  stack.push(10)
  stack.push(20)
  expect(stack.peek()).toBe(20)
  expect(stack.size()).toBe(2)
})

Green:

typescript
peek(): number | undefined {
  return this.items[this.items.length - 1]
}

第六轮:空栈的边界行为

Red:

typescript
it('should return undefined when popping an empty stack', () => {
  const stack = new Stack()
  expect(stack.pop()).toBeUndefined()
})

it('should return undefined when peeking an empty stack', () => {
  const stack = new Stack()
  expect(stack.peek()).toBeUndefined()
})

Green —— 已经通过! 因为数组的 pop() 在空数组时返回 undefined,我们的 peek 实现也自然处理了这个边界。

第七轮:支持泛型(重构驱动)

Refactor: 到目前为止,我们的 Stack 只支持 number。在所有测试通过的保护下,我们可以安全地重构为泛型:

typescript
export class Stack<T> {
  private items: T[] = []

  push(item: T): void {
    this.items.push(item)
  }

  pop(): T | undefined {
    return this.items.pop()
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1]
  }

  isEmpty(): boolean {
    return this.items.length === 0
  }

  size(): number {
    return this.items.length
  }
}

然后添加一个新测试验证泛型能力:

typescript
it('should work with string type', () => {
  const stack = new Stack<string>()
  stack.push('hello')
  stack.push('world')
  expect(stack.pop()).toBe('world')
  expect(stack.peek()).toBe('hello')
})

所有测试通过,泛型重构成功。

完整测试文件

typescript
import { describe, it, expect } from 'vitest'
import { Stack } from './stack'

describe('Stack', () => {
  it('should create an empty stack', () => {
    const stack = new Stack()
    expect(stack.isEmpty()).toBe(true)
    expect(stack.size()).toBe(0)
  })

  it('should not be empty after pushing an element', () => {
    const stack = new Stack()
    stack.push(1)
    expect(stack.isEmpty()).toBe(false)
    expect(stack.size()).toBe(1)
  })

  it('should return the last pushed element when popping', () => {
    const stack = new Stack()
    stack.push(42)
    expect(stack.pop()).toBe(42)
    expect(stack.isEmpty()).toBe(true)
  })

  it('should follow LIFO order', () => {
    const stack = new Stack()
    stack.push(1)
    stack.push(2)
    stack.push(3)
    expect(stack.pop()).toBe(3)
    expect(stack.pop()).toBe(2)
    expect(stack.pop()).toBe(1)
  })

  it('should peek at the top element without removing it', () => {
    const stack = new Stack()
    stack.push(10)
    stack.push(20)
    expect(stack.peek()).toBe(20)
    expect(stack.size()).toBe(2)
  })

  it('should return undefined when popping an empty stack', () => {
    const stack = new Stack()
    expect(stack.pop()).toBeUndefined()
  })

  it('should return undefined when peeking an empty stack', () => {
    const stack = new Stack()
    expect(stack.peek()).toBeUndefined()
  })

  it('should work with string type', () => {
    const stack = new Stack<string>()
    stack.push('hello')
    stack.push('world')
    expect(stack.pop()).toBe('world')
    expect(stack.peek()).toBe('hello')
  })
})

1.7 TDD 实战:表单验证器

让我们再看一个更贴近前端业务的例子——用 TDD 开发一个表单验证器。

第一轮:验证必填字段

Red:

typescript
import { describe, it, expect } from 'vitest'
import { FormValidator } from './form-validator'

describe('FormValidator', () => {
  it('should fail when required field is empty', () => {
    const validator = new FormValidator({
      username: { required: true },
    })
    const result = validator.validate({ username: '' })
    expect(result.valid).toBe(false)
    expect(result.errors.username).toBe('This field is required')
  })
})

Green:

typescript
interface FieldRules {
  required?: boolean
}

interface ValidationResult {
  valid: boolean
  errors: Record<string, string>
}

type Rules = Record<string, FieldRules>

export class FormValidator {
  constructor(private rules: Rules) {}

  validate(data: Record<string, string>): ValidationResult {
    const errors: Record<string, string> = {}

    for (const [field, rule] of Object.entries(this.rules)) {
      if (rule.required && !data[field]) {
        errors[field] = 'This field is required'
      }
    }

    return {
      valid: Object.keys(errors).length === 0,
      errors,
    }
  }
}

第二轮:必填字段有值时通过

Red:

typescript
it('should pass when required field has value', () => {
  const validator = new FormValidator({
    username: { required: true },
  })
  const result = validator.validate({ username: 'john' })
  expect(result.valid).toBe(true)
  expect(result.errors).toEqual({})
})

Green —— 已经通过。

第三轮:最小长度验证

Red:

typescript
it('should fail when value is shorter than minLength', () => {
  const validator = new FormValidator({
    password: { minLength: 8 },
  })
  const result = validator.validate({ password: '123' })
  expect(result.valid).toBe(false)
  expect(result.errors.password).toBe('Minimum length is 8')
})

Green:

typescript
interface FieldRules {
  required?: boolean
  minLength?: number
}

export class FormValidator {
  constructor(private rules: Rules) {}

  validate(data: Record<string, string>): ValidationResult {
    const errors: Record<string, string> = {}

    for (const [field, rule] of Object.entries(this.rules)) {
      const value = data[field] || ''

      if (rule.required && !value) {
        errors[field] = 'This field is required'
        continue
      }

      if (rule.minLength && value.length < rule.minLength) {
        errors[field] = `Minimum length is ${rule.minLength}`
      }
    }

    return {
      valid: Object.keys(errors).length === 0,
      errors,
    }
  }
}

第四轮:正则表达式验证

Red:

typescript
it('should fail when value does not match pattern', () => {
  const validator = new FormValidator({
    email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
  })
  const result = validator.validate({ email: 'invalid-email' })
  expect(result.valid).toBe(false)
  expect(result.errors.email).toBe('Invalid format')
})

it('should pass when value matches pattern', () => {
  const validator = new FormValidator({
    email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
  })
  const result = validator.validate({ email: 'test@example.com' })
  expect(result.valid).toBe(true)
})

Green:

typescript
interface FieldRules {
  required?: boolean
  minLength?: number
  pattern?: RegExp
}

validate(data: Record<string, string>): ValidationResult {
  const errors: Record<string, string> = {}

  for (const [field, rule] of Object.entries(this.rules)) {
    const value = data[field] || ''

    if (rule.required && !value) {
      errors[field] = 'This field is required'
      continue
    }

    if (rule.minLength && value.length < rule.minLength) {
      errors[field] = `Minimum length is ${rule.minLength}`
      continue
    }

    if (rule.pattern && !rule.pattern.test(value)) {
      errors[field] = 'Invalid format'
    }
  }

  return {
    valid: Object.keys(errors).length === 0,
    errors,
  }
}

第五轮:多字段同时验证

Red:

typescript
it('should validate multiple fields simultaneously', () => {
  const validator = new FormValidator({
    username: { required: true },
    password: { required: true, minLength: 8 },
    email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
  })
  const result = validator.validate({
    username: '',
    password: '123',
    email: 'bad',
  })
  expect(result.valid).toBe(false)
  expect(result.errors.username).toBe('This field is required')
  expect(result.errors.password).toBe('This field is required')
  expect(result.errors.email).toBe('This field is required')
})

注意:这个测试暴露了一个问题——当字段既是 required 又有 minLength 时,如果值为空,应该先报 required 错误。当前实现已经正确处理了这一点(continue 语句)。

Green —— 已经通过。

这就是 TDD 的力量——每一步都很小、很安全,但积少成多就构建出了一个完整且经过充分测试的模块。


二、BDD(行为驱动开发)

2.1 什么是 BDD

BDD(Behavior-Driven Development)是 Dan North 在 2003 年提出的开发方法论。它从 TDD 发展而来,但将关注点从代码的内部实现转移到系统的外部行为

BDD 的核心理念:用自然语言描述软件的行为,让所有利益相关者(开发、测试、产品、业务)都能理解需求和验收标准。

TDD 与 BDD 的关注点差异:

  TDD 关注:                           BDD 关注:
  ┌────────────────────┐              ┌────────────────────┐
  │                    │              │                    │
  │  函数 add(a, b)    │              │  用户点击"购买"按钮 │
  │  输入: 1, 2        │              │  系统应该:          │
  │  输出: 3           │              │  - 创建订单         │
  │                    │              │  - 扣减库存         │
  │  关注: 实现正确性   │              │  - 发送确认邮件     │
  │                    │              │                    │
  │  测试者: 开发者     │              │  关注: 业务行为     │
  │                    │              │  测试者: 全团队     │
  └────────────────────┘              └────────────────────┘

2.2 BDD 的核心语法:Given-When-Then

BDD 使用 Given-When-Then 三段式来描述行为场景:

  • Given(给定):描述系统的初始状态和前置条件
  • When(当):描述用户执行的操作或触发的事件
  • Then(那么):描述期望的结果和系统响应
Given-When-Then 模型:

  ┌─────────────────────────────────────────────┐
  │                                             │
  │  Given: 前置条件(系统处于某个已知状态)        │
  │  ┌─────────────────────────────────┐        │
  │  │  用户已登录                      │        │
  │  │  购物车中有 3 件商品             │         │
  │  │  账户余额为 1000 元              │        │
  │  └─────────────────────────────────┘        │
  │                   │                         │
  │                   ▼                         │
  │  When: 触发动作(用户做了什么)                │
  │  ┌─────────────────────────────────┐        │
  │  │  用户点击"结算"按钮              │         │
  │  └─────────────────────────────────┘        │
  │                   │                         │
  │                   ▼                         │
  │  Then: 期望结果(系统应该怎样响应)            │
  │  ┌─────────────────────────────────┐        │
  │  │  生成一个订单                    │         │
  │  │  账户余额减少相应金额             │        │
  │  │  显示支付成功页面                │         │
  │  └─────────────────────────────────┘        │
  │                                             │
  └─────────────────────────────────────────────┘

2.3 Cucumber 与 Gherkin 语法

Gherkin 是 BDD 中用于编写行为规范的领域特定语言(DSL),Cucumber 是执行 Gherkin 规范的工具框架。

一个标准的 Gherkin Feature 文件:

gherkin
Feature: User Login
  As a registered user
  I want to log in to my account
  So that I can access my personal dashboard

  Scenario: Successful login with valid credentials
    Given the user is on the login page
    And the user has a registered account with email "user@example.com"
    When the user enters email "user@example.com"
    And the user enters password "SecurePass123"
    And the user clicks the "Login" button
    Then the user should be redirected to the dashboard
    And a welcome message "Hello, User!" should be displayed

  Scenario: Failed login with wrong password
    Given the user is on the login page
    When the user enters email "user@example.com"
    And the user enters password "WrongPassword"
    And the user clicks the "Login" button
    Then an error message "Invalid email or password" should be displayed
    And the user should remain on the login page

  Scenario Outline: Password validation rules
    Given the user is on the registration page
    When the user enters password "<password>"
    Then the password strength indicator should show "<strength>"

    Examples:
      | password     | strength |
      | 123          | weak     |
      | abcdefgh     | medium   |
      | Abc@1234!    | strong   |

Gherkin 语法的关键字:

Gherkin 关键字体系:

  Feature           功能描述(一个 .feature 文件对应一个 Feature)

    ├── Background   所有 Scenario 的公共前置条件

    ├── Scenario     一个具体的行为场景
    │     │
    │     ├── Given   前置条件
    │     ├── And     附加条件(连接 Given/When/Then)
    │     ├── When    触发动作
    │     ├── Then    期望结果
    │     └── But     排除条件

    └── Scenario Outline   参数化场景模板
          └── Examples      参数数据表

2.4 BDD 与 TDD 的关系

BDD 不是 TDD 的替代品,而是 TDD 的补充和扩展。可以把它们看作不同层次的抽象:

BDD 与 TDD 的层次关系:

  ┌─────────────────────────────────────────────┐
  │                                             │
  │  BDD (外层)                                 │
  │  ┌─────────────────────────────────────┐    │
  │  │                                     │    │
  │  │  描述系统行为(用户故事级别)          │    │
  │  │  Given-When-Then                    │    │
  │  │  "当用户提交表单时,应该显示成功提示"  │    │
  │  │                                     │    │
  │  │  ┌─────────────────────────────┐    │    │
  │  │  │                             │    │    │
  │  │  │  TDD (内层)                 │    │    │
  │  │  │                             │    │    │
  │  │  │  驱动具体实现               │     │    │
  │  │  │  Red-Green-Refactor         │    │    │
  │  │  │  "validateForm() 应返回..."  │    │    │
  │  │  │                             │    │    │
  │  │  └─────────────────────────────┘    │    │
  │  │                                     │    │
  │  └─────────────────────────────────────┘    │
  │                                             │
  └─────────────────────────────────────────────┘

  BDD 定义"做什么" → TDD 驱动"怎么做"

2.5 在 Vitest/Jest 中实践 BDD 风格

虽然不一定要用 Cucumber 这类专门的 BDD 框架,我们完全可以在 Vitest/Jest 中用 BDD 风格编写测试。关键在于使用 describeit 的嵌套来表达 Given-When-Then 结构。

方式一:describe 嵌套表达上下文

typescript
import { describe, it, expect } from 'vitest'
import { ShoppingCart } from './shopping-cart'

describe('ShoppingCart', () => {
  describe('when the cart is empty', () => {
    it('should have zero items', () => {
      const cart = new ShoppingCart()
      expect(cart.itemCount()).toBe(0)
    })

    it('should have zero total price', () => {
      const cart = new ShoppingCart()
      expect(cart.totalPrice()).toBe(0)
    })

    it('should not allow checkout', () => {
      const cart = new ShoppingCart()
      expect(cart.canCheckout()).toBe(false)
    })
  })

  describe('when adding a product', () => {
    it('should increase item count by one', () => {
      const cart = new ShoppingCart()
      cart.addProduct({ id: '1', name: 'Book', price: 29.99 })
      expect(cart.itemCount()).toBe(1)
    })

    it('should include the product price in total', () => {
      const cart = new ShoppingCart()
      cart.addProduct({ id: '1', name: 'Book', price: 29.99 })
      expect(cart.totalPrice()).toBe(29.99)
    })
  })

  describe('when applying a discount code', () => {
    describe('and the code is valid', () => {
      it('should reduce total price by discount percentage', () => {
        const cart = new ShoppingCart()
        cart.addProduct({ id: '1', name: 'Book', price: 100 })
        cart.applyDiscount('SAVE20')
        expect(cart.totalPrice()).toBe(80)
      })
    })

    describe('and the code is invalid', () => {
      it('should throw an error', () => {
        const cart = new ShoppingCart()
        expect(() => cart.applyDiscount('FAKE')).toThrow('Invalid discount code')
      })
    })
  })

  describe('when removing the last item', () => {
    it('should make the cart empty again', () => {
      const cart = new ShoppingCart()
      cart.addProduct({ id: '1', name: 'Book', price: 29.99 })
      cart.removeProduct('1')
      expect(cart.isEmpty()).toBe(true)
    })

    it('should not allow checkout', () => {
      const cart = new ShoppingCart()
      cart.addProduct({ id: '1', name: 'Book', price: 29.99 })
      cart.removeProduct('1')
      expect(cart.canCheckout()).toBe(false)
    })
  })
})

注意 describe 的嵌套结构如何自然地映射到 Given-When-Then:

映射关系:

  describe('ShoppingCart')                 → Feature
    describe('when the cart is empty')     → Given (空购物车)
      it('should have zero items')         → Then (期望结果)
    describe('when adding a product')      → When (添加商品)
      it('should increase item count')     → Then (期望结果)

方式二:显式 Given-When-Then 注释结构

typescript
import { describe, it, expect, beforeEach } from 'vitest'
import { AuthService } from './auth-service'

describe('AuthService', () => {
  let authService: AuthService

  describe('login', () => {
    beforeEach(() => {
      authService = new AuthService()
    })

    it('should authenticate user with valid credentials', async () => {
      authService.registerUser('alice', 'password123')

      const result = await authService.login('alice', 'password123')

      expect(result.success).toBe(true)
      expect(result.token).toBeDefined()
    })

    it('should reject invalid password', async () => {
      authService.registerUser('alice', 'password123')

      const result = await authService.login('alice', 'wrongpassword')

      expect(result.success).toBe(false)
      expect(result.error).toBe('Invalid credentials')
    })

    it('should reject non-existent user', async () => {
      const result = await authService.login('unknown', 'password')

      expect(result.success).toBe(false)
      expect(result.error).toBe('User not found')
    })

    it('should lock account after 3 failed attempts', async () => {
      authService.registerUser('alice', 'password123')

      await authService.login('alice', 'wrong1')
      await authService.login('alice', 'wrong2')
      await authService.login('alice', 'wrong3')

      const result = await authService.login('alice', 'password123')

      expect(result.success).toBe(false)
      expect(result.error).toBe('Account locked')
    })
  })
})

方式三:使用 testing-library 实践组件级 BDD

tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  describe('when the form is initially rendered', () => {
    it('should display email and password fields', () => {
      render(<LoginForm onSubmit={() => {}} />)

      expect(screen.getByLabelText('Email')).toBeInTheDocument()
      expect(screen.getByLabelText('Password')).toBeInTheDocument()
    })

    it('should have a disabled submit button', () => {
      render(<LoginForm onSubmit={() => {}} />)

      expect(screen.getByRole('button', { name: 'Login' })).toBeDisabled()
    })
  })

  describe('when the user fills in valid credentials', () => {
    it('should enable the submit button', async () => {
      const user = userEvent.setup()
      render(<LoginForm onSubmit={() => {}} />)

      await user.type(screen.getByLabelText('Email'), 'test@example.com')
      await user.type(screen.getByLabelText('Password'), 'password123')

      expect(screen.getByRole('button', { name: 'Login' })).toBeEnabled()
    })
  })

  describe('when the user submits the form', () => {
    it('should call onSubmit with the form data', async () => {
      const user = userEvent.setup()
      const handleSubmit = vi.fn()
      render(<LoginForm onSubmit={handleSubmit} />)

      await user.type(screen.getByLabelText('Email'), 'test@example.com')
      await user.type(screen.getByLabelText('Password'), 'password123')
      await user.click(screen.getByRole('button', { name: 'Login' }))

      expect(handleSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      })
    })
  })

  describe('when the login fails', () => {
    it('should display an error message', async () => {
      const user = userEvent.setup()
      const handleSubmit = vi.fn().mockRejectedValue(new Error('Invalid credentials'))
      render(<LoginForm onSubmit={handleSubmit} />)

      await user.type(screen.getByLabelText('Email'), 'test@example.com')
      await user.type(screen.getByLabelText('Password'), 'wrong')
      await user.click(screen.getByRole('button', { name: 'Login' }))

      expect(await screen.findByText('Invalid credentials')).toBeInTheDocument()
    })
  })
})

三、TDD vs BDD 对比

3.1 核心对比表

维度TDDBDD
全称Test-Driven DevelopmentBehavior-Driven Development
提出者Kent BeckDan North
核心关注点代码单元的正确性系统行为是否符合业务需求
描述语言编程语言(测试代码)自然语言 + 编程语言
测试粒度细粒度(函数/方法级别)粗粒度(用户故事/场景级别)
典型语法expect(add(1,2)).toBe(3)Given...When...Then...
主要受众开发者开发者 + 测试 + 产品 + 业务
驱动方式测试驱动代码实现行为规范驱动测试和实现
文档价值技术文档(面向开发者)业务文档(面向全团队)
工具Vitest/Jest/MochaCucumber/SpecFlow/Vitest
适用场景工具函数、算法、数据处理业务逻辑、用户交互、端到端流程
学习成本中等较高(需要团队共识)

3.2 测试编写风格对比

同一个功能,用 TDD 和 BDD 风格编写的差异:

TDD 风格(关注实现):

typescript
describe('calculateDiscount', () => {
  it('should return 0 when amount is less than 100', () => {
    expect(calculateDiscount(50)).toBe(0)
  })

  it('should return 10% when amount is between 100 and 500', () => {
    expect(calculateDiscount(200)).toBe(20)
  })

  it('should return 20% when amount is greater than 500', () => {
    expect(calculateDiscount(1000)).toBe(200)
  })
})

BDD 风格(关注行为):

typescript
describe('Discount calculation', () => {
  describe('when a customer makes a small purchase (under $100)', () => {
    it('should not apply any discount', () => {
      expect(calculateDiscount(50)).toBe(0)
    })
  })

  describe('when a customer makes a medium purchase ($100-$500)', () => {
    it('should apply a 10% loyalty discount', () => {
      expect(calculateDiscount(200)).toBe(20)
    })
  })

  describe('when a customer makes a large purchase (over $500)', () => {
    it('should apply a 20% VIP discount', () => {
      expect(calculateDiscount(1000)).toBe(200)
    })
  })
})

两者的断言逻辑完全相同,但 BDD 风格通过 describe 的嵌套和描述性命名,清晰地传达了业务语义

3.3 选择建议

何时选择 TDD vs BDD:

  ┌──────────────────────────────────────────────────┐
  │                                                  │
  │  选择 TDD:                                       │
  │  ┌──────────────────────────────────┐            │
  │  │ ✓ 开发工具函数/算法/数据处理      │            │
  │  │ ✓ 纯逻辑模块(无 UI 交互)       │             │
  │  │ ✓ 团队以开发者为主               │             │
  │  │ ✓ 需要极高的代码覆盖率           │             │
  │  │ ✓ 底层库和框架开发               │             │
  │  └──────────────────────────────────┘            │
  │                                                  │
  │  选择 BDD:                                       │
  │  ┌──────────────────────────────────┐            │
  │  │ ✓ 复杂业务逻辑的需求澄清         │            │
  │  │ ✓ 用户交互场景的验收测试          │            │
  │  │ ✓ 跨职能团队协作                 │             │
  │  │ ✓ 需要非技术人员理解测试          │            │
  │  │ ✓ 端到端的功能验证               │             │
  │  └──────────────────────────────────┘            │
  │                                                  │
  │  最佳实践: 两者结合                               │
  │  ┌──────────────────────────────────┐            │
  │  │ BDD 定义外层行为规范              │            │
  │  │        ↓                         │            │
  │  │ TDD 驱动内层代码实现              │            │
  │  └──────────────────────────────────┘            │
  │                                                  │
  └──────────────────────────────────────────────────┘

四、测试策略

4.1 测试金字塔

测试金字塔(Test Pyramid)由 Mike Cohn 提出,是最经典的测试分层策略模型:

测试金字塔(Test Pyramid):

                        ╱╲
                       ╱  ╲
                      ╱    ╲
                     ╱ E2E  ╲           数量: 少
                    ╱ Tests  ╲          速度: 慢(秒~分钟)
                   ╱──────────╲         成本: 高
                  ╱            ╲        信心: 高(但脆弱)
                 ╱ Integration  ╲       数量: 适中
                ╱    Tests      ╲       速度: 中(毫秒~秒)
               ╱────────────────╲       成本: 中
              ╱                  ╲      信心: 中高
             ╱    Unit Tests      ╲     数量: 多
            ╱                      ╲    速度: 快(毫秒)
           ╱────────────────────────╲   成本: 低
          ╱                          ╲  信心: 中(单元级别)
         ╱────────────────────────────╲

  核心原则:
  ├── 底层测试多、快、便宜
  ├── 顶层测试少、慢、昂贵
  └── 越往上,覆盖的场景越接近真实用户,但维护成本越高

4.2 测试奖杯(Testing Trophy)

Kent C. Dodds 提出了 Testing Trophy(测试奖杯)作为对传统测试金字塔的补充和修正。他认为在前端领域,集成测试应该是投入最多的层级:

测试奖杯(Testing Trophy):

                    ┌────┐
                    │E2E │              少量
                    │    │              验证关键用户流程
              ┌─────┴────┴─────┐
              │                │
              │  Integration   │        最多 ← 重点投入!
              │    Tests       │        验证模块协作
              │                │        最高 ROI
              │                │
              ├────────────────┤
              │                │
              │  Unit Tests    │        适量
              │                │        验证纯逻辑
              │                │
         ┌────┴────────────────┴────┐
         │                          │
         │      Static Analysis     │   TypeScript + ESLint
         │                          │   零运行时成本
         └──────────────────────────┘

  Kent C. Dodds 的核心观点:
  "Write tests. Not too many. Mostly integration."
  (写测试。不要太多。主要写集成测试。)

为什么集成测试 ROI 最高?

不同层级测试的 ROI 分析:

  ┌──────────────────────────────────────────────────────────┐
  │                                                          │
  │  Static Analysis (静态分析)                               │
  │  ├── 成本: 几乎为零(配置一次,自动运行)                   │
  │  ├── 能发现: 类型错误、语法错误、代码风格问题                │
  │  └── 不能发现: 逻辑错误、运行时错误                        │
  │                                                          │
  │  Unit Tests (单元测试)                                    │
  │  ├── 成本: 低                                             │
  │  ├── 能发现: 函数级别的逻辑错误                            │
  │  └── 不能发现: 模块间的集成问题、UI 渲染问题                │
  │                                                          │
  │  Integration Tests (集成测试)    ← 最佳平衡点              │
  │  ├── 成本: 中等                                           │
  │  ├── 能发现: 模块协作问题、组件交互问题、状态管理问题         │
  │  └── 最接近用户真实使用方式,同时速度仍然较快                │
  │                                                          │
  │  E2E Tests (端到端测试)                                    │
  │  ├── 成本: 高(慢、脆弱、需要维护)                        │
  │  ├── 能发现: 完整流程中的问题                              │
  │  └── 应该只覆盖关键路径(登录、支付、核心业务流程)          │
  │                                                          │
  └──────────────────────────────────────────────────────────┘

4.3 何时写测试

关于"先写测试 vs 后写测试 vs 不写测试",业界有不同的观点和实践:

先写测试(Test First / TDD)

适用场景:
├── 开发新功能,需求明确
├── 开发工具库/SDK
├── 复杂算法或业务逻辑
├── 团队有 TDD 文化和经验
└── 对代码质量要求极高的项目

典型流程:
  需求 → 写测试 → 实现 → 重构 → 通过

后写测试(Test After)

适用场景:
├── 快速原型验证
├── 探索性开发(不确定接口形态)
├── 遗留代码的测试补充
├── UI 组件的交互测试
└── 大部分团队的实际做法

典型流程:
  需求 → 实现 → 写测试 → 发现问题 → 修复 → 通过

不写测试(No Test)

可以接受的场景:
├── 一次性脚本
├── 快速实验/PoC
├── 即将废弃的代码
└── 简单的配置文件

不可接受的场景:
├── 核心业务逻辑
├── 公共库/SDK
├── 金融/支付相关代码
└── 长期维护的项目

4.4 测试覆盖率的陷阱

很多团队将测试覆盖率作为代码质量的衡量指标,甚至设置硬性门槛(如必须达到 80%)。但需要清醒认识到:100% 覆盖率 ≠ 没有 Bug。

覆盖率的真相:

  ┌─────────────────────────────────────────────┐
  │                                             │
  │  代码:                                      │
  │  function divide(a, b) {                    │
  │    return a / b                             │
  │  }                                          │
  │                                             │
  │  测试:                                      │
  │  expect(divide(10, 2)).toBe(5)  ✓           │
  │                                             │
  │  覆盖率: 100% ✅                             │
  │                                             │
  │  但是...                                    │
  │  divide(10, 0) = Infinity   没测!❌          │
  │  divide(null, 2) = 0        没测!❌          │
  │  divide('a', 2) = NaN       没测!❌          │
  │                                             │
  │  结论: 100% 覆盖率只是说明每行代码都被执行了,  │
  │        不代表所有边界情况都被验证了。            │
  │                                             │
  └─────────────────────────────────────────────┘

覆盖率的正确使用方式:

做法说明
✅ 作为参考指标覆盖率下降时提醒团队关注
✅ 关注增量覆盖新代码的覆盖率应该高于整体
✅ 关注分支覆盖比行覆盖更有价值
❌ 作为唯一指标不能仅凭覆盖率判断测试质量
❌ 追求 100%为了覆盖率写无意义的测试适得其反
❌ 设置过高门槛导致团队写低质量测试凑数

4.5 增量测试策略:关键路径优先

在资源有限的情况下,应该优先测试最重要的部分:

增量测试策略:

  优先级从高到低:

  P0 ─── 核心业务逻辑(支付、订单、认证)
  │      必须有完整的单元 + 集成 + E2E 测试

  P1 ─── 公共工具函数 / 核心组件
  │      必须有单元测试 + 组件测试

  P2 ─── 频繁变更的模块
  │      至少有关键路径的集成测试

  P3 ─── 简单的展示型组件
  │      可选:快照测试或视觉回归测试

  P4 ─── 一次性代码 / 即将废弃的模块
         通常不需要测试

五、前端测试完整策略

5.1 各层级测试的分工

前端测试是一个多层次的体系,每个层级有不同的职责和工具:

前端测试分层架构:

  ┌──────────────────────────────────────────────────────┐
  │                     E2E Tests                        │
  │                  (Playwright / Cypress)               │
  │                                                      │
  │  验证完整的用户流程                                     │
  │  ├── 用户注册 → 登录 → 浏览商品 → 下单 → 支付          │
  │  ├── 真实浏览器环境                                    │
  │  ├── 可以测试跨页面、跨系统的交互                       │
  │  └── 运行慢,数量少,只覆盖关键路径                     │
  ├──────────────────────────────────────────────────────┤
  │                 Integration Tests                     │
  │           (Vitest + Testing Library + MSW)            │
  │                                                      │
  │  验证多个模块的协作                                     │
  │  ├── 组件 + 状态管理 + API 调用的协作                   │
  │  ├── 模拟 API(MSW)但不模拟内部模块                    │
  │  ├── 最接近用户使用方式,ROI 最高                       │
  │  └── 是测试投入的重点                                  │
  ├──────────────────────────────────────────────────────┤
  │                  Component Tests                      │
  │              (Vitest + Testing Library)               │
  │                                                      │
  │  验证单个组件的渲染和交互                               │
  │  ├── Props 传入 → 渲染正确                             │
  │  ├── 用户操作 → 回调触发 / 状态变化                     │
  │  ├── 在 jsdom/happy-dom 中运行                        │
  │  └── 以用户视角而非实现细节                             │
  ├──────────────────────────────────────────────────────┤
  │                    Unit Tests                         │
  │                     (Vitest)                          │
  │                                                      │
  │  验证纯函数和工具模块                                   │
  │  ├── formatDate, calculatePrice, validateEmail        │
  │  ├── 不依赖 DOM、网络、定时器                           │
  │  ├── 运行极快(毫秒级)                                │
  │  └── 关注输入 → 输出的映射关系                          │
  ├──────────────────────────────────────────────────────┤
  │                  Static Analysis                      │
  │               (TypeScript + ESLint)                   │
  │                                                      │
  │  在编码阶段捕获问题                                     │
  │  ├── 类型错误(TypeScript)                            │
  │  ├── 代码规范(ESLint)                                │
  │  ├── 零运行时成本                                      │
  │  └── 配置一次,持续受益                                 │
  └──────────────────────────────────────────────────────┘

5.2 各层级的测试数量建议

建议的测试分布(基于 Testing Trophy 理念):

  测试类型          │  占比   │  运行时间  │  重点覆盖
  ─────────────────┼────────┼──────────┼────────────────
  Static Analysis   │  -     │  实时     │  全部代码
  Unit Tests        │  20%   │  < 1ms   │  纯函数/算法
  Component Tests   │  25%   │  < 50ms  │  UI 组件
  Integration Tests │  40%   │  < 200ms │  模块协作/页面
  E2E Tests         │  15%   │  < 30s   │  关键用户流程

5.3 测试基础设施搭建

一个完整的前端测试基础设施通常包含以下工具链:

前端测试技术栈:

  ┌──────────────────────────────────────────────────────┐
  │                                                      │
  │  测试运行器 & 断言库                                   │
  │  ┌────────────────────────────────────────┐          │
  │  │  Vitest                                │          │
  │  │  ├── 兼容 Jest API                     │          │
  │  │  ├── 基于 Vite,速度极快               │           │
  │  │  ├── 原生 ESM 支持                     │          │
  │  │  ├── 内置覆盖率(c8/istanbul)          │          │
  │  │  └── 支持并行执行                      │           │
  │  └────────────────────────────────────────┘          │
  │                                                      │
  │  组件测试                                             │
  │  ┌────────────────────────────────────────┐          │
  │  │  Testing Library                       │          │
  │  │  ├── @testing-library/react            │          │
  │  │  ├── @testing-library/vue              │          │
  │  │  ├── @testing-library/user-event       │          │
  │  │  └── 以用户视角测试组件                 │           │
  │  └────────────────────────────────────────┘          │
  │                                                      │
  │  API 模拟                                             │
  │  ┌────────────────────────────────────────┐          │
  │  │  MSW (Mock Service Worker)             │          │
  │  │  ├── 在网络层拦截请求                   │          │
  │  │  ├── 不侵入业务代码                    │           │
  │  │  ├── 可在浏览器和 Node.js 中使用        │          │
  │  │  └── 支持 REST 和 GraphQL              │          │
  │  └────────────────────────────────────────┘          │
  │                                                      │
  │  E2E 测试                                             │
  │  ┌────────────────────────────────────────┐          │
  │  │  Playwright                            │          │
  │  │  ├── 多浏览器支持(Chromium/Firefox/    │          │
  │  │  │   WebKit)                          │          │
  │  │  ├── 自动等待机制                      │           │
  │  │  ├── 强大的选择器引擎                   │          │
  │  │  ├── 内置截图和录像                    │           │
  │  │  └── 并行执行                          │           │
  │  └────────────────────────────────────────┘          │
  │                                                      │
  └──────────────────────────────────────────────────────┘

Vitest 配置示例

typescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
      ],
      thresholds: {
        branches: 70,
        functions: 70,
        lines: 70,
        statements: 70,
      },
    },
  },
})

MSW 配置示例

typescript
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

const handlers = [
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: 1,
      name: 'Alice',
      email: 'alice@example.com',
    })
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json() as { email: string; password: string }

    if (body.email === 'alice@example.com' && body.password === 'password') {
      return HttpResponse.json({ token: 'fake-jwt-token' })
    }

    return HttpResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    )
  }),
]

export const server = setupServer(...handlers)
typescript
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

集成测试示例(组件 + API + 状态管理)

tsx
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
import { UserProfile } from './UserProfile'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

function renderWithProviders(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  })

  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  )
}

describe('UserProfile', () => {
  describe('when the page loads', () => {
    it('should fetch and display user information', async () => {
      renderWithProviders(<UserProfile userId="1" />)

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

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

      expect(screen.getByText('alice@example.com')).toBeInTheDocument()
    })
  })

  describe('when the API returns an error', () => {
    it('should display an error message', async () => {
      server.use(
        http.get('/api/user', () => {
          return HttpResponse.json(
            { error: 'User not found' },
            { status: 404 }
          )
        })
      )

      renderWithProviders(<UserProfile userId="999" />)

      await waitFor(() => {
        expect(screen.getByText('Failed to load user')).toBeInTheDocument()
      })
    })
  })

  describe('when the user clicks the edit button', () => {
    it('should switch to edit mode and save changes', async () => {
      const user = userEvent.setup()
      renderWithProviders(<UserProfile userId="1" />)

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

      await user.click(screen.getByRole('button', { name: 'Edit' }))

      const nameInput = screen.getByLabelText('Name')
      await user.clear(nameInput)
      await user.type(nameInput, 'Alice Updated')

      server.use(
        http.put('/api/user/1', () => {
          return HttpResponse.json({
            id: 1,
            name: 'Alice Updated',
            email: 'alice@example.com',
          })
        })
      )

      await user.click(screen.getByRole('button', { name: 'Save' }))

      await waitFor(() => {
        expect(screen.getByText('Alice Updated')).toBeInTheDocument()
      })
    })
  })
})

Playwright E2E 测试示例

typescript
import { test, expect } from '@playwright/test'

test.describe('User Authentication Flow', () => {
  test('should allow a user to register and login', async ({ page }) => {
    await page.goto('/register')

    await page.getByLabel('Email').fill('newuser@example.com')
    await page.getByLabel('Password').fill('SecurePass123!')
    await page.getByLabel('Confirm Password').fill('SecurePass123!')
    await page.getByRole('button', { name: 'Register' }).click()

    await expect(page.getByText('Registration successful')).toBeVisible()

    await page.goto('/login')

    await page.getByLabel('Email').fill('newuser@example.com')
    await page.getByLabel('Password').fill('SecurePass123!')
    await page.getByRole('button', { name: 'Login' }).click()

    await expect(page).toHaveURL('/dashboard')
    await expect(page.getByText('Welcome')).toBeVisible()
  })

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

    await page.getByLabel('Email').fill('wrong@example.com')
    await page.getByLabel('Password').fill('wrongpassword')
    await page.getByRole('button', { name: 'Login' }).click()

    await expect(page.getByText('Invalid email or password')).toBeVisible()
    await expect(page).toHaveURL('/login')
  })
})

5.4 CI 集成:测试自动化流水线

一个完善的 CI 测试流水线应该分阶段执行不同类型的测试:

CI 测试自动化流水线:

  ┌─────────┐     ┌──────────┐     ┌──────────┐     ┌──────────┐
  │  Push /  │     │  Stage 1 │     │  Stage 2 │     │  Stage 3 │
  │   PR     │────▶│  Lint &  │────▶│  Unit &  │────▶│  E2E     │
  │          │     │  Type    │     │Component │     │  Tests   │
  └─────────┘     │  Check   │     │  & Integ │     │          │
                   │          │     │  Tests   │     │          │
                   └────┬─────┘     └────┬─────┘     └────┬─────┘
                        │                │                │
                   ┌────▼─────┐     ┌────▼─────┐     ┌────▼─────┐
                   │ ~30 sec  │     │ ~2 min   │     │ ~5 min   │
                   │          │     │          │     │          │
                   │ ESLint   │     │ Vitest   │     │Playwright│
                   │ tsc      │     │ Coverage │     │ Critical │
                   │ Prettier │     │ Report   │     │ Paths    │
                   └──────────┘     └──────────┘     └──────────┘
                        │                │                │
                        ▼                ▼                ▼
                   Fast Fail         覆盖率报告       截图/录像
                   快速失败反馈      上传到 CI 平台     失败时保存

GitHub Actions 配置示例

yaml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm run lint
      - run: pnpm run typecheck

  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm run test --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  e2e:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps
      - run: pnpm run build
      - run: pnpm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

5.5 前端测试完整架构图

前端测试完整架构:

  开发阶段                     CI/CD 阶段                    生产阶段
  ──────────                   ──────────                    ──────────

  ┌──────────┐                ┌──────────────┐             ┌──────────┐
  │ IDE 集成  │                │  Git Hook    │             │ 错误监控  │
  │          │                │  (husky)     │             │ (Sentry) │
  │ TypeScript│               │              │             │          │
  │ ESLint   │                │ pre-commit:  │             │ 运行时    │
  │ 实时反馈  │                │  lint-staged │             │ 错误收集  │
  └────┬─────┘                └──────┬───────┘             └──────────┘
       │                             │
       ▼                             ▼
  ┌──────────┐                ┌──────────────┐
  │ Watch    │                │  CI Pipeline │
  │ Mode     │                │              │
  │          │                │  lint        │
  │ Vitest   │                │    ↓         │
  │ --watch  │                │  typecheck   │
  │          │                │    ↓         │
  │ 改代码→   │                │  unit test   │
  │ 自动跑测试│                │    ↓         │
  └──────────┘                │  integration │
                              │    ↓         │
                              │  e2e test    │
                              │    ↓         │
                              │  coverage    │
                              │  report      │
                              │    ↓         │
                              │  deploy      │
                              └──────────────┘

5.6 测试命名规范

好的测试命名让测试报告本身就是一份文档。推荐以下命名模式:

单元测试命名

模式: [被测函数] + [场景/输入] + [期望结果]

示例:
  ✅ "formatPrice should return $0.00 when given 0"
  ✅ "validateEmail should return false for missing @ symbol"
  ✅ "parseQueryString should handle empty string"

  ❌ "test formatPrice"
  ❌ "it works"
  ❌ "test case 1"

BDD 风格命名

模式: describe("when [场景]") + it("should [期望行为]")

示例:
  describe('ShoppingCart')
    describe('when adding an item that already exists')
      it('should increase the quantity instead of adding a duplicate')

  输出:
    ShoppingCart
      when adding an item that already exists
        ✓ should increase the quantity instead of adding a duplicate

5.7 测试中的常见反模式

反模式问题正确做法
测试实现细节重构时测试就挂,增加维护成本测试行为和输出,而非内部状态
过度 Mock测试失去意义,只在验证 Mock只 Mock 外部依赖(API、定时器)
测试间依赖测试顺序影响结果,难以调试每个测试独立,用 beforeEach 重置
快照滥用快照过大,改动时无脑更新只对关键 UI 结构做快照
忽略异步测试通过但实际有 Bug正确使用 await/waitFor
测试太大失败时难以定位原因每个测试只验证一个行为
硬编码等待使用 setTimeout 等待使用 waitFor/findBy 等语义化等待
忽略清理测试间状态污染afterEach 中清理副作用

六、面试高频问题

问题一:什么是 TDD?它的核心流程是什么?

回答思路:

TDD 是测试驱动开发,核心是 Red-Green-Refactor 三步循环。先写一个失败的测试(Red),然后用最简方式让测试通过(Green),最后在测试保护下重构代码(Refactor)。TDD 本质上是一种设计工具——它通过可测试性这个约束来驱动更好的代码设计。TDD 的好处包括高覆盖率、重构信心、活文档和快速反馈。挑战在于学习曲线较陡、初期速度变慢、以及对测试基础设施的依赖。

问题二:TDD 和 BDD 有什么区别?

回答思路:

核心区别在于关注层次不同。TDD 关注代码单元的正确性(函数级别),用编程语言编写测试,受众是开发者。BDD 关注系统行为是否符合业务需求(用户故事级别),使用 Given-When-Then 自然语言描述,受众是全团队(包括产品和业务人员)。BDD 是 TDD 的扩展——BDD 定义外层的行为规范,TDD 驱动内层的代码实现。在前端实践中,可以用 Vitest/Jest 的 describe 嵌套来实现 BDD 风格的测试组织。

问题三:解释测试金字塔和测试奖杯的区别

回答思路:

测试金字塔是 Mike Cohn 提出的经典模型,建议大量单元测试 + 适量集成测试 + 少量 E2E 测试。测试奖杯是 Kent C. Dodds 针对前端提出的修正模型,认为集成测试应该是投入最多的层级,因为它最接近用户真实使用方式且维护成本适中。测试奖杯底部增加了静态分析层(TypeScript + ESLint),认为类型检查能以极低成本捕获大量错误。在前端实践中,测试奖杯更具指导意义——"Write tests. Not too many. Mostly integration."

问题四:100% 测试覆盖率能保证没有 Bug 吗?

回答思路:

不能。覆盖率只衡量代码是否被执行到,不衡量是否被充分验证。一个函数 divide(a, b) { return a/b } 只需要一个测试 divide(10, 2) 就能达到 100% 覆盖率,但 divide(10, 0) 的边界情况完全没有被测试。正确的做法是将覆盖率作为参考指标而非目标指标,重点关注分支覆盖和增量覆盖,同时结合代码审查和探索性测试来提升整体质量。

问题五:在实际项目中,你会如何制定测试策略?

回答思路:

首先评估项目类型和团队情况。对于核心业务逻辑(支付、订单等),要求完整的单元 + 集成 + E2E 测试。对于公共工具函数和核心组件,至少有单元测试和组件测试。对于频繁变更的模块,优先写关键路径的集成测试。技术栈选择上:Vitest 作为测试运行器,Testing Library 做组件测试,MSW 模拟 API,Playwright 做 E2E。CI 层面分三阶段:lint/typecheck → unit/integration test → e2e test,快速失败,并行执行。覆盖率设为 70% 的警告线而非硬性门槛。

问题六:什么是 Given-When-Then?在前端怎么实践?

回答思路:

Given-When-Then 是 BDD 的核心语法模式。Given 描述系统的初始状态(前置条件),When 描述用户的操作(触发动作),Then 描述期望的结果。在前端实践中,不一定需要 Cucumber 这类专门工具,可以用 Vitest/Jest 的 describe 嵌套来表达:describe('when the cart is empty') → Given,内部的操作即 When,it('should show empty message') 即 Then。在 Testing Library 中写组件测试时,这种模式非常自然——render 是 Given,userEvent 是 When,expect + screen 查询是 Then。

问题七:先写测试还是后写测试?你倾向于哪种?

回答思路:

两种方式各有适用场景,不应教条化。先写测试(TDD)适合需求明确、逻辑复杂的场景,如工具函数、算法、核心业务逻辑,因为它能驱动更好的设计并提供即时反馈。后写测试适合探索性开发、UI 原型阶段,或遗留代码的补充测试。在实际工作中,我倾向于混合策略:对核心逻辑用 TDD,对 UI 组件先实现再补测试,对已有代码在修 Bug 时补回归测试。关键原则是:最终都要有测试,方式可以灵活。

问题八:如何为一个没有测试的遗留项目建立测试体系?

回答思路:

不要试图一次性补全所有测试——这不现实也不必要。推荐增量策略:第一步,搭建测试基础设施(Vitest + Testing Library + CI 集成),确保新代码有测试。第二步,对关键业务路径(登录、支付、核心流程)补 E2E 测试作为安全网。第三步,在修 Bug 时必须先写复现该 Bug 的测试("Bug 驱动测试"),修复后测试变绿。第四步,在重构代码前先补充该模块的测试。第五步,逐步提升覆盖率,优先覆盖高风险、高变更频率的模块。这样经过几个迭代,测试覆盖率会自然提升到合理水平。


七、延伸阅读

书籍

  • 《Test-Driven Development: By Example》 —— Kent Beck TDD 的奠基之作,通过两个完整的 TDD 实例(多币种钱包和 xUnit 框架),深入讲解 TDD 的思维方式和节奏。

  • 《The Art of Unit Testing》 —— Roy Osherove 系统讲解单元测试的技巧和最佳实践,包括 Mock/Stub 策略、可测试性设计、测试维护等。

  • 《Growing Object-Oriented Software, Guided by Tests》 —— Steve Freeman & Nat Pryce 从 TDD 的角度讲解面向对象设计,展示如何用测试驱动出优良的架构。

  • 《BDD in Action》 —— John Ferguson Smart 全面介绍 BDD 方法论,从需求分析到自动化验收测试的完整流程。

  • 《Clean Code》 —— Robert C. Martin 虽然不是专门讲测试的书,但其中关于测试的章节提出了重要的测试原则(F.I.R.S.T 原则)。

在线资源

  • Testing Library 官方文档 —— https://testing-library.com/ Kent C. Dodds 创建的测试工具库,文档本身就是 BDD 理念的最佳实践指南。

  • Vitest 官方文档 —— https://vitest.dev/ 下一代前端测试框架,与 Vite 生态深度集成。

  • Kent C. Dodds 的博客 —— https://kentcdodds.com/blog 大量关于前端测试策略的深入文章,包括 Testing Trophy 的原始论述。

  • Playwright 官方文档 —— https://playwright.dev/ 微软出品的 E2E 测试框架,功能强大,文档详尽。

  • MSW(Mock Service Worker)官方文档 —— https://mswjs.io/ 网络层 API Mock 的最佳实践工具。

关键文章

  • "The Practical Test Pyramid" —— Ham Vocke(Martin Fowler 博客) 对测试金字塔的现代化解读,包含详细的实践建议。

  • "Write tests. Not too many. Mostly integration." —— Kent C. Dodds Testing Trophy 理念的原始论述,重新审视前端测试的投资策略。

  • "Introducing BDD" —— Dan North BDD 方法论的开山之作,解释了为什么从 TDD 演化出 BDD。

  • "Testing Implementation Details" —— Kent C. Dodds 深入分析为什么不应该测试实现细节,以及如何正确地测试行为。

  • "Mocking is a Code Smell" —— Eric Elliott 反思过度 Mock 的问题,讨论可测试性设计。

用心学习,用代码说话 💻