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 断言的最佳实践

断言不是越多越好,而是要恰到好处。我个人总结了三条原则:

  1. 一个测试只测一个逻辑点。 如果一个方法里有三个断言,那它很可能在测三件事。拆开!
  2. 优先使用更具体的断言。 能 assertTrue 就别用 assertEquals(true, ...),能 assertThrows 就别用 try-catch 手动抓异常。
  3. 断言信息要可读。 给断言加个 message 参数,失败时一眼就能看出问题。
// 好的写法
assertEquals("用户年龄应该为18", 18, user.getAge());

// 不好的写法
assertEquals(18, user.getAge());
// 失败时只看到 "expected: 18 but was: 17",还得猜是哪个字段
我曾经踩过的坑: 在循环里写断言。比如遍历一个列表,每个元素都要校验。如果第5个元素失败了,前面的4个断言都白跑了,而且你根本不知道是哪个元素出了问题。正确的做法是用参数化测试,或者至少把索引信息加到断言信息里。

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 单元测试知识体系

下面这张图总结了本章的核心脉络。你可以把它当作一个检查清单,写测试时对照着看:

单元测试基础知识体系 单元测试基础 · 知识体系 框架基础 断言艺术 命名规范 JUnit 5 NUnit 核心断言 最佳实践 命名模式 命名原则 核心原则 一个测试只测一个点 · 断言要精准 · 命名要像人话

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折扣算错了,还是边界条件没处理。

小技巧: 测试方法名可以写得很长,别怕。IDE 会自动换行,而且测试报告里长名字比短名字有用得多。我见过最长的测试名有60多个字符,但团队里没人抱怨,因为一看就懂。

单元测试这件事,说白了就是给代码上保险。你花10分钟写测试,可能在未来省下10小时的排查时间。这笔账,怎么算都划算。