Skip to content

实践中的测试

前一页介绍了 Vitest API:断言、模拟、快照和测试生命周期钩子。本页专注于将这些工具应用于真实代码。它涵盖了如何决定要测试什么、如何有效地构建测试,以及随着项目增长如何组织测试文件。

要测试什么

当你坐下来为一个函数或模块编写测试时,首先思考它的契约:它对调用它的代码承诺了什么?契约由它的输入(参数、配置)和输出(返回值、副作用、错误)定义。这些就是测试应该验证的内容。

以一个 formatPrice 函数为例:

formatPrice.js
js
export function formatPrice(amount, currency) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount)
}

这里的契约是:给定一个金额和货币代码,返回一个格式化后的价格字符串。针对这个函数,良好的测试应覆盖:

formatPrice.test.js
js
import { expect, test } from 'vitest'
import { formatPrice } from './formatPrice.js'

test('formats USD prices', () => {
  expect(formatPrice(10, 'USD')).toBe('$10.00')
})

test('formats EUR prices', () => {
  expect(formatPrice(10, 'EUR')).toBe('€10.00')
})

test('handles zero', () => {
  expect(formatPrice(0, 'USD')).toBe('$0.00')
})

test('handles negative amounts', () => {
  expect(formatPrice(-5.5, 'USD')).toBe('-$5.50')
})

test('rounds to two decimal places', () => {
  expect(formatPrice(10.999, 'USD')).toBe('$11.00')
})

注意这些测试没有做什么。它们没有检查传递给内部 Intl.NumberFormat 的选项,也没有检查中间变量。它们只验证输出。

TIP

一个实用的经验法则是:如果有人重构了内部实现但输出保持不变,测试应该失败吗?如果是,那么你很可能在测试实现细节而不是行为。

测试结构

大多数测试遵循自然的三个部分结构,有时称为“安排、执行、断言”:

  1. 设置测试所需的数据
  2. 调用被测试的函数或执行操作
  3. 检查结果是否符合预期
js
test('removes an item from the list', () => {
  // 设置
  const list = new ShoppingList()
  list.add('milk')
  list.add('bread')

  // 执行
  list.remove('milk')

  // 检查
  expect(list.getItems()).toEqual(['bread'])
})

不需要注释来标记每个部分。写几次测试后,结构会变得自然。重要的是保持每个测试只关注一个行为。

每个测试只验证一个行为

如果你发现自己测试名称中写有“并且”(and)、“或者”(or)等连词,这通常是一个信号,表明你应该将其拆分为多个独立的测试。

描述性名称

编写描述行为的测试名称,而不是实现细节。“返回美元格式的价格”比“使用正确的选项调用 Intl.NumberFormat”更好。当测试失败时,测试名称应该能告诉你哪里出了问题,而无需阅读测试主体。

测试边界情况

在覆盖主要行为之后,考虑边界情况。边缘在哪里?哪些输入不寻常但有效?当出错时应该发生什么?

下面是一个处理 parseAge 函数的例子,它接收用户输入并返回数字:

parseAge.js
js
export function parseAge(input) {
  const age = Number(input)
  if (Number.isNaN(age) || age < 0 || age > 150) {
    throw new Error(`Invalid age: ${input}`)
  }
  return Math.floor(age)
}

快乐路径很直接,但边界情况往往隐藏着 bug:

parseAge.test.js
js
import { expect, test } from 'vitest'
import { parseAge } from './parseAge.js'

test('parses a valid age', () => {
  expect(parseAge('25')).toBe(25)
})

test('rounds down decimal ages', () => {
  expect(parseAge('25.9')).toBe(25)
})

test('handles zero', () => {
  expect(parseAge('0')).toBe(0)
})

test('handles the upper boundary', () => {
  expect(parseAge('150')).toBe(150)
})

test('throws for negative numbers', () => {
  expect(() => parseAge('-1')).toThrow('Invalid age: -1')
})

test('throws for numbers above 150', () => {
  expect(() => parseAge('151')).toThrow('Invalid age: 151')
})

test('throws for non-numeric strings', () => {
  expect(() => parseAge('abc')).toThrow('Invalid age: abc')
})

test('throws for empty string', () => {
  expect(() => parseAge('')).toThrow('Invalid age: ')
})

不需要测试每一个可能的输入。关注边界值(0、150、151、-1)、错误路径,以及函数可能实际接收的输入类型。

TIP

如果你不确定某个边界情况是否重要,问问自己:真实用户或调用者会触发它吗?如果会,就测试它。

属性基础测试

对于具有广泛有效输入的函数,手动选择边界情况可能远远不够。属性基础测试是一种技术:你描述应该对任何输入成立的属性,测试框架会生成数百个随机输入,试图找到破坏属性的反例。

