Skip to content
检测中

函数式测试

纯函数测试 = 输入断言,无需 mock/setup/teardown。

纯函数测试哲学

ts
// 纯函数:测试即文档
const add = (a: number, b: number) => a + b;

test('add sums two numbers', () => {
  expect(add(2, 3)).toBe(5);
  expect(add(-1, 1)).toBe(0);
  expect(add(0, 0)).toBe(0);
});

vs 副作用测试

ts
// 副作用测试:需 mock、setup、teardown
const processOrder = async (order: Order) => {
  const tax = calculateTax(order);
  const taxed = { ...order, tax };
  await db.save(taxed);
  return taxed;
};

test('processOrder saves with tax', async () => {
  // mock db
  const mockSave = jest.fn().mockResolvedValue({});
  (db as any).save = mockSave;

  // setup
  const order = { id: 1, total: 100 };

  // execute
  const result = await processOrder(order);

  // assert
  expect(mockSave).toHaveBeenCalled();
  expect(result.tax).toBe(10);

  // teardown (handled by jest.restoreAllMocks)
});

测试分类

1. 单元测试(纯函数)

ts
import * as R from 'ramda';

const calculateTotal = (items: Array<{ price: number; qty: number }>) =>
  R.pipe(
    R.map(R.multiply(R.__, R.__)), // 需要 zip
    R.sum
  )(items.map(i => [i.price, i.qty]));

// 简化版本
const calculateTotal = (items: Array<{ price: number; qty: number }>) =>
  items.reduce((acc, item) => acc + item.price * item.qty, 0);

test('calculateTotal sums item totals', () => {
  expect(calculateTotal([
    { price: 10, qty: 2 },
    { price: 5, qty: 3 }
  ])).toBe(35);

  expect(calculateTotal([])).toBe(0);

  expect(calculateTotal([
    { price: 0, qty: 5 }
  ])).toBe(0);
});

2. 集成测试(Effect)

ts
type Effect<A> = { _tag: 'Effect'; perform: () => Promise<A> };

const testEffect = async <A>(
  eff: Effect<A>,
  mock: {
    perform: () => Promise<A>;
    expect: (result: A) => void;
  }
) => {
  const result = await eff.perform();
  mock.expect(result);
};

const fetchUser = (id: number): Effect<User> =>
  effect(async () => {
    const res = await fetch(`/api/users/${id}`);
    return res.json();
  });

test('fetchUser returns user', async () => {
  await testEffect(fetchUser(1), {
    perform: async () => ({ id: 1, name: 'Alice' }),
    expect: (user) => {
      expect(user.name).toBe('Alice');
    }
  });
});

3. Reducer 测试(状态迁移)

ts
type CounterState = { count: number };
type CounterAction = { type: 'INCREMENT' } | { type: 'DECREMENT' };

const counterReducer = (
  state: CounterState,
  action: CounterAction
): CounterState => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
};

test('counterReducer updates count', () => {
  const state = { count: 0 };

  expect(counterReducer(state, { type: 'INCREMENT' }))
    .toEqual({ count: 1 });

  expect(counterReducer(state, { type: 'DECREMENT' }))
    .toEqual({ count: -1 });

  expect(counterReducer({ count: 5 }, { type: 'DECREMENT' }))
    .toEqual({ count: 4 });
});

属性测试(Property-based Testing)

ts
import * as fc from 'fast-check';

// 定理:add 是可交换的
test('add is commutative', () => {
  fc.assert(
    fc.property(fc.integer(), fc.integer(), (a, b) => {
      return add(a, b) === add(b, a);
    })
  );
});

// 定理:add 有零元
test('add has zero element', () => {
  fc.assert(
    fc.property(fc.integer(), (n) => {
      return add(n, 0) === n && add(0, n) === n;
    })
  );
});

// 定理:add 是可结合的
test('add is associative', () => {
  fc.assert(
    fc.property(fc.integer(), fc.integer(), fc.integer(), (a, b, c) => {
      return add(add(a, b), c) === add(a, add(b, c));
    })
  );
});

Golden Master Testing

ts
// 保存"正确"输出作为参考
const goldenMaster = (input: string): string => {
  // 旧实现,视为正确
  return input.toUpperCase();
};

// 新实现
const newImplementation = (input: string): string => {
  return input.toUpperCase();
};

test('newImplementation matches golden master', () => {
  fc.assert(
    fc.property(fc.string(), (input) => {
      return newImplementation(input) === goldenMaster(input);
    })
  );
});

测试覆盖率

分支覆盖率

ts
const parseStatus = (code: number): 'success' | 'error' | 'unknown' => {
  if (code >= 200 && code < 300) return 'success';
  if (code >= 400) return 'error';
  return 'unknown';
};

test('parseStatus handles all cases', () => {
  expect(parseStatus(200)).toBe('success');
  expect(parseStatus(299)).toBe('success');
  expect(parseStatus(400)).toBe('error');
  expect(parseStatus(500)).toBe('error');
  expect(parseStatus(100)).toBe('unknown');
  expect(parseStatus(300)).toBe('unknown');
});

Edge Cases

ts
const divide = (a: number, b: number): number | null => {
  if (b === 0) return null;
  return a / b;
};

test('divide handles edge cases', () => {
  expect(divide(10, 2)).toBe(5);
  expect(divide(-10, 2)).toBe(-5);
  expect(divide(10, -2)).toBe(-5);
  expect(divide(0, 5)).toBe(0);
  expect(divide(0, -1)).toBe(0);
  expect(divide(Infinity, 1)).toBe(Infinity);
  expect(divide(10, 0)).toBeNull();
  expect(divide(NaN, 1)).toBeNaN();
});

快照测试

ts
const formatUser = (user: User): string => {
  return `Name: ${user.name}, Email: ${user.email}`;
};

test('formatUser snapshot', () => {
  const user = { name: 'Alice', email: '[email protected]' };
  expect(formatUser(user)).toMatchSnapshot();
});

// 后续修改时, Jest 会对比快照
// 修改快照:`jest -u` 或 `expect(formatUser(user)).toMatchInlineSnapshot()`

测试工具

Vitest

ts
import { describe, it, expect } from 'vitest';

describe('add', () => {
  it('adds two numbers', () => {
    expect(add(2, 3)).toBe(5);
  });
});

fast-check

ts
import * as fc from 'fast-check';

it('reverse(reverse(array)) === array', () => {
  fc.assert(
    fc.property(fc.array(fc.anything()), (arr) => {
      return R.reverse(R.reverse(arr)).toEqual(arr);
    })
  );
});

下一步

Released under the ISC License.