3、单元测试基础:单元测试定义、单元测试框架选型(JUnit/Pytest)、第一个单元测试用例
好,咱们今天聊聊单元测试。说实话,很多刚入行的同学觉得单元测试就是「写几个assert」,其实远没那么简单。我见过太多项目,单元测试覆盖率看着挺高,但一上线就崩——为什么?因为测试根本没测到点子上。
3.1 单元测试到底是个啥?
单元测试,说白了就是验证代码里最小的可测试单元。在面向对象里,这个单元通常是一个类或一个方法;在函数式编程里,就是一个函数。它的核心目标就一个:确保这段代码在给定输入下,输出符合预期。
我个人习惯把单元测试比作「代码的体检报告」。你想想看,一个大型系统就像一个人体,单元测试就是检查每个器官是否正常工作。如果每个器官都健康,那整个人的健康就有基本保障了。
单元测试的三个核心特征:
- 隔离性:不依赖外部资源(数据库、网络、文件系统)
- 可重复性:每次运行结果一致
- 快速性:毫秒级执行,能频繁运行
我在项目中遇到过最典型的反例:有人把单元测试写成了集成测试。测试一个Service方法,里面调了数据库、发了HTTP请求、还写了文件。结果跑一次测试要30秒,整个团队都不愿意跑。嗯,这其实已经不算单元测试了。
3.2 框架选型:JUnit vs Pytest
选框架这事儿,其实没有绝对的对错。我两个都用过,说说我的感受。
| 维度 | JUnit (Java) | Pytest (Python) |
|---|---|---|
| 语言生态 | Java 标配,Spring 全家桶深度集成 | Python 社区首选,Flask/Django 都支持 |
| 断言风格 | Assertions.assertThat() 链式调用 | assert 原生语句,简洁直观 |
| 参数化测试 | @ParameterizedTest + @CsvSource | @pytest.mark.parametrize 装饰器 |
| Mock 支持 | Mockito / EasyMock | unittest.mock / pytest-mock |
| 测试发现 | 按命名约定自动发现(*Test.java) | 按文件名和函数名自动发现(test_*.py) |
| 学习曲线 | 中等,注解较多 | 低,Pythonic 风格 |
我个人建议:Java 项目无脑选 JUnit 5 + Mockito,这是业界标准组合。Python 项目选 Pytest,它比自带的 unittest 好用太多。我曾经在一个 Python 项目里看到有人用 unittest 写了上千行测试代码,后来我花了一天时间全部重构到 Pytest,代码量直接砍半。
避坑指南:我曾经在选型时犯过一个错——为了「统一技术栈」,在 Java 项目里强行用 TestNG。结果发现社区资源少、文档不全,最后又切回 JUnit。所以我的建议是:选主流框架,别标新立异。
3.3 第一个单元测试用例
光说不练假把式。咱们直接上手写一个。假设我们有一个简单的计算器类:
Java 版本(JUnit 5)
// 被测试类
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("除数不能为0");
}
return a / b;
}
}
// 测试类
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
@Test
void testAdd() {
Calculator calc = new Calculator();
int result = calc.add(3, 5);
assertEquals(8, result, "3 + 5 应该等于 8");
}
@Test
void testDivideByZero() {
Calculator calc = new Calculator();
assertThrows(IllegalArgumentException.class,
() -> calc.divide(10, 0),
"除数为0应该抛出异常");
}
}
Python 版本(Pytest)
# 被测试模块
class Calculator:
def add(self, a, b):
return a + b
def divide(self, a, b):
if b == 0:
raise ValueError("除数不能为0")
return a / b
# 测试文件
import pytest
class TestCalculator:
def test_add(self):
calc = Calculator()
result = calc.add(3, 5)
assert result == 8, "3 + 5 应该等于 8"
def test_divide_by_zero(self):
calc = Calculator()
with pytest.raises(ValueError, match="除数不能为0"):
calc.divide(10, 0)
注意:写第一个测试用例时,最容易犯的错是「测试太复杂」。我见过有人第一个测试用例就写了50行,又是Mock又是参数化。其实没必要。第一个测试用例应该遵循「三A原则」:
- Arrange(准备):创建对象、准备数据
- Act(执行):调用被测方法
- Assert(断言):验证结果
保持简单,一个测试只验证一个行为。
3.4 测试用例的命名规范
命名这事儿,看似小事,其实影响很大。我见过最糟糕的命名就是 test1()、test2()——跑挂了根本不知道哪个功能出了问题。
我个人推荐两种命名风格:
- 方法名_场景_预期结果:
testAdd_TwoPositiveNumbers_ReturnsSum - should_场景_when_条件:
should_throwException_when_divideByZero
两种都可以,关键是团队统一。我在上一个项目里强制要求所有测试用例必须用第一种风格,代码审查时看到 test1 直接打回。一开始有人抱怨,但三个月后,所有人都说真香——因为看测试报告时,一眼就知道哪里出了问题。
3.5 运行你的第一个测试
写完了测试,怎么跑?
- JUnit:IDE 里直接右键运行,或者用 Maven/Gradle 的
mvn test/gradle test - Pytest:命令行执行
pytest test_calculator.py -v,-v参数会显示详细输出
我第一次跑单元测试时,看到绿色的「PASSED」标识,心里还挺激动的。但后来发现,真正的挑战不是让测试通过,而是让测试真正有意义。记住:一个能通过的测试,不一定是个好测试;但一个不能通过的测试,一定是个有用的测试。
小技巧:刚开始写单元测试时,可以先从「边界条件」入手。比如测试一个方法时,先测正常值、再测边界值(0、负数、最大值)、最后测异常情况。这样能覆盖大部分问题。
好了,单元测试的基础就聊到这儿。下一节咱们会深入讲讲「如何写出高质量的测试用例」,包括测试覆盖率、Mock 技巧、以及如何避免常见的测试反模式。到时候我会分享一些我在实际项目中踩过的坑,保证让你少走弯路。