CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
后端入门实战/课时/单元测试
RawBlame
课时目录12 节

后端入门实战

  1. 01后端入门概览
  2. 02Git 与托管
  3. 03语言选型
  4. 04PostgreSQL
  5. 05数据库进阶
  6. 06Redis 与缓存
  7. 07REST API
  8. 08认证
  9. 09ORM
  10. 10单元测试
  11. 11集成与功能测试
  12. 12综合练习

单元测试

测试金字塔底座。

25 分钟

单元测试

这一课你将能做到

  1. 用 Vitest 为业务函数写出结构清晰(AAA)、覆盖边界条件的单元测试并跑通
  2. 用 mock 隔离外部依赖(数据库/HTTP),让测试不依赖任何外部环境就能跑
  3. 画出测试金字塔,说出单元测试应该测什么、不应该测什么,以及覆盖率数字的正确用法

为什么要学

改一行不敢发布、重构全靠人肉回归、bug 修 A 出 B——这些痛的根源都是没有测试网。单元测试是这张网的最密一层:它跑得快(毫秒级)、定位准(失败即具体函数)、写得勤(每次改动随手补)。

人话:单元测试就是给代码配的「体检机」。你把函数的输入喂进去,断言输出符合预期;以后任何人改了这段代码,体检机秒级报警。面试时说「我为核心逻辑写过单测、TDD 跑过红绿循环」,和说「我没写过测试」,在面试官眼里是两种工程师。

本课主线用 Node 的 Vitest(对 TS 友好、零配置、和第 7-9 课的项目无缝衔接),JUnit 5 与 pytest 给出对照写法——概念完全一致,换语言只是换断言 API。

先建立图像

测试金字塔与好测试的 FIRST 标准:

        ╱  E2E  ╲          少而慢:整条链路,分钟级
       ╱ 集成测试 ╲         适量:模块拼起来的"缝",秒级
      ╱  单元测试  ╲        大量:单个函数/类,毫秒级,占 70%+
     ╱──────────────╲

好的单元测试 = FIRST
Fast     快(毫秒级,敢天天跑)
Isolated 独立(不依赖别的测试、不依赖网络/数据库)
Repeatable 可重复(同一环境每次结果一致)
Self-validating 自校验(通过/失败自动判定,不用人看输出)
Timely 及时(随代码一起写,不是事后补)

跟着做

步骤 1:项目初始化,跑通第一个测试

mkdir bebg-test-unit && cd bebg-test-unit && npm init -y
npm install -D vitest

package.json 里补两处(type: module 让项目用 ESM):

{
  "type": "module",
  "scripts": { "test": "vitest run", "test:watch": "vitest" }
}

写一个真实的业务函数(折扣计价,边界多、适合当主角):

// src/price.js
export function discountPrice(price, vipLevel) {
  if (!Number.isFinite(price) || price < 0) {
    throw new Error('价格必须是非负数字');
  }
  const level = vipLevel ?? 0;
  if (level === 1) return round2(price * 0.95);
  if (level === 2) return round2(price * 0.90);
  return round2(price);
}

function round2(n) {
  return Math.round(n * 100) / 100;   // 金额保留两位,避免浮点误差
}

第一个测试文件(注意目录约定:tests/ 或与源码同目录 *.test.js 都行):

// tests/price.test.js
import { describe, it, expect } from 'vitest';
import { discountPrice } from '../src/price.js';

describe('discountPrice 折扣计价', () => {
  it('无会员等级不打折', () => {
    expect(discountPrice(100, 0)).toBe(100);
  });

  it('VIP1 打 95 折,VIP2 打 9 折', () => {
    expect(discountPrice(100, 1)).toBe(95);
    expect(discountPrice(100, 2)).toBe(90);
  });

  it('结果保留两位小数(33.33 * 0.95 这类浮点坑)', () => {
    expect(discountPrice(33.33, 2)).toBe(30);          // 29.997 → 30
    expect(discountPrice(10.1, 1)).toBe(9.6);          // 9.595 → 9.6(不是 9.59)
  });

  it('负数价格抛出异常', () => {
    expect(() => discountPrice(-1, 0)).toThrow('价格必须是非负数字');
  });

  it('vipLevel 缺省视为 0', () => {
    expect(discountPrice(88)).toBe(88);
  });
});
npm test
# ✓ tests/price.test.js (5 tests) — 全绿

