单元测试基础

嘿,欢迎来到第2章。今天咱们聊聊单元测试的根基——到底什么是单元测试、测试金字塔长什么样,以及测试驱动开发(TDD)是怎么一回事。这些内容看起来基础,但我在项目里见过太多人一开始就绕弯路,所以咱们把话说透。

什么是单元测试

单元测试,说白了就是针对软件里最小可测试单元的验证。这个“单元”通常是一个函数、一个方法,或者一个类里面的公开行为。它不依赖数据库、网络、文件系统这些外部资源——因为这些该由集成测试去管。

我个人习惯把单元测试比作“显微镜下的质检员”。你写一个函数计算折扣价,单元测试就去检查输入正确的金额能不能返回正确结果,边界值(比如0元、负数、超大量)会不会崩溃。

单元测试的核心特征

  • 快速:毫秒级执行,跑完上千个测试也就几秒
  • 隔离:不碰外部依赖,用Mock/Stub替代
  • 可重复:任何环境、任何时候跑结果都一样
  • 自动化:集成到构建流程里,每次提交都自动执行

举个最简例子。假设我们有一个计算税率的函数:

function calculateTax(amount, rate) {
  // 金额为0或负数时返回0
  if (amount <= 0) return 0;
  return amount * rate;
}

它的单元测试长这样(用Jest风格):

test('正常金额计算税', () => {
  expect(calculateTax(100, 0.1)).toBe(10);
});

test('金额为0返回0', () => {
  expect(calculateTax(0, 0.1)).toBe(0);
});

test('金额为负数返回0', () => {
  expect(calculateTax(-50, 0.1)).toBe(0);
});

你看,每一行代码都只有一个验证点。我刚开始写单元测试时,总喜欢在一个用例里测好几个场景,后来发现失败了根本定位不到哪一行——教训啊。

我曾经踩过的坑: 写单元测试时把数据库连上了,结果测试跑了两分钟,整个CI卡住。记住:单元测试必须做到“不落盘、不联网、不跨进程”。

测试金字塔模型

提到测试分层,绕不开这个经典模型——测试金字塔。它由Mike Cohn提出,把测试按粒度从下往上分成三层:

  1. 单元测试(底层):数量最多,速度最快,覆盖业务逻辑
  2. 集成测试(中间层):验证模块间的交互,比如API、数据库
  3. 端到端测试(顶层):模拟真实用户操作,数量最少

金字塔的形状说明了什么?底层要厚实,顶层要薄。我见过一个团队把大量精力花在UI自动化上,结果每次页面改版整个测试套件全崩,反观单位测试覆盖率不到10%——你想想看,这种投资回报率有多低。

下面的表帮你快速对比各层特点:

层级 粒度 执行速度 维护成本 典型工具
端到端测试 粗(整个系统) 慢(秒~分钟) 高 Selenium, Cypress
集成测试 中(模块/接口) 中(毫秒~秒) 中 Testcontainers, Spring Boot Test
单元测试 细(函数/方法) 快(微秒~毫秒) 低 JUnit, Jest, pytest

嗯,这里要注意:金字塔并不是死规矩。如果你的项目是纯算法库,可能不需要端到端测试;如果是微服务系统,集成测试的比例会适当提高。但原则不变——单元测试永远是地基。

测试金字塔与TDD知识体系 单元测试 (最多,最快) 集成测试 (适中) 端到端测试 (最少,最慢) 🔴 红:写失败测试 先写断言,测试不通过 🟢 绿:让测试通过 写最少代码通过测试 🔧 重构:优化代码 保持测试通过,改善设计 TDD驱动单元测试 ⚠ 提示 单元测试是TDD的基石

测试驱动开发(TDD)简介

TDD,全称Test-Driven Development。它不是一种测试技术,而是一种开发方法论。核心流程就是上面SVG里画的“红-绿-重构”循环:

  • 红:先写一个会失败的测试(还没实现功能)
  • 绿:写最简代码让这个测试通过
  • 重构:优化代码,同时保证测试依然全绿

我在项目中实际用过TDD开发一个支付模块。起初觉得“先写测试再写代码”很反直觉,但坚持下来发现两个好处:一是代码设计更松耦合(因为你必须让类可测试),二是回归成本极低。我有多依赖它?有一次重构了整个核心算法,跑一遍测试只花了3秒,全部通过,直接上线——那种安全感,没试过TDD的人体会不到。

我的个人建议: 如果你刚开始学TDD,别一上来就要求100%覆盖率。先从几个关键业务函数练手,感受“红绿切换”的节奏。你会慢慢上瘾的。

来看一个真实TDD的例子:我们要写一个判断年份是否为闰年的函数。按照TDD步骤:

第一步(红): 先写测试,期望输入2000年返回true(闰年规则:能被4整除但不能被100整除,或者能被400整除)。

test('2000年是闰年', () => {
  expect(isLeapYear(2000)).toBe(true);
});
test('1900年不是闰年', () => {
  expect(isLeapYear(1900)).toBe(false);
});

此时函数还没写,测试报红。

第二步(绿): 写最简单实现。

function isLeapYear(year) {
  if (year % 400 === 0) return true;
  if (year % 100 === 0) return false;
  return year % 4 === 0;
}

运行测试——全绿。

第三步(重构): 当前代码已经很清晰,无需改动。但你可以考虑将条件合并成一行,或者提取常量。记住:重构时不新增行为。

你可能会问:“TDD和单元测试是什么关系?” 很简单——TDD强迫你写单元测试,而且这些测试天然具有自文档、可回归的特性。我个人的经验是,即使不是严格使用TDD,至少做到“写功能前先写测试大纲”,能帮你节省大量调试时间。