例如,你可以说“对于任何有效的年龄字符串,parseAge 应该返回一个非负整数”,然后让工具寻找反例。fast-check 是一个与 Vitest 集成的流行属性基础测试库。这是一个高级技术,但随着测试需求的增长,了解它是非常有价值的。

何时使用模拟

模拟是一个强大的工具,但很容易被滥用。

慢速依赖

网络请求、文件系统操作和数据库调用会让测试从毫秒级变成秒级。使用模拟来保持反馈循环快速。

对于 HTTP 请求,建议使用 Mock Service Worker 而不是直接模拟 fetch。请参阅模拟请求指南了解设置说明。

非确定性值

如果代码依赖于当前日期、随机数或 UUID 生成器,请在测试中模拟这些以确保可预测性。Vitest 提供了 vi.useFakeTimers()vi.setSystemTime() 来控制测试中的时间。

不要模拟什么

不要模拟你正在测试的东西。如果你正在测试一个 UserService,不要模拟 UserService。而是模拟它的依赖项(数据库、邮件发送器),让服务本身真实运行。

另外,当依赖项是简单内存数据结构或纯函数时,优先使用真实实现。越接近真实使用场景,测试提供的信心就越多。

TIP

只有在真实对象缓慢、不稳定或具有无法在测试中控制的副作用时,才使用模拟。

通过测试修复 Bug

当发现一个 Bug 时,直接去修复代码是很诱人的。更好的方法是先编写一个失败的重现代码的测试,然后修复代码并观察测试变绿。

这样做有几个好处。测试证明 Bug 的存在(不仅仅是误解)。它记录了确切的问题所在。并且它能防止同样的 Bug 以后再次出现,因为如果有人不小心重新引入同样的问题,测试会捕获它。

这在实际中看起来是这样的。假设用户报告 parseAge 在收到带前导空格的字符串(如 " 25")时会崩溃。首先编写一个重现问题的测试:

js
test('handles leading spaces', () => {
  expect(parseAge(' 25')).toBe(25)
})

运行它并确认它失败。现在你确切知道哪里出了问题,并有一个明确的目标。修复实现:

js
export function parseAge(input) {
  const age = Number(input.trim())
  // ...
}

再次运行测试。它通过了。Bug 已修复,并且你还有一个回归测试,如果有人之后删除了 .trim() 调用,它还能捕获这个问题。

TIP

如果你使用 AI 代理来修复 Bug,请配置它们遵循相同的原则:首先用失败的测试重现问题,然后再修复代码。这可以防止代理通过修改测试而不是代码来“修复” Bug,并让你确信修复确实有效。

组织测试文件

没有唯一正确的测试组织方式,但某些模式比其他模式更具扩展性。

文件布局

最简单的起点是每个源文件对应一个测试文件。对于每个 utils.js,旁边都有一个 utils.test.js。这使得查找任何给定代码的测试变得容易,并且大多数编辑器会在文件树中并排显示它们:

src/
  utils.js
  utils.test.js
  formatPrice.js
  formatPrice.test.js

一些团队更喜欢使用单独的 __tests__test 目录。这两种方法都可行。重要的是在整个项目中保持一致性。Vitest 的 include 模式默认匹配这两种布局。

使用 describe 进行分组

当一个模块导出多个函数时,使用 describe 块来分组每个函数的测试。这使得测试输出更有条理,并清楚哪个函数对应哪个失败测试:

js
describe('formatPrice', () => {
  test('formats USD prices', () => { /* ... */ })
  test('handles zero', () => { /* ... */ })
})

describe('parseAmount', () => {
  test('parses valid amounts', () => { /* ... */ })
  test('throws for invalid input', () => { /* ... */ })
})

避免嵌套超过一两层的 describe 块。深度嵌套的测试树难以阅读,通常意味着源模块承担了太多职责。

拆分大型文件

随着项目增长,一些测试文件不可避免地会变长。如果测试文件超过几百行,考虑按主题或功能区域拆分。例如,userService.test.js 可能变成 userService.creation.test.jsuserService.auth.test.js。这也有助于在开发过程中更快地运行测试子集。

命名测试

测试名称比你想象的更重要。当测试在 CI 中失败时,测试名称通常是人们首先看到的内容。“工作正常”或“处理边界情况”这样的名称无法告诉你哪里出了问题。

更倾向于描述具体行为的名称:“返回空购物车时返回 0”、“如果电子邮件格式无效则抛出”、“添加新项时保留现有项”。测试输出应该像模块功能的规范说明一样可读。

一个完整示例

我们来整合所有内容。这是一个小型 TodoList 模块:

todoList.js
js
let nextId = 1

