单元测试
隔离测试最小逻辑单元,快速且确定性。
为什么
单元测试是抵御回归的第一道防线。它们在毫秒级运行,每天在 CI 中执行数千次,失败时精确定位到出错的函数。没有它们,每次重构都是赌博——你改了一个共享工具函数,然后祈祷下游没坏,因为唯一的反馈是手动点击应用。有强单元覆盖的团队发布更快,因为他们信任测试能抓住明显的破坏,从而把手动测试的精力集中在自动化无法触及的微妙探索路径上。
何时使用
一旦有了纯函数或含逻辑的类就写第一个单元测试。每修一个 bug 加一条(回归守卫),每个新公开方法加一条。业务逻辑目标 70-80% 行覆盖;跳过平凡的 getter/setter。
如何做
1. 选匹配技术栈的框架 - JS/TS: Vitest(快、Vite 原生)或 Jest - Python: pytest + fixtures - Go: 内置 testing 包 + testify - Rust: cargo test + #[test] 2. 每个测试按 Arrange-Act-Assert 结构 - Arrange: 准备输入和依赖(mock/stub) - Act: 调用被测函数,捕获结果 - Assert: 验证结果符合预期 - 每个测试一个逻辑断言;用描述性命名 3. 隔离外部依赖 - mock 网络调用、数据库、文件系统、时间 - 用依赖注入让真实和假实现可互换 - 绝不 mock 你正在测的东西——mock 它的协作者 4. 每次保存(watch 模式)和每次 CI 推送都跑 - vitest --watch / pytest-watch / cargo watch -x test - CI: 任何红测试即构建失败,不许"以后再修" 5. 测覆盖率但别追 100% - 生成报告,看业务逻辑中未覆盖的分支 - 忽略入口点、配置和平凡代码的覆盖率
示例
// src/lib/discount.ts
export function applyDiscount(price: number, code: string): number {
if (code === "SAVE10") return round(price * 0.9);
if (code === "SAVE20") return round(price * 0.8);
return price;
}
const round = (n: number) => Math.round(n * 100) / 100;
// src/lib/__tests__/discount.test.ts
import { describe, it, expect } from "vitest";
import { applyDiscount } from "../discount";
describe("applyDiscount", () => {
it("SAVE10 打 9 折", () => {
expect(applyDiscount(100, "SAVE10")).toBe(90);
});
it("SAVE20 打 8 折", () => {
expect(applyDiscount(100, "SAVE20")).toBe(80);
});
it("未知码返回原价", () => {
expect(applyDiscount(100, "NOPE")).toBe(100);
});
it("四舍五入到 2 位小数", () => {
expect(applyDiscount(33.33, "SAVE10")).toBe(30);
});
});常见问题
- ⚠依赖执行顺序或共享可变状态的测试单独跑通过但换序就挂——始终在 beforeEach 重置状态,绝不依赖前一个测试已运行。
- ⚠mock 太深导致测试断言实现以特定顺序调特定方法,任何无害重构都会挂测试,即使行为没变。
- ⚠只测快乐路径让边界(null、空数组、负数、并发调用)无人守卫,而这些正是生产 bug 藏身处。
- ⚠慢单元测试(每条超 100ms)通常意味着真在打数据库或网络——套件涨到分钟级,开发者不再本地跑,回归就溜进来。
最佳实践
- ✓把测试名当描述行为的句子:"SAVE10 返回 90"读起来像规格;"test1"或"discountTest"在凌晨 2 点 CI 挂了啥也看不出来。
- ✓每条测试保持在 50ms 以下;如果更慢,你大概在碰 I/O——把逻辑抽成纯函数再测它。
- ✓对多输入输出对的函数用表驱动测试:一条参数化测试带 20 行比 20 个复制粘贴的测试块更易维护。
- ✓测试代码用和生产代码一样的质量标准——抽 helper、避免重复、lint 它;腐烂的测试套件侵蚀整个套件的信心。