体会一下结构:describe 圈住一个被测单元,每个 it 是一条可读的规格说明——把「it 的标题连起来读」,就是这个函数的需求文档。

步骤 2:AAA 结构——每个测试的三段式

把上面任意一个测试放大看:

it('VIP2 打 9 折', () => {
  // Arrange 准备:构造输入
  const price = 100;
  const vipLevel = 2;
  // Act 执行:只调用一次被测函数
  const result = discountPrice(price, vipLevel);
  // Assert 断言:验证结果
  expect(result).toBe(90);
});

一条测试只验证一种行为;断言尽量只有一个主断言。测试写得啰嗦不要紧,读得懂才是第一目标——三个月后失败报警时,你要靠它 10 秒定位。

步骤 3:TDD 走一轮红-绿-重构

需求新增:满 300 再打 88 折(可与 VIP 折叠,叠加后仍保留两位小数)。按 TDD 先写会失败的测试:

// 追加到 tests/price.test.js
it('满 300 叠加满减折扣(VIP2 + 满300 = 90 * 88% = 79.2)', () => {
  expect(discountPrice(300, 2)).toBe(79.2);   // 现在 RUNNING 会失败 —— 红
});
npm test
# ✗ 1 failed —— 红灯,确认测试真的能测出新功能缺失

实现最小代码让测试通过(src/price.js 修改):

export function discountPrice(price, vipLevel) {
  if (!Number.isFinite(price) || price < 0) {
    throw new Error('价格必须是非负数字');
  }
  let result = price;
  if (vipLevel === 1) result *= 0.95;
  if (vipLevel === 2) result *= 0.90;
  if (result >= 300) result *= 0.88;      // 新增
  return round2(result);                  // 绿灯
}

最后重构:变量改名、拆函数,跑测试保持绿。红-绿-重构的节奏让每一步都有网兜着。

步骤 4:mock——隔离外部依赖

问题来了:函数依赖数据库/HTTP 怎么测?答案不是连真库,而是mock 替身。写一个典型场景——用户服务调外部接口:

// src/api.js —— 真实的 HTTP 依赖(测试时会被替换)
export async function fetchFromApi(path) {
  const res = await fetch('https://api.example.com' + path);
  if (!res.ok) throw new Error('请求失败: ' + res.status);
  return res.json();
}
// src/userService.js —— 被测对象
import { fetchFromApi } from './api.js';

export async function getUserLabel(id) {
  const user = await fetchFromApi(`/users/${id}`);
  if (!user.name) throw new Error('用户资料不完整');
  return `${user.name}<${user.email ?? 'no-email'}>`;
}
// tests/userService.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';

// 把整个 api 模块换成替身——测试不碰网络
vi.mock('../src/api.js', () => ({
  fetchFromApi: vi.fn(),
}));

import { fetchFromApi } from '../src/api.js';
import { getUserLabel } from '../src/userService.js';

describe('getUserLabel', () => {
  beforeEach(() => vi.clearAllMocks());

  it('正常拼出 label', async () => {
    fetchFromApi.mockResolvedValue({ name: 'alice', email: 'a@x.com' });
    await expect(getUserLabel(1)).resolves.toBe('alice<a@x.com>');
    expect(fetchFromApi).toHaveBeenCalledWith('/users/1');   // 顺带验证交互
  });

  it('没有邮箱时用占位符', async () => {
    fetchFromApi.mockResolvedValue({ name: 'bob' });
    await expect(getUserLabel(2)).resolves.toBe('bob<no-email>');
  });

  it('上游 500 时错误向上抛', async () => {
    fetchFromApi.mockRejectedValue(new Error('请求失败: 500'));
    await expect(getUserLabel(3)).rejects.toThrow('请求失败: 500');
  });
});
npm test   # 全绿,全程零网络请求

