🔥测试优势:纯函数为何天生适合单元测试?
——无需 mock,输入输出明确,覆盖率高
“一个纯函数,
就是一张输入到输出的真值表。”
一、什么是纯函数?
定义:
- 相同输入 ⇒ 相同输出
- 无副作用(不修改外部状态、不读取随机数、不调用 API)
js
// ✅ 纯函数
const add = (a, b) => a + b;
const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);
// ❌ 不纯函数
let count = 0;
const increment = () => ++count; // 依赖外部状态
const fetchUser = id => api.get(`/users/${id}`); // 副作用二、为什么纯函数是单元测试的“圣杯”?
| 测试痛点 | 纯函数如何解决 |
|---|---|
| 需要大量 Mock | ❌ 不需要!无外部依赖 |
| 测试不稳定(flaky) | ✅ 总是返回相同结果 |
| 难以断言 | ✅ 输出唯一确定 |
| 测试代码比业务代码还长 | ✅ 测试极其简洁 |
| 覆盖率难提升 | ✅ 路径清晰,易覆盖 |
三、实战对比:纯 vs 不纯
不纯函数:难测试
js
class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
}
async getUserAge(id) {
const user = await this.apiClient.get(`/users/${id}`);
if (!user) throw new Error('User not found');
return calculateAge(user.birthDate);
}
}测试需要 Mock:
js
test('getUserAge returns age', async () => {
const mockClient = {
get: jest.fn().mockResolvedValue({ birthDate: '1990-01-01' })
};
const service = new UserService(mockClient);
const age = await service.getUserAge(1);
expect(age).toBe(35);
expect(mockClient.get).toHaveBeenCalledWith('/users/1');
});- 依赖
jest - 需模拟网络
- 异步
- 测试脆弱
纯函数:极简测试
js
// 纯函数版本
const getUserAge = user => {
if (!user) throw new Error('User not found');
return calculateAge(user.birthDate);
};
const calculateAge = birthDateStr => {
const birthDate = new Date(birthDateStr);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
};测试:直接调用,无需 mock
js
test('calculateAge returns correct age', () => {
expect(calculateAge('1990-01-01')).toBe(35);
expect(calculateAge('2000-12-31')).toBe(24);
expect(calculateAge('2020-06-15')).toBe(5);
});
test('getUserAge throws for missing user', () => {
expect(() => getUserAge(null)).toThrow('User not found');
});- 同步
- 无依赖
- 可离线运行
- 可并行执行
四、纯函数测试的四大优势
1. 无需 Mock
- 没有数据库、API、时间、随机数依赖
- 所有依赖通过参数传入
js
// 好:把时间作为参数
const isExpired = (item, now = Date.now()) =>
item.expiresAt < now;2. 输入输出明确
- 每个测试就是“给输入,断言输出”
- 可以用表格驱动测试(Table-driven Testing)
js
test.each([
['1990-01-01', 35],
['2000-12-31', 24],
['2020-06-15', 5]
])('calculates age for %s', (date, expected) => {
expect(calculateAge(date)).toBe(expected);
});3. 高覆盖率
- 逻辑路径清晰
- 易使用
if-else/switch覆盖所有分支
js
const validateEmail = email => {
if (!email) return { valid: false, error: 'Required' };
if (!email.includes('@')) return { valid: false, error: 'Invalid format' };
return { valid: true };
};
// 轻松覆盖所有分支
test('validateEmail handles empty', () => {
expect(validateEmail('')).toEqual({ valid: false, error: 'Required' });
});
test('validateEmail handles invalid format', () => {
expect(validateEmail('abc')).toEqual({ valid: false, error: 'Invalid format' });
});
test('validateEmail accepts valid email', () => {
expect(validateEmail('a@b.com')).toEqual({ valid: true });
});4. 可缓存、可重放
- 测试结果可序列化
- 可构建“黄金测试”(Golden Tests)
- 可用于快照测试
js
expect(formatInvoice(invoiceData)).toMatchSnapshot();五、工程实践:如何写出更易测试的代码?
1. 依赖注入(DI)
js
const processOrder = (order, taxCalculator, validator) => {
if (!validator(order)) throw new Error('Invalid');
return { ...order, tax: taxCalculator(order.amount) };
};测试时传入纯函数即可。
2. 分层架构:把副作用推到边界
UI → [纯函数] → [适配器] → API/DB
↑
测试在此3. 使用 fp-ts / io-ts 进行类型级验证
ts
import * as t from 'io-ts';
const User = t.type({
name: t.string,
age: t.number
});
// 编译时 + 运行时类型安全,减少无效输入测试六、结语:纯函数是“可验证的数学”
当你写一个纯函数:
js
const fib = n => n <= 1 ? n : fib(n-1) + fib(n-2);你其实在定义一个数学函数。
它的测试不是“模拟环境”,
而是验证数学性质:
fib(0) === 0fib(1) === 1fib(n) === fib(n-1) + fib(n-2)
这正是单元测试的理想形态:
确定性、可重复、无需环境依赖。
“如果你的函数需要 mock 才能测试,
那它可能承担了太多职责。”
从今天起,
让更多的逻辑变成纯函数,
你的测试套件会感谢你。