单元测试与 TDD 完全指南:从 Red-Green-Refactor 到测试金字塔

"我也知道单元测试好,可写起来太烦了,跑得也慢。" —— 几乎每个跳过测试的工程师都说过这句话。问题不在测试本身,在很多人没掌握 TDD 的核心思想,也没体会过"测试驱动设计"带来的代码反馈。这篇文章从"测试到底要测什么"讲起,完整走一遍 Red-Green-Refactor,然后讨论 TDD 不适合的场景。

单元测试要测什么

一句话:测函数的行为,不测函数的实现。这是判断"测试写得好不好"的根本标准。

// 不好的测试:绑死实现细节
test('add 调用了 internal._sum', () => {
    const spy = jest.spyOn(internal, '_sum');
    add(1, 2);
    expect(spy).toHaveBeenCalledWith(1, 2);
});
// 一旦重命名 _sum,测试就挂 —— 但 add(1,2)=3 没变,这种"挂"没意义

// 好的测试:只关心行为
test('add 返回两个数之和', () => {
    expect(add(1, 2)).toBe(3);
    expect(add(-1, 1)).toBe(0);
    expect(add(0.1, 0.2)).toBeCloseTo(0.3);
});

好的测试有三个特点:清晰(看测试名和断言就知道这函数干啥)、独立(测试之间不互相依赖、不依赖执行顺序)、(几毫秒级,几千个测试也能秒过)。

TDD 的三步循环:Red-Green-Refactor

TDD 不是"先写测试再写代码"那么粗。它是一个严格的三步小循环:

  1. Red:写一个失败的测试。这个测试描述你下一个要做的功能。一定要看到它真的失败(否则可能根本没在跑)。
  2. Green:用最简单的代码让它通过。哪怕是 hardcoded 返回值都行,重点是"测试由红变绿"。
  3. Refactor:在所有测试都绿的保护下,重构代码,让它优雅。

很多人卡在"我现在还没想清楚怎么实现,怎么写测试?"。TDD 的回答是:正因为没想清楚,才要先写测试 —— 测试逼你想清楚"我要的输入输出是什么"

实战:用 TDD 写一个 FizzBuzz

经典面试题:1 到 100,3 的倍数打 Fizz,5 的倍数打 Buzz,15 的倍数打 FizzBuzz,否则打数字本身。

第 1 轮:从最简单的情况开始

// fizzbuzz.test.js
import { fizzbuzz } from './fizzbuzz';

test('1 返回 "1"', () => {
    expect(fizzbuzz(1)).toBe('1');
});
// fizzbuzz.js  —— 最简实现,先让测试绿
export function fizzbuzz(n) {
    return '1';   // 是的,hardcoded,完全合规
}

第 2 轮:再加一个普通数

test('2 返回 "2"', () => {
    expect(fizzbuzz(2)).toBe('2');
});

// 实现升级
export function fizzbuzz(n) {
    return String(n);
}

第 3 轮:Fizz

test('3 返回 "Fizz"', () => {
    expect(fizzbuzz(3)).toBe('Fizz');
});

export function fizzbuzz(n) {
    if (n % 3 === 0) return 'Fizz';
    return String(n);
}

第 4 轮:Buzz

test('5 返回 "Buzz"', () => {
    expect(fizzbuzz(5)).toBe('Buzz');
});

export function fizzbuzz(n) {
    if (n % 3 === 0) return 'Fizz';
    if (n % 5 === 0) return 'Buzz';
    return String(n);
}

第 5 轮:FizzBuzz

test('15 返回 "FizzBuzz"', () => {
    expect(fizzbuzz(15)).toBe('FizzBuzz');
});

// 注意:15 已经被前面"%3===0"那条命中,返回 'Fizz' —— 测试红
// 修改:先判断 15
export function fizzbuzz(n) {
    if (n % 15 === 0) return 'FizzBuzz';
    if (n % 3 === 0) return 'Fizz';
    if (n % 5 === 0) return 'Buzz';
    return String(n);
}
// 绿

第 6 轮:Refactor

// 同样的逻辑,可以拼接 —— 更扩展
export function fizzbuzz(n) {
    let out = '';
    if (n % 3 === 0) out += 'Fizz';
    if (n % 5 === 0) out += 'Buzz';
    return out || String(n);
}
// 重构后再跑测试:依然全绿

整个过程的精髓:每一步都极小,每一步都看到红绿变化。一旦某步红了,你知道刚才那行代码是问题源 —— 不需要 debug,直接撤销重写。这种"小步快走"的节奏一旦适应,你会发现编码体验从未如此安心。

jest 实战:常用断言

// 基本相等
expect(1 + 1).toBe(2);                  // 严格相等(===)
expect({ a: 1 }).toEqual({ a: 1 });     // 深度相等

// 数值
expect(0.1 + 0.2).toBeCloseTo(0.3);     // 浮点近似
expect(5).toBeGreaterThan(3);
expect(5).toBeGreaterThanOrEqual(5);

// 字符串
expect('hello world').toContain('world');
expect('abc123').toMatch(/^\w+$/);

// 数组与对象
expect([1, 2, 3]).toHaveLength(3);
expect([1, 2, 3]).toContain(2);
expect({ a: 1, b: 2 }).toHaveProperty('a', 1);

// 异常
expect(() => brokenFn()).toThrow('expected error message');

// 异步
await expect(fetchData()).resolves.toEqual({ ok: true });
await expect(failingPromise()).rejects.toThrow();

// 函数调用
const mock = jest.fn();
mock(1, 2);
expect(mock).toHaveBeenCalled();
expect(mock).toHaveBeenCalledWith(1, 2);
expect(mock).toHaveBeenCalledTimes(1);

组织测试:describe / beforeEach / afterEach

describe('UserService', () => {
    let svc;

    beforeEach(() => {
        svc = new UserService();  // 每个测试前重新创建,保证隔离
    });

    afterEach(() => {
        svc.disconnect();
    });

    describe('createUser', () => {
        test('保存新用户', async () => {
            const u = await svc.createUser({ name: 'mores' });
            expect(u.id).toBeDefined();
        });

        test('名字重复抛错', async () => {
            await svc.createUser({ name: 'mores' });
            await expect(svc.createUser({ name: 'mores' }))
                .rejects.toThrow('duplicate');
        });
    });
});

把相关测试组织进 describe,共享的 setup 用 beforeEach —— 这让测试既清晰又快速(只跑当前 describe 的可以 --testNamePattern)。

Mock:孤立被测对象

单元测试只测一个单元的逻辑,它依赖的其他东西(数据库、网络、时间)要被 mock 掉。

// mock 模块
jest.mock('axios');
import axios from 'axios';

test('fetchUser 拿到数据', async () => {
    axios.get.mockResolvedValue({ data: { id: 1, name: 'mores' } });
    const u = await fetchUser(1);
    expect(u).toEqual({ id: 1, name: 'mores' });
    expect(axios.get).toHaveBeenCalledWith('/users/1');
});

// mock 时间
jest.useFakeTimers();
test('debounce 在 300ms 后调用', () => {
    const fn = jest.fn();
    const d = debounce(fn, 300);
    d(); d(); d();
    jest.advanceTimersByTime(299);
    expect(fn).not.toHaveBeenCalled();
    jest.advanceTimersByTime(1);
    expect(fn).toHaveBeenCalledTimes(1);
});

// mock Date.now()
jest.spyOn(Date, 'now').mockReturnValue(1700000000000);

Mock 是双刃剑:过度 mock 会让测试只验证你写下的假设,而不是真实行为。原则是只 mock 那些"真依赖且不能在测试里跑"的东西(网络、时间、第三方 API),业务对象之间能跑就尽量跑。

测试金字塔:别只写单元测试

不同层级的测试解决不同问题:

          ↑  慢、贵、不稳定,但能验证真实业务
        E2E 测试   (用 Playwright / Cypress 走真实浏览器)
       集成测试   (跑起多个模块/组件协作,可能起真实 DB)
      单元测试   (一个函数/类,毫秒级,几千个)
          ↓  快、稳、便宜,占测试总量的 70-80%

实战经验:核心算法和复杂业务逻辑用单元测试,模块间协作用集成测试,关键用户流程(注册、下单、支付)用 E2E。比例失衡的两种典型病:全是单元测试导致"每个测都过,整体一跑就崩";全是 E2E 测试导致"跑一次 20 分钟、动不动失败重跑"。

覆盖率:一个被高估的指标

# jest 看覆盖率
jest --coverage

# 输出每个文件的 Statements / Branches / Functions / Lines 覆盖率

覆盖率是"测试到了多少代码",不是"测试得多好"。100% 覆盖也可能漏 bug —— 比如测试覆盖了某行但没断言它的输出。所以:

  • 覆盖率作为下限用,不作为目标。核心模块 80%+,边缘工具 50%+ 是合理目标。
  • 盯紧"分支覆盖率"(branches),它比行覆盖更接近"测试是否真的考虑了不同路径"。
  • 新代码的覆盖率比总体更重要:CI 可以设"PR 引入的新代码必须 ≥ 某个覆盖率"才允许合并。

TDD 不适合的场景

诚实地说,TDD 不是万灵药。下面这些场景里 TDD 反而是阻力:

  • 探索性原型:你都不知道最终长什么样,写测试是浪费 —— 等设计稳定后再补测试。
  • UI 视觉细节:像素级、颜色、动画的"对不对"靠肉眼,不靠断言。这类用快照测试或视觉回归测试,不是 TDD。
  • 极简的脚手架代码:Getter/Setter、纯 DTO、简单类型转换,写测试的成本超过实现成本。
  • 外部 API 集成:依赖第三方服务的真实行为,单元测试只能验证 mock 的合约,真值要走集成测试。

属性测试(Property-based Testing):比示例测试更强

普通测试是"给一组样例输入,断言输出"。属性测试是"声明一个对所有输入都该成立的性质,让框架自动生成数千个随机输入来验证"。

// fast-check 库
import fc from 'fast-check';

// 普通示例测试
test('reverse(reverse(arr)) === arr', () => {
    expect(reverse(reverse([1, 2, 3]))).toEqual([1, 2, 3]);
});

// 属性测试:声明性质,fast-check 自动生成 100+ 种数组验证
test('reverse 两次等于原数组(任意数组)', () => {
    fc.assert(fc.property(
        fc.array(fc.integer()),
        (arr) => expect(reverse(reverse(arr))).toEqual(arr),
    ));
});

// 把"sort 后是有序的、长度不变、元素集合不变"作为性质
test('sort 性质', () => {
    fc.assert(fc.property(fc.array(fc.integer()), (arr) => {
        const s = sort(arr);
        expect(s.length).toBe(arr.length);
        for (let i = 1; i < s.length; i++) expect(s[i-1] <= s[i]).toBe(true);
        expect(new Set(s)).toEqual(new Set(arr));
    }));
});

属性测试擅长发现"边界情况" —— 空数组、单元素、超长、Unicode、特殊数字(NaN、Infinity)。fast-check 失败时还会自动"收缩"输入到最简反例,告诉你"用 [0, -1] 就能复现"。Hypothesis(Python)、QuickCheck(Haskell)是同类工具。

快照测试:UI 和大对象的捷径

// React 组件渲染结果快照
import { render } from '@testing-library/react';

test('Button 渲染正确', () => {
    const { container } = render(<Button text="OK" />);
    expect(container.firstChild).toMatchSnapshot();
});

// 第一次跑生成 __snapshots__/ButtonTest.snap
// 之后每次跑对比,有变化就失败,提示"是否更新快照"

快照测试适合:大对象结构(API 响应、配置生成)、UI 结构、序列化输出。注意快照失败时要审视"这个变化是预期的吗?",而不是无脑 jest -u 更新 —— 否则快照测试就退化成"什么都测了等于什么都没测"。

测试异步代码

// async/await 写法,最清晰
test('fetch user', async () => {
    const u = await api.getUser(1);
    expect(u.id).toBe(1);
});

// Promise 链
test('fetch user (promise)', () => {
    return api.getUser(1).then((u) => {
        expect(u.id).toBe(1);
    });
});

// 测试事件触发(回调风格)
test('emitter 触发 ready', (done) => {
    emitter.on('ready', () => {
        expect(emitter.state).toBe('ready');
        done();
    });
    emitter.start();
});

// 控制异步流的时序,用 fake timers
jest.useFakeTimers();
test('debounce 时序', () => {
    const fn = jest.fn();
    const d = debounce(fn, 300);
    d(); d();
    jest.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(1);
});

持续集成里的测试策略

CI 上跑测试,几个值得做的设置:

  • 跑覆盖率,但只对 PR 引入的代码设阈值。"必须 80% 覆盖才能合并"在总代码里不现实,但对"新增/修改"很合理(用 codecov 的 patch 覆盖率)。
  • 并行跑。jest 默认就并行,大型仓库可以用 nx 或 turbo 的 affected 机制只跑改动相关的测试。
  • 失败重跑要慎用。flaky 测试是"看起来稳定的代码"的最大敌人 —— 应该修复或删除,而不是用重试掩盖。
  • 分层:PR 跑单元 + 关键集成,夜间或合并到主干后跑全量 E2E。这平衡了反馈速度和覆盖深度。

测试遗留代码:Strangler 模式

没有测试的老代码不能"一口气补完测试"。务实的做法:

  1. 不要碰原代码,先在它上面套一层"特征测试"(characterization tests) —— 把当前行为(哪怕是 bug)断言下来。
  2. 这层测试只防止"你改坏现状",不验证"是不是符合规约"。
  3. 之后修改代码时,先看测试是否还过;有意改行为时,改测试再改代码。
  4. 随着重构进行,把不合理的特征测试逐步替换为规约测试。

这种"先固化,再演进"的姿态,比"想等代码干净了再补测试"务实得多。多数遗留代码改造失败,都源于试图一次性"既补测又重构"。

写在最后

TDD 的精髓不是"测试覆盖率到 100%",而是用测试塑造代码的设计 —— 一段不好测的代码,通常也是一段难维护的代码。当你为"怎么测这个函数"皱起眉头时,这个函数本身可能就有职责过多、依赖过深、副作用难隔离的问题。测试在帮你听代码说话

给想开始 TDD 的人一个最小习惯:下一个 bug 修复前,先写一个能复现这个 bug 的失败测试,然后修复代码让它通过,最后把测试留下。这是不需要文化变革就能立刻收益的做法 —— 同样的 bug 永远不会回来,而你的测试库自然就长大了。三个月后回头看,你会发现 TDD 不再是"额外工作",而是写代码的默认节奏。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

函数式编程实战指南:从纯函数到 Maybe / Either 容器

2026-5-15 11:21:09

技术教程

单例模式完全指南:从双重检查锁到枚举单例,以及为什么大家都说"少用"

2026-5-15 11:35:34

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索