vi.mock 替换模块、mockResolvedValue/mockRejectedValue 指定返回、toHaveBeenCalledWith 验证调用——mock 的本质是:把「不可控的外部世界」换成「你说了算的剧本」,让单元测试回到毫秒级。

步骤 5:JUnit 5 与 pytest 对照

同样的计价测试,Java 与 Python 长这样:

JUnit 5(Java):

class PriceTest {
    @Test
    @DisplayName("VIP2 打 9 折")
    void vip2Discount() {
        assertEquals(90.0, PriceService.discountPrice(100, 2), 0.001);
    }

    @Test
    @DisplayName("负数价格抛异常")
    void negativePrice() {
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
            () -> PriceService.discountPrice(-1, 0));
        assertEquals("价格必须是非负数字", ex.getMessage());
    }

    // Mockito 模拟依赖,等价于 vi.mock
    @Test
    void fetchIsCalled() {
        ApiClient api = mock(ApiClient.class);
        when(api.fetch("/users/1")).thenReturn(new User("alice", "a@x.com"));
        UserService service = new UserService(api);
        assertEquals("alice<a@x.com>", service.getUserLabel(1));
        verify(api).fetch("/users/1");
    }
}

pytest(Python):

# test_price.py
import pytest
from price import discount_price

def test_vip2_discount():
    assert discount_price(100, 2) == 90

def test_negative_price():
    with pytest.raises(ValueError, match="价格必须是非负数字"):
        discount_price(-1, 0)

# monkeypatch 模拟依赖,等价于 vi.mock
def test_get_user_label(monkeypatch):
    import user_service
    async def fake_fetch(path):
        return {"name": "alice", "email": "a@x.com"}
    monkeypatch.setattr(user_service, "fetch_from_api", fake_fetch)
    assert user_service.get_user_label(1) == "alice<a@x.com>"

注意共性:命名表达行为、异常用专门断言、依赖用替身注入。语言换了,思维不变。

讲清楚要点

  1. 测行为,不测实现:断言「输入 100/等级 2 输出 90」,而不是断言「内部先乘了 0.9」。测实现的测试一重构就碎,沦为改代码的累赘——这是测试腐烂的第一大原因。
  2. 什么值得测:业务规则与边界(0、负数、极值、空、非法输入)、修过的 bug(写个回归测试钉死)、核心链路的分支。不值得测:getter/setter、框架自身行为、纯展示拼串。测试是投资,要有回报率。
  3. 覆盖率是仪表盘不是 KPI:80% 上下是常见的健康线,但关键分支没测的 90% 不如有价值断言的 60%。为凑数字给 getter 写测试是自我欺骗。Vitest 查看覆盖率:npm install -D @vitest/coverage-v8 后 npx vitest run --coverage。
  4. mock 的克制原则:只 mock 你不拥有的边界(网络、时钟、随机、文件系统);不要 mock 被测对象自己,也不要 mock 它的内部方法——mock 太多说明模块拆分有问题,测的全是「剧本」而不是代码。
  5. 测试代码同样是代码:命名、去重(参数化)、入库标准(测试不过不许合并——CI 里强制跑,第 11 课接上)。

你可能会懵的地方

  • 私有方法要不要测? 不直接测。私有方法是实现细节,通过公共接口覆盖它;如果公共接口怎么都触发不到那段私有逻辑,说明要么是死代码、要么类职责太大该拆了。

  • 一个测试里能放多个断言吗? 验证同一行为的多个侧面可以(如结果值和类型);不同行为必须拆开——否则第一个断言挂了,后面的全不执行,你一次只能看到一个错。

  • 测试之间可以共享数据吗? 只读常量可以,可变状态绝不行(模块级变量、数据库残留、文件)。每个测试自建自清(beforeEach/beforeAll + 隔离),执行顺序不影响结果——顺序依赖的测试会在 CI 并行时随机爆炸。

  • TDD 每个功能都要走吗? 现实是弹性执行:核心业务规则、复杂边界适合严格红绿循环;原型探索期先写代码补测试也可接受。但「补」要发生在合并之前,不是「以后再说」。

  • 异步代码怎么测? 断言用 resolves/rejects(Vitest)、await assertThrowsAsync(pytest 有 pytest-asyncio),JDK 用 CompletableFuture 断言。核心纪律只有一条:忘了 await 的测试永远绿——一个从不失败的测试比没有测试更危险,可以故意塞一个错误断言确认它会红。