export function createTodoList() {
  const items = []

  return {
    add(text) {
      if (!text.trim()) {
        throw new Error('Todo text cannot be empty')
      }
      const todo = { id: nextId++, text, completed: false }
      items.push(todo)
      return todo
    },

    remove(id) {
      const index = items.findIndex(item => item.id === id)
      if (index === -1) {
        throw new Error(`Todo with id ${id} not found`)
      }
      items.splice(index, 1)
    },

    toggle(id) {
      const todo = items.find(item => item.id === id)
      if (!todo) {
        throw new Error(`Todo with id ${id} not found`)
      }
      todo.completed = !todo.completed
    },

    getAll() {
      return items
    },

    getCompleted() {
      return items.filter(item => item.completed)
    },
  }
}

从这段代码中,我们可以识别出需要测试的行为:

  • 添加项目(主要目的)
  • 添加空项目(应失败)
  • 通过 ID 移除项目
  • 移除不存在的项目(应失败)
  • 切换完成状态
  • 获取所有项目与已完成项目

下面是测试文件的可能样子:

todoList.test.js
js
import { describe, expect, test } from 'vitest'
import { createTodoList } from './todoList.js'

describe('add', () => {
  test('adds a new todo', () => {
    const list = createTodoList()
    const todo = list.add('Buy groceries')

    expect(todo.text).toBe('Buy groceries')
    expect(todo.completed).toBe(false)
    expect(list.getAll()).toHaveLength(1)
  })

  test('assigns unique IDs to each todo', () => {
    const list = createTodoList()
    const first = list.add('First')
    const second = list.add('Second')

    expect(first.id).not.toBe(second.id)
  })

  test('throws when text is empty', () => {
    const list = createTodoList()
    expect(() => list.add('')).toThrow('Todo text cannot be empty')
  })

  test('throws when text is only whitespace', () => {
    const list = createTodoList()
    expect(() => list.add('   ')).toThrow('Todo text cannot be empty')
  })
})

describe('remove', () => {
  test('removes a todo by ID', () => {
    const list = createTodoList()
    const todo = list.add('Buy groceries')

    list.remove(todo.id)

    expect(list.getAll()).toHaveLength(0)
  })

  test('keeps other items when removing one', () => {
    const list = createTodoList()
    const first = list.add('First')
    list.add('Second')

    list.remove(first.id)

    expect(list.getAll()).toHaveLength(1)
    expect(list.getAll()[0].text).toBe('Second')
  })

  test('throws when ID does not exist', () => {
    const list = createTodoList()
    expect(() => list.remove(999)).toThrow('Todo with id 999 not found')
  })
})

describe('toggle', () => {
  test('marks a todo as completed', () => {
    const list = createTodoList()
    const todo = list.add('Buy groceries')

    list.toggle(todo.id)

    expect(list.getAll()[0].completed).toBe(true)
  })

  test('toggles back to incomplete', () => {
    const list = createTodoList()
    const todo = list.add('Buy groceries')

    list.toggle(todo.id)
    list.toggle(todo.id)

    expect(list.getAll()[0].completed).toBe(false)
  })

  test('throws when ID does not exist', () => {
    const list = createTodoList()
    expect(() => list.toggle(999)).toThrow('Todo with id 999 not found')
  })
})

describe('getCompleted', () => {
  test('returns only completed todos', () => {
    const list = createTodoList()
    const buy = list.add('Buy groceries')
    list.add('Clean house')
    list.toggle(buy.id)

    const completed = list.getCompleted()

    expect(completed).toHaveLength(1)
    expect(completed[0].text).toBe('Buy groceries')
  })

  test('returns empty array when nothing is completed', () => {
    const list = createTodoList()
    list.add('Buy groceries')

    expect(list.getCompleted()).toHaveLength(0)
  })
})

每个 describe 块专注于一个方法。每个测试验证一个具体行为。测试名称读起来就像模块的规范说明。如果任何测试失败,名称和断言会告诉你确切哪里出了问题。

TIP

注意我们在每个测试中都创建了一个新的 createTodoList() 实例。这保持了测试的独立性,意味着它们可以按任意顺序运行而互不影响。如果你发现自己每个测试都重复相同的设置代码,这正是使用 beforeEachtest.extend 固定装置的时机。

What about nextId?

The nextId counter at the top of the module is shared across all calls to createTodoList(), including across tests. This means IDs aren't predictable: one test might get IDs 1 and 2, while another gets 3 and 4 depending on execution order. This works fine here because the tests only check relative uniqueness (first.id !== second.id), not specific ID values. If a test asserted expect(todo.id).toBe(1), it would break depending on which tests ran before it. When you have shared module-level state like this, make sure your tests don't depend on its specific value.


如果你正在构建一个 Web 应用程序并希望在真实的浏览器环境中测试组件,请查阅组件测试指南,了解如何测试 React、Vue、Svelte 和其他 UI 框架。