第四部分:自动化测试 · Level 3 入门
从手工点击到代码验证:写你的第一个自动化测试,理解 AAA 模式,用 Vitest 为 TaskFlow 建立第一道自动防线。
🎯 前 7 节课我们掌握了"测什么、测多少、怎么记、怎么管"。从这节课开始,我们进入自动化测试的世界——用手写代码来执行测试,让机器替我们做重复的事。Lesson 0008 是 Level 3 的起点,目标是:你能为 TaskFlow 写第一个自动化测试,并在 CI/CD 中运行它。
📑 本节目录
学习目标 什么是单元测试 为什么要自动化 Vitest 快速上手 AAA 模式:写测试的"万能公式" 写第一个测试 运行测试与 CI/CD 集成 核心比喻 示例 实践:TaskFlow 第一个自动化测试 AI 实践 QA Checklist 思考题 推荐阅读
📌 学习目标
为什么学习
手工测试的问题是:每次代码变更都要重新测。一个功能改了 5 行代码,你需要手动回归 20 个用例;每次发布前要跑 3 小时手工测试。自动化测试让你写一次,跑一万次,而且每次都一样快、一样准确。
你将掌握:
理解什么是单元测试、集成测试和 E2E 测试(定位在 Trophy 模型中) 掌握 Vitest 的安装和基本配置 掌握 AAA 模式(Arrange-Act-Assert) 为 TaskFlow 写第一个自动化测试 将测试接入 GitHub Actions CI/CD
实际价值
每次代码变更,自动运行测试 → 发现问题立即知道 发布前自动回归 → 不再有"上线前夜的恐慌" 代码审查时,测试是"安全网" → 大胆重构 AI Coding 时代,AI 生成代码必须由自动化测试验证
AI 时代如何应用
AI Coding 工具(Claude Code、GitHub Copilot)每秒生成大量代码——这些代码必须由自动化测试验证。没有测试的 AI 生成代码 = 随时可能崩溃的地雷。测试工程师的职责变成:设计测试、审查 AI 生成的测试、执行人类判断。
📚 什么是单元测试?
**单元测试(Unit Test)**是测试的"原子"——它测试代码中最小的可测试单元(通常是单个函数或方法),完全隔离于外部依赖(数据库、API、文件系统)。
单元测试的 3 个核心特征
| 快速 | ||
| 隔离 | ||
| 确定性 |
单元测试 vs 集成测试 vs E2E 测试
回顾 Lesson 0005 的 Trophy 模型,自动化测试分为 3 个层次:
| 单元测试 | ||||
| 集成测试 | ||||
| E2E 测试 |
💡 记住:单元测试不是越多越好,而是越有价值越好。测试一个无意义的函数 100 次,不如测试一个关键函数 3 次。
🤔 为什么要自动化?
💡 比喻 · 手工洗碗 vs 洗碗机
手工测试就像每次吃完饭用手洗碗:
每次都要洗(每次代码变更都要重新测试) 速度慢(手工测试 1 小时,自动化 10 秒) 会累(重复 100 次后,人会出错、疲惫) 别人无法代劳(只有你知道怎么洗这个碗) 自动化测试就像买洗碗机:
一次性投入(写测试要时间) 长期受益(之后每次都自动运行) 速度极快(每次几分钟) 结果一致(机器不会累) 洗碗机的成本是买机器的钱;自动化测试的成本是写测试的时间。但一旦写好,长期免费。
手工测试 vs 自动化测试对比
什么时候应该自动化?
不是所有测试都值得自动化。以下是决策标准:
⚡ Vitest 快速上手
Vitest是 Next.js + TypeScript 项目的首选测试框架——它比 Jest 更快(基于 Vite),与 Next.js 生态无缝集成,API 与 Jest 兼容。
Vitest vs Jest vs Mocha
安装 Vitest
# 在 taskflow 项目中安装
cd taskflow
# 安装 Vitest 核心
npm install -D vitest
# 安装 React Testing Library
npm install -D @testing-library/react @testing-library/user-event
npm install -D @testing-library/jest-dom
# 安装 JSdom(模拟浏览器环境)
npm install -D jsdom
# 安装 TypeScript 类型
npm install -D @types/node
配置 vitest.config.ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // 模拟浏览器 DOM
setupFiles: ['./src/test/setup.ts'], // 测试初始化文件
globals: true, // 全局 test/it/expect
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['node_modules', 'dist'],
},
})
配置 package.json scripts
// 在 package.json 中添加
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",// 单次运行(CI/CD 用)
"test:ui": "vitest --ui",// 可视化 UI
"test:coverage": "vitest run --coverage"// 覆盖率报告
}
}
创建测试初始化文件
// src/test/setup.ts
import '@testing-library/jest-dom'
// 配置测试环境所需的全局 Mock
📐 AAA 模式:写测试的"万能公式"
**AAA 模式(Arrange-Act-Assert)**是写自动化测试的"万能公式"。无论你测什么,都遵循这 3 个步骤。
AAA 是什么?
| Arrange | ||
| Act | ||
| Assert |
📝 示例:测试 TaskFlow 的日期格式化函数
// src/utils/dateUtils.test.ts
import { describe, it, expect } from 'vitest'
import { formatDueDate } from '../dateUtils'
describe('formatDueDate', () => {
// ===== Arrange =====
// 准备阶段:准备好各种输入数据
// ===== Act =====
// 执行阶段:调用被测函数
// ===== Assert =====
// 断言阶段:验证结果
it('显示"今天"当截止日期是今天', () => {
// Arrange
const today = new Date('2026-07-20')
// Act
const result = formatDueDate(today)
// Assert
expect(result).toBe('今天')
})
it('显示"明天"当截止日期是明天', () => {
// Arrange
const tomorrow = new Date('2026-07-21')
// Act
const result = formatDueDate(tomorrow)
// Assert
expect(result).toBe('明天')
})
it('显示完整日期当截止日期超过 1 天后', () => {
// Arrange
const future = new Date('2026-08-01')
// Act
const result = formatDueDate(future)
// Assert
expect(result).toBe('2026-08-01')
})
it('显示"已过期"当截止日期在过去', () => {
// Arrange
const past = new Date('2026-07-01')
// Act
const result = formatDueDate(past)
// Assert
expect(result).toBe('已过期')
})
})
AAA 模式为什么重要?
结构清晰:读测试的人立即知道"准备什么 → 做什么 → 验证什么" 易于调试:如果测试失败,你立即知道是哪个阶段出了问题 易于维护:添加新用例只需复制一个 AAA 块,改 Arrange 和 Assert 防止遗漏:每个测试都必须有这三个部分,强制你完整思考
AAA 的常见错误
❌ 错误 1:Act 和 Assert 混在一起
// 差:Act 和 Assert 混在一行
expect(formatDueDate(new Date('2026-07-20'))).toBe('今天')
// 好:分开
const result = formatDueDate(new Date('2026-07-20'))
expect(result).toBe('今天')
❌ 错误 2:Arrange 包含太多东西
// 差:一个测试里设置了一堆无关的东西
it('格式化日期', () => {
const db = new MockDatabase() // 无关的数据库
const user = { id: 1, name: 'Test' } // 无关的用户
const config = { timezone: 'Asia/Shanghai' } // 无关的配置
const date = new Date('2026-07-20')
// ...
})
✍️ 写第一个测试:TaskFlow 日期工具函数
让我们为 TaskFlow 的实际代码写测试。先看一下我们的代码结构:
TaskFlow 工具函数示例
// src/utils/dateUtils.ts
// 这是我们要测试的函数
export function formatDueDate(date: Date): string {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 0) return '今天'
if (diffDays === 1) return '明天'
if (diffDays < 0) return '已过期'
return date.toISOString().split('T')[0]
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
export function validateTaskTitle(title: string): { valid: boolean; error?: string } {
if (!title || title.trim() === '') {
return { valid: false, error: '标题不能为空' }
}
if (title.length < 3) {
return { valid: false, error: '标题至少 3 个字符' }
}
if (title.length > 50) {
return { valid: false, error: '标题最多 50 个字符' }
}
return { valid: true }
}
第一个测试文件
// src/utils/dateUtils.test.ts
import { describe, it, expect } from 'vitest'
import { formatDueDate } from './dateUtils'
describe('formatDueDate', () => {
// 正常路径
it('今天返回"今天"', () => {
const today = new Date()
const result = formatDueDate(today)
expect(result).toBe('今天')
})
// 正常路径
it('明天返回"明天"', () => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const result = formatDueDate(tomorrow)
expect(result).toBe('明天')
})
// 边界值
it('昨天返回"已过期"', () => {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const result = formatDueDate(yesterday)
expect(result).toBe('已过期')
})
// 正常路径
it('超过1天返回日期字符串', () => {
const future = new Date()
future.setDate(future.getDate() + 5)
const result = formatDueDate(future)
// 格式:YYYY-MM-DD
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
第二个测试文件:表单验证
// src/utils/validateUtils.test.ts
import { describe, it, expect } from 'vitest'
import { validateEmail, validateTaskTitle } from './validateUtils'
describe('validateEmail', () => {
// 有效等价类
it('标准邮箱格式通过验证', () => {
expect(validateEmail('user@taskflow.com')).toBe(true)
})
it('带点的邮箱格式通过验证', () => {
expect(validateEmail('first.last@taskflow.com')).toBe(true)
})
it('带加号的邮箱格式通过验证', () => {
expect(validateEmail('user+tag@taskflow.com')).toBe(true)
})
// 无效等价类
it('缺少 @ 符号验证失败', () => {
expect(validateEmail('user.taskflow.com')).toBe(false)
})
it('缺少域名部分验证失败', () => {
expect(validateEmail('user@')).toBe(false)
})
it('包含空格验证失败', () => {
expect(validateEmail('user @taskflow.com')).toBe(false)
})
})
describe('validateTaskTitle', () => {
// 有效等价类
it('3-50 字符标题通过验证', () => {
expect(validateTaskTitle('项目启动')).toEqual({ valid: true })
})
it('恰好 50 字符标题通过验证', () => {
const title50 = 'a'.repeat(50)
expect(validateTaskTitle(title50)).toEqual({ valid: true })
})
// 边界值
it('2 字符标题验证失败(太短)', () => {
expect(validateTaskTitle('ab')).toEqual({
valid: false,
error: '标题至少 3 个字符'
})
})
it('51 字符标题验证失败(太长)', () => {
const title51 = 'a'.repeat(51)
expect(validateTaskTitle(title51)).toEqual({
valid: false,
error: '标题最多 50 个字符'
})
})
// 无效等价类
it('空字符串验证失败', () => {
expect(validateTaskTitle('')).toEqual({
valid: false,
error: '标题不能为空'
})
})
it('纯空格验证失败', () => {
expect(validateTaskTitle(' ')).toEqual({
valid: false,
error: '标题不能为空'
})
})
})
🚀 运行测试与 CI/CD 集成
本地运行
# 交互式运行(watch 模式,代码变更自动重跑)
npm test
# 单次运行(CI/CD 用,变更后自动退出)
npm run test:run
# 查看测试覆盖率
npm run test:coverage
# 可视化测试 UI
npm run test:ui
Vitest 输出示例
RUN v1.x.x .../taskflow
✓ src/utils/dateUtils.test.ts (4 tests) 45ms
✓ src/utils/validateUtils.test.ts (10 tests) 32ms
Test Files 2 passed (2)
Tests 14 passed (14) ✓
Time 1.23s
接入 GitHub Actions CI/CD
// .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm run test:run
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
测试覆盖率
覆盖率是测试"跑到了多少代码"的量化指标。Vitest 内置 v8 覆盖率工具。
| 行覆盖率 | ||
| 函数覆盖率 | ||
| 分支覆盖率 | ||
| 语句覆盖率 |
⚠️ 重要提醒:覆盖率是工具,不是目标。100% 覆盖率的烂测试不如 70% 覆盖率的好测试。测关键路径,不要测"凑数"的代码。
💡 核心比喻
💡 比喻 1:AAA = 做实验
AAA 模式就像做科学实验:
Arrange(准备) = 准备实验器材、设置对照组(科学家不会在没有设备的情况下做实验) Act(执行) = 进行实验操作(施加刺激、注入试剂) Assert(断言) = 测量结果、与预期对比(记录数据,验证假设) 没有这三步的实验不叫实验,叫瞎试。AAA 让你的测试从"瞎试"变成"科学实验"。
💡 比喻 2:测试覆盖率 = 考试分数
覆盖率就像考试分数:
0-30% = 交白卷(几乎没有测试) 50-70% = 及格线(基本覆盖) 80-90% = 良好(重点模块覆盖) 95%+ = 完美主义(注意:100% 不代表没有 Bug) 但考 60 分不代表你真的学会了——可能是蒙的。测得多不代表测得对。覆盖率是参考,不是圣经。
💡 比喻 3:Mock = 替身演员
单元测试需要 Mock(模拟)外部依赖,就像拍电影需要替身演员:
你要拍一个演员跳伞的镜头,但你不想让演员真的从飞机上跳下来 → 找个替身(Mock) 你要测试一个函数调用了 Supabase API,但不想真的连接数据库 → Mock 掉 Supabase 替身(Mock)代替真实的演员(真实数据库),但最终的电影效果看起来一样 Mock 的目的是让你测试"当前这个人的表演",而不是"整个剧组的配合"。Mock = 隔离外部依赖,让测试只关心自己的逻辑。
📝 示例
📝 示例 1:Vercel 的测试文化
Vercel(Next.js 背后的公司)的工程实践:
每个 PR 必须通过所有测试,否则不能合并 使用 Vitest(因为 Next.js 团队推荐) 测试覆盖率目标:核心模块 ≥ 90%,一般模块 ≥ 70% 使用 Codecov 监控覆盖率趋势,不允许覆盖率下降 AI Coding(Copilot)生成的代码必须有对应的测试
📝 反例:覆盖率达标,质量仍然差
某团队覆盖率 95%,但上线后 Bug 不断:
❌ 95% 覆盖率都是"测试函数返回 true"——没有边界值测试 ❌ 测试都是"assert(true === true)"——没有真正的逻辑验证 ❌ Mock 掉了所有真实的逻辑——测试通过但实际跑不通 ❌ 只测试了 happy path——错误处理一个都没测 教训:覆盖的是代码行,不是质量。好的测试测的是行为,不是实现。
🛠️ 实践:TaskFlow 第一个自动化测试
步骤
1️⃣ 在 taskflow 项目中安装 Vitest
cd taskflow
npm install -D vitest @testing-library/react @testing-library/user-event
npm install -D @testing-library/jest-dom jsdom
npm install -D @types/node
2️⃣ 创建 vitest.config.ts
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
include: ['src/**/*.{test,spec}.{js,ts}'],
},
})
3️⃣ 创建 src/utils 目录和工具函数
mkdir -p src/utils src/test
touch src/utils/dateUtils.ts
touch src/utils/validateUtils.ts
4️⃣ 创建 src/test/setup.ts
// src/test/setup.ts
import '@testing-library/jest-dom'
5️⃣ 实现 dateUtils.ts
// src/utils/dateUtils.ts
export function formatDueDate(date: Date): string {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays === 0) return '今天'
if (diffDays === 1) return '明天'
if (diffDays < 0) return '已过期'
return date.toISOString().split('T')[0]
}
6️⃣ 写 dateUtils.test.ts
// src/utils/dateUtils.test.ts
import { describe, it, expect } from 'vitest'
import { formatDueDate } from './dateUtils'
describe('formatDueDate', () => {
it('今天返回"今天"', () => {
const today = new Date()
expect(formatDueDate(today)).toBe('今天')
})
it('明天返回"明天"', () => {
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
expect(formatDueDate(tomorrow)).toBe('明天')
})
it('昨天返回"已过期"', () => {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
expect(formatDueDate(yesterday)).toBe('已过期')
})
})
7️⃣ 运行测试
npm run test:run
应该看到:3 passed (3)
8️⃣ 提交代码
git add .
git commit -m "Lesson 0008: TaskFlow 第一个自动化测试(Vitest + AAA)"
🤖 AI 实践
🤖 AI 实践 1:用 AI 从函数生成测试用例
Prompt 模板:
请为以下函数生成 Vitest 测试用例,使用 AAA 模式。
```typescript
export function validatePassword(password: string): {
valid: boolean
errors: string[]
} {
const errors: string[] = []
if (password.length < 8) errors.push('密码至少 8 个字符')
if (!/[A-Z]/.test(password)) errors.push('密码必须包含大写字母')
if (!/[a-z]/.test(password)) errors.push('密码必须包含小写字母')
if (!/[0-9]/.test(password)) errors.push('密码必须包含数字')
return { valid: errors.length === 0, errors }
}
请生成:
等价类划分(有效/无效) 边界值测试(8字符、各种组合) 每个测试用例的 Arrange-Act-Assert 格式:Vitest + expect
**预期效果**:得到可以直接运行的 Vitest 测试代码。
### 🤖 AI 实践 2:用 AI 检查现有测试的质量
**Prompt 模板**:
```markdown
请审查以下 Vitest 测试用例的质量,满分 10 分。
```typescript
it('验证邮箱', () => {
expect(validateEmail('test@test.com')).toBe(true)
})
请分析:
这个测试覆盖了哪些场景?遗漏了哪些? 是否有边界值测试? 是否有无效等价类的测试? 如何改进? 建议添加哪些测试用例?
**预期效果**:得到测试质量评估和改进建议。
### 🤖 AI 实践 3:用 AI 写 React Hook 测试
**Prompt 模板**:
```markdown
请为以下 React Hook 生成 Vitest + Testing Library 测试用例。
```typescript
// src/hooks/useTaskFilter.ts
import { useState } from 'react'
import type { Task } from '../types'
export function useTaskFilter(tasks: Task[]) {
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all')
const filteredTasks = tasks.filter(task => {
if (filter === 'active') return !task.completed
if (filter === 'completed') return task.completed
return true
})
return { filteredTasks, filter, setFilter }
}
请生成:
测试默认过滤为 'all' 测试切换到 'active' 只显示未完成任务 测试切换到 'completed' 只显示已完成任务 使用 renderHook from @testing-library/react-hooks
**预期效果**:得到完整的 React Hook 测试代码。
---
## ✅ QA Checklist
> 学完这节课,你应该能:
- [ ] 解释什么是单元测试及其 3 个核心特征
- [ ] 区分单元测试、集成测试、E2E 测试的定位
- [ ] 解释 AAA 模式的三个阶段
- [ ] 安装和配置 Vitest
- [ ] 用 describe + it + expect 写测试
- [ ] 为日期格式化函数写测试
- [ ] 为表单验证函数写测试(含边界值)
- [ ] 运行 Vitest 并解读输出
- [ ] 将测试接入 GitHub Actions CI/CD
- [ ] 用 AI 辅助生成测试用例
---
## ❓ 思考题
> 请认真思考这些问题
1. AAA 模式看起来很简单,但为什么很多新手写测试时仍然会写成一团糟?你觉得最难坚持的是哪个 A?
2. 覆盖率 100% 的测试就是"好测试"吗?什么样的测试是"高质量"的测试?
3. 手工测试和自动化测试哪个更"有价值"?为什么有些团队过度追求自动化而忽视了手工探索性测试?
4. 在 AI Coding 时代,AI 可以自动生成测试——这会降低测试工程师的门槛吗?还是说反而提高了对测试设计能力的要求?
5. Vitest 的 watch 模式让你在保存代码时自动重跑测试——这对开发体验有什么影响?有没有潜在的负面影响?
6. Mock 可以隔离外部依赖,但如果 Mock 得太多,测试是否反而失去了意义?如何在"隔离"和"真实"之间找到平衡?
---
## 📖 推荐阅读
### 文章
- **[Vitest Guide](https://vitest.dev/guide/testing-types.html)** — Vitest 官方
- Vitest 官方指南,包含完整 API 文档。
- **[Arrange Act Assert Pattern](https://kentcdodds.com/blog/arrange-act-assert)** — Kent C. Dodds
- AAA 模式的原作者解读。必读。
- **[JavaScript Testing Best Practices](https://github.com/goldbergyoni/javascript-testing-best-practices)** — Yoni Goldberg
- JavaScript 测试的全面指南,包含单元测试、集成测试、E2E。
### 视频
- **"Vitest Tutorial for Beginners"** — YouTube 搜索
- 完整的 Vitest 入门教程。
- **"Arrange Act Assert Pattern Explained"** — YouTube 搜索
- AAA 模式的实操演示。
---
## 💬 任何不清楚的地方?
> 这是你的老师(AI Agent)。Vitest 配置问题、测试写不出来的场景、想用 AI 生成测试的代码——都可以问。
---
## ⏭️ 下一节预告
**Lesson 0009:Mock 与 Stub — 隔离外部依赖的艺术**
Lesson 0008 我们理解了"怎么写测试"。下一节课我们回答"测什么要 Mock"和"怎么 Mock":
- Mock vs Stub vs Spy 的区别
- 何时应该 Mock(何时不应该)
- Vitest Mock API 详解
- Mock Supabase Auth 和 Database
- TaskFlow 集成测试实战
**前置知识**:Lesson 0008(自动化测试入门 + AAA 模式)
---
> 📅 TaskFlow 测试课程 · Lesson 0008 · Level 3 入门
>
> 设计师:AI 测试架构师 · 持续优化中
夜雨聆风