错法对照

错法问题正解
断言全部注释掉「临时跳过」测试永远绿,形同虚设用 it.skip 显式跳过并在 PR 里说明
测试读取顺序依赖(B 测试依赖 A 建的数据)并行执行随机挂每个测试自建数据、自清理
mock 被测对象内部方法测的是剧本不是行为只替换外部依赖的边界
一个 it 塞十种输入循环断言失败时不知道哪个输入错参数化(it.each / @ParameterizedTest / pytest.mark.parametrize)
事后补测试对着实现抄只验证了现状,覆盖不了应有的边界按需求/边界设计用例,实现是黑盒
追求 100% 覆盖率大量无效测试拖慢迭代保住关键路径与边界,覆盖率做参考

卡点急救

  1. vitest: command not found:装成了全局依赖或没装。npm install -D vitest(本地依赖),用 npm test 走 package.json 的 script。
  2. 报 SyntaxError: Cannot use import statement outside a module:package.json 缺 "type": "module",或测试文件后缀应为 .mjs。本项目模板记得补上。
  3. vi.mock 不生效:路径必须与被测文件导入的路径完全一致(含 ../ 与扩展名);vi.mock 会被提升到文件顶部,不要放在 it 里面。
  4. 测试意外全绿(测试失效):异步断言忘了 await/resolves。检验法:故意写一个必失败的断言,确认测试真的会红。
  5. watch 模式跑不停或跑太多:npm run test(单次)与 npm run test:watch(增量监听)区分开;CI 里只用单次模式。

面试常问

  • 单元测试和集成测试的区别? 单测:最小单元(函数/类)+ 依赖全部替身 + 毫秒级 + 数量最多;集成:多个真实模块拼装(如路由+服务+真测试库)+ 秒级 + 数量适中。前者定位「逻辑对不对」,后者定位「拼起来通不通」。下一课专门展开。

  • 为什么需要 mock?滥用有什么问题? 隔离不可控依赖(网络/DB/时钟),保证快、稳、可重复。滥用(mock 链太深、mock 被测对象内部)会让测试与实现强耦合、一改就碎——mock 只留在架构边界上。

  • 说说 TDD 的流程与利弊。 红(写失败测试明确需求)→ 绿(最小实现通过)→ 重构(保持绿的前提下改善结构)。好处:需求先落地成可执行规格、回归网随写随有;成本:起步慢、对设计能力有要求。适合规则明确的业务逻辑,探索性代码可放宽。

  • 测试覆盖率多少合适? 没有魔法数字:工程上 70-80% 是常见健康线,但更重要的是关键分支全覆盖 + 失败测试有清晰行为语义。回答要点出「覆盖率衡量的是执行过,不是验证过」。

  • 如何测试一段依赖当前时间的逻辑(如优惠券过期)? 注入时钟而不是直接 new Date():把「取当前时间」作为依赖传入或用库的时钟替身(Vitest vi.useFakeTimers、Java Clock 注入),测试里把时间拨到任意点。这同时倒逼出更好的设计。

自测三问

  1. 给 discountPrice 新增规则「折扣价不低于 1 元」,用 TDD 流程走一遍:先写出会失败的测试。
  2. getUserLabel 的三个测试如果去掉 beforeEach(() => vi.clearAllMocks()),哪个用例可能被前一个污染?为什么?
  3. 你的项目里「金额计算模块」和「纯 CRUD 接口」分别适合放多少单测、多少集成测试?说理由。

小结一句话

单元测试 = 给核心逻辑配 FIRST 级的自动化体检:测行为不测实现、mock 只留在边界、TDD 红绿循环让需求先变成可执行的规格。

延伸

  • Vitest 官方文档
  • 《单元测试的艺术》——测试思维经典
  • 下一课:集成与功能测试——模块拼起来的「缝」,得用真环境来验。
加载本节练习…
← 上一课ORM下一课 →集成与功能测试