3、单元测试基础:JUnit/NUnit基础、断言艺术、测试命名规范
单元测试,说白了就是给代码写“体检报告”。你写了一个函数,它到底能不能跑?边界情况处理了没?别人改你的代码会不会改出bug?这些都得靠单元测试来兜底。
我刚开始做项目时,总觉得写测试浪费时间。直到有一次,我改了一个工具类里的日期格式化方法,自以为没问题,结果上线后订单系统全崩了——就因为一个时区参数没传对。嗯,从那以后,我再也不敢不写单元测试了。
3.1 JUnit / NUnit 基础
不管你是Java阵营还是.NET阵营,单元测试框架的核心套路都差不多。我两边都写过,说实话,学会了其中一个,另一个上手也就半天功夫。
3.1.1 JUnit 5 快速上手
JUnit 5 是目前Java生态的标配。它由三个组件组成:JUnit Platform(运行引擎)、JUnit Jupiter(编程模型)、JUnit Vintage(兼容老版本)。
一个典型的测试类长这样:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
void shouldAddTwoNumbers() {
int result = calculator.add(3, 5);
assertEquals(8, result);
}
@Test
void shouldThrowExceptionWhenDividingByZero() {
assertThrows(ArithmeticException.class,
() -> calculator.divide(10, 0));
}
}
这里有几个关键注解:
| 注解 | 作用 | 执行次数 |
|---|---|---|
@Test |
标记一个测试方法 | 每个测试方法执行一次 |
@BeforeEach |
每个测试前执行,用于初始化 | 每个测试方法前执行一次 |
@AfterEach |
每个测试后执行,用于清理 | 每个测试方法后执行一次 |
@BeforeAll |
所有测试前执行一次,必须static | 整个测试类只执行一次 |
@AfterAll |
所有测试后执行一次,必须static | 整个测试类只执行一次 |
@BeforeEach 而不是 @BeforeAll。虽然慢一点,但能保证每个测试用例的独立性,不会因为上一个测试改了状态而影响下一个。
3.1.2 NUnit 快速上手
.NET 这边,NUnit 是主流选择。它的设计思路和 JUnit 几乎一样,只是命名风格偏 .NET 的 PascalCase。
using NUnit.Framework;
[TestFixture]
public class CalculatorTests
{
private Calculator _calculator;
[SetUp]
public void SetUp()
{
_calculator = new Calculator();
}
[Test]
public void Add_TwoNumbers_ReturnsSum()
{
var result = _calculator.Add(3, 5);
Assert.AreEqual(8, result);
}
[Test]
public void Divide_ByZero_ThrowsException()
{
Assert.Throws<DivideByZeroException>(
() => _calculator.Divide(10, 0));
}
}
对应关系一目了然:[TestFixture] 对应 @TestClass,[SetUp] 对应 @BeforeEach,[Test] 对应 @Test。
3.2 断言艺术
断言是测试的灵魂。你想想看,一个测试方法如果没有断言,那它跑完跟没跑有什么区别?
我见过不少新手写测试,断言就只会用 assertEquals。其实断言库提供了很多工具,用对了能让测试更精准、更易读。
3.2.1 核心断言方法
| 场景 | JUnit 5 | NUnit |
|---|---|---|
| 相等判断 | assertEquals(expected, actual) |
Assert.AreEqual(expected, actual) |
| 布尔判断 | assertTrue(condition) |
Assert.IsTrue(condition) |
| 空值判断 | assertNull(object) |
Assert.IsNull(object) |
| 同一引用 | assertSame(expected, actual) |
Assert.AreSame(expected, actual) |
| 异常断言 | assertThrows(ExClass, () -> ...) |
Assert.Throws<ExClass>(() => ...) |
| 超时断言 | assertTimeout(ofSeconds(2), () -> ...) |
Assert.That(code, Throws.Nothing) + 自定义 |
3.2.2 断言的最佳实践
断言不是越多越好,而是要恰到好处。我个人总结了三条原则:
- 一个测试只测一个逻辑点。 如果一个方法里有三个断言,那它很可能在测三件事。拆开!
- 优先使用更具体的断言。 能
assertTrue就别用assertEquals(true, ...),能assertThrows就别用 try-catch 手动抓异常。 - 断言信息要可读。 给断言加个 message 参数,失败时一眼就能看出问题。
// 好的写法
assertEquals("用户年龄应该为18", 18, user.getAge());
// 不好的写法
assertEquals(18, user.getAge());
// 失败时只看到 "expected: 18 but was: 17",还得猜是哪个字段
3.3 测试命名规范
测试命名这件事,看起来是小事,但影响很大。你想想看,一个项目有成百上千个测试,如果命名乱七八糟,跑失败了都不知道该修哪里。
3.3.1 命名模式
业界最常用的命名模式有三种。我个人推荐第一种:
| 模式 | 格式 | 示例 |
|---|---|---|
| Given-When-Then | 方法名_场景_预期行为 |
withdraw_insufficientBalance_throwsException |
| Should-When | should_预期行为_when_场景 |
should_throwException_when_balanceInsufficient |
| 传统模式 | test方法名_场景 |
testWithdrawWithInsufficientBalance |
3.3.2 命名原则
好的测试名应该像一句人话。你不需要看代码,光看名字就知道这个测试在测什么。
- 用业务语言,不要用技术语言。 比如
shouldReturnErrorWhenOrderExpired比testOrderExpirationCheck好得多。 - 包含预期结果。 名字里就要说清楚“应该怎样”,而不是“测试什么”。
- 保持一致性。 整个团队用同一套命名规范,别今天用 Given-When-Then,明天用 Should-When。
方法名_场景_预期行为 的格式。比如 calculateDiscount_vipUser_returns20PercentOff。这样在测试报告里,失败信息直接就是一句完整的话,排查问题快很多。
3.4 单元测试知识体系
下面这张图总结了本章的核心脉络。你可以把它当作一个检查清单,写测试时对照着看:
3.5 实战:写一个完整的测试
光说不练假把式。我们拿一个真实的业务场景来写测试。
假设有一个订单服务,需要计算折扣。规则是:VIP用户打8折,普通用户满100减20。我们来写测试:
class OrderServiceTest {
private OrderService orderService;
@BeforeEach
void setUp() {
orderService = new OrderService();
}
@Test
void calculateDiscount_vipUser_returns20PercentOff() {
User vipUser = new User(UserType.VIP);
Order order = new Order(vipUser, 200.0);
double discount = orderService.calculateDiscount(order);
assertEquals(40.0, discount, 0.001);
}
@Test
void calculateDiscount_regularUserAmountOver100_returns20Off() {
User regularUser = new User(UserType.REGULAR);
Order order = new Order(regularUser, 150.0);
double discount = orderService.calculateDiscount(order);
assertEquals(20.0, discount, 0.001);
}
@Test
void calculateDiscount_regularUserAmountUnder100_returns0() {
User regularUser = new User(UserType.REGULAR);
Order order = new Order(regularUser, 50.0);
double discount = orderService.calculateDiscount(order);
assertEquals(0.0, discount, 0.001);
}
@Test
void calculateDiscount_nullOrder_throwsException() {
assertThrows(IllegalArgumentException.class,
() -> orderService.calculateDiscount(null));
}
}
你看,每个测试的名字都清楚说明了“什么场景下,应该得到什么结果”。跑失败了,光看名字就知道是VIP折扣算错了,还是边界条件没处理。
单元测试这件事,说白了就是给代码上保险。你花10分钟写测试,可能在未来省下10小时的排查时间。这笔账,怎么算都划算。