Skip to content

🔥测试优势:纯函数为何天生适合单元测试?

——无需 mock,输入输出明确,覆盖率高

“一个纯函数,
就是一张输入到输出的真值表。”

一、什么是纯函数?

定义:

  1. 相同输入 ⇒ 相同输出
  2. 无副作用(不修改外部状态、不读取随机数、不调用 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) === 0
  • fib(1) === 1
  • fib(n) === fib(n-1) + fib(n-2)

这正是单元测试的理想形态:
确定性、可重复、无需环境依赖。

“如果你的函数需要 mock 才能测试,
那它可能承担了太多职责。”

从今天起,
让更多的逻辑变成纯函数,
你的测试套件会感谢你。