JUnit 断言与测试规范 | JavaSE

JUnit 断言与测试规范

一、学习目标

学完本章,你应该能够:

  • 解释什么是断言(Assertion)以及断言为什么是自动化测试的核心。
  • 熟练使用 assertEquals()assertTrue()assertFalse()assertNull()assertNotNull() 等常见断言。
  • 使用 assertThrows() 验证异常行为。
  • 正确区分 Expected Value 与 Actual Value。
  • 按照 Arrange-Act-Assert 思想组织测试代码。
  • 为正常值、边界值和异常值设计测试。
  • 编写相互独立、可重复执行并具有明确语义的测试方法。

二、核心知识

2.1 为什么仅仅执行方法还不够

例如:

@Test
void add() {
    Calculator calculator = new Calculator();

    int result = calculator.add(10, 20);

    System.out.println(result);
}

运行以后:

30

这个测试虽然调用了业务代码,但存在一个重要问题:

JUnit 不知道 30 是对还是错。

因此真正的自动化测试必须告诉测试框架:

我期望什么结果

然后让框架自动比较:

期望结果
vs
实际结果

这就是:

断言(Assertion)


2.2 什么是断言

断言可以理解为:

对程序应该满足的条件作出明确声明。

例如:

assertEquals(30, result);

表示:

我断言 result 必须等于 30。

如果:

result == 30

测试继续并最终通过。

如果:

result != 30

测试失败。


2.3 Expected 与 Actual

这是单元测试非常重要的一组概念:

Expected → 期望值
Actual   → 实际值

例如:

assertEquals(30, calculator.add(10, 20));

其中:

30                         → Expected
calculator.add(10, 20)     → Actual

推荐始终保持这种顺序:

assertEquals(expected, actual);

这样测试失败时信息更加清晰。


2.4 自动化测试的完整逻辑

一个真正有意义的测试其实是在描述:

给定某种输入
      ↓
执行某个行为
      ↓
应该得到某个结果

例如:

@Test
void addTwoPositiveNumbersShouldReturnTheirSum() {
    Calculator calculator = new Calculator();

    int result = calculator.add(10, 20);

    assertEquals(30, result);
}

背后的逻辑就是:

Given:10 和 20
When :执行 add
Then :结果应该是 30

三、使用方法

3.1 Assertions 类

JUnit Jupiter 的常用断言位于:

org.junit.jupiter.api.Assertions

可以这样使用:

Assertions.assertEquals(30, result);

也可以使用静态导入:

import static org.junit.jupiter.api.Assertions.*;

之后直接写:

assertEquals(30, result);

测试类通常推荐使用静态导入,使代码更接近自然语言。


3.2 assertEquals

用于判断:

期望值 == 实际值

例如:

@Test
void add() {
    Calculator calculator = new Calculator();

    int result = calculator.add(10, 20);

    assertEquals(30, result);
}

字符串也可以:

assertEquals("LingXi", user.getName());

3.3 assertNotEquals

判断:

实际结果不应该等于某值

例如:

assertNotEquals(0, user.getId());

但是如果可以明确知道正确值:

assertEquals(expected, actual);

通常比:

assertNotEquals(...)

表达得更精确。


3.4 assertTrue

判断条件必须为:

true

例如:

@Test
void adultUserShouldBeAdult() {
    User user = new User(20);

    boolean result = user.isAdult();

    assertTrue(result);
}

也可以:

assertTrue(age >= 18);

3.5 assertFalse

要求:

条件必须为 false

例如:

assertFalse(user.isDeleted());

3.6 assertNull

判断对象必须为:

null

例如:

assertNull(repository.findById(-1));

3.7 assertNotNull

判断对象不能为:

null

例如:

User user = repository.findById(1);

assertNotNull(user);

3.8 assertSame 与 assertNotSame

assertSame() 判断的是:

两个引用是否指向同一个对象。

例如:

Object object = new Object();

assertSame(object, object);

注意它和:

assertEquals()

不是同一个概念。

可以粗略理解:

assertEquals → 比较“是否相等”
assertSame   → 比较“是不是同一个对象”

这与之前学习:

equals()
==
对象引用

的知识直接关联。


3.9 给断言添加失败信息

例如:

assertEquals(
        30,
        result,
        "10 + 20 的结果应该为 30"
);

当测试失败时,这段信息可以帮助定位问题。

不过不要写没有价值的信息:

assertEquals(30, result, "错了");

更好的消息应该说明:

哪条业务规则没有满足

3.10 assertThrows:测试异常

异常行为同样属于程序行为。

例如:

public class Calculator {

    public int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("除数不能为0");
        }

        return a / b;
    }
}

当:

divide(10, 0)

时,正确行为不是返回一个数字。

而应该:

抛出 IllegalArgumentException

因此测试应该写:

@Test
void divideByZeroShouldThrowException() {
    Calculator calculator = new Calculator();

    assertThrows(
            IllegalArgumentException.class,
            () -> calculator.divide(10, 0)
    );
}

这里:

IllegalArgumentException.class

表示期望异常类型。

Lambda:

() -> calculator.divide(10, 0)

表示需要执行并观察是否抛异常的代码。


3.11 进一步检查异常内容

assertThrows() 会返回捕获到的异常对象:

@Test
void divideByZeroShouldThrowException() {
    Calculator calculator = new Calculator();

    IllegalArgumentException exception =
            assertThrows(
                    IllegalArgumentException.class,
                    () -> calculator.divide(10, 0)
            );

    assertEquals("除数不能为0", exception.getMessage());
}

现在我们同时验证了:

异常类型正确
+
异常信息正确

3.12 assertAll

有时我们需要验证一个对象多个属性:

User user = service.getUser();

如果依次写:

assertEquals(1, user.getId());
assertEquals("LingXi", user.getName());
assertEquals(20, user.getAge());

第一条失败以后,后面的断言可能无法继续给出完整信息。

可以使用:

assertAll(
        () -> assertEquals(1, user.getId()),
        () -> assertEquals("LingXi", user.getName()),
        () -> assertEquals(20, user.getAge())
);

这样可以把多个相关断言组织在一起。

初学阶段不用滥用。

当:

多个断言共同描述同一个业务结果

时再考虑使用。


四、原理与进阶

4.1 测试不是“举几个例子”

假设:

public int divide(int a, int b)

只测试:

divide(10, 2)

是不够的。

至少应该思考三类数据:

正常值
边界值
异常值

4.2 正常值

正常业务输入:

divide(10, 2)

期望:

5

例如:

@Test
void divideNormalNumbers() {
    assertEquals(5, calculator.divide(10, 2));
}

4.3 边界值

边界值是:

位于业务规则边界附近的数据。

假设:

boolean isAdult(int age)

判断:

age >= 18

那么:

17
18
19

非常值得测试。

因为 Bug 最容易出现在:

>
>=
<
<=

这些边界判断上。

例如:

@Test
void age17ShouldNotBeAdult() {
    assertFalse(userService.isAdult(17));
}

@Test
void age18ShouldBeAdult() {
    assertTrue(userService.isAdult(18));
}

其中:

18

就是关键边界。


4.4 异常值

例如:

divide(10, 0)

或者:

setAge(-1)

这些输入可能应该:

拒绝
抛异常
返回特殊结果

无论采用什么设计,都应该通过测试明确行为。


4.5 一个测试应该尽量只表达一个行为

不推荐:

@Test
void testEverything() {
    // 测登录
    // 测注册
    // 测删除
    // 测查询
    // 测密码修改
}

这种测试一旦失败:

到底哪个功能坏了?

不够清晰。

推荐:

loginWithCorrectPasswordShouldSucceed
loginWithWrongPasswordShouldFail
registerWithDuplicateUsernameShouldThrowException
deleteExistingUserShouldSucceed

一个测试关注一个明确行为。


五、实践应用

5.1 AAA 测试结构

一种非常实用的测试组织方式叫:

Arrange — Act — Assert

简称:

AAA

Arrange:准备

准备对象和测试数据:

Calculator calculator = new Calculator();

Act:执行

调用被测试代码:

int result = calculator.add(10, 20);

Assert:验证

判断结果:

assertEquals(30, result);

组合起来:

@Test
void addTwoNumbersShouldReturnSum() {
    // Arrange
    Calculator calculator = new Calculator();

    // Act
    int result = calculator.add(10, 20);

    // Assert
    assertEquals(30, result);
}

对于较短测试可以不写 AAA 注释,但思维结构应该保持。


5.2 Given-When-Then

另一种表达方式:

Given
When
Then

它和 AAA 本质非常接近:

| AAA | Given-When-Then | | ------- | --------------- | | Arrange | Given | | Act | When | | Assert | Then |

例如:

Given:用户年龄为 18
When :判断是否成年
Then :应该返回 true

这会迫使你思考:

我到底正在验证哪条业务规则?


5.3 测试方法如何命名

差:

@Test
void test1() {
}

稍好:

@Test
void testLogin() {
}

更清晰:

@Test
void loginWithCorrectPasswordShouldSucceed() {
}

或者:

@Test
void shouldReturnTrueWhenAgeIs18() {
}

测试名最好可以直接告诉阅读者:

什么条件
+
什么行为
+
什么结果

5.4 测试应该可以重复运行

如果今天运行:

PASS

明天什么都没有改变,再运行却:

FAIL

这种测试价值会大幅下降。

高质量测试应该尽可能:

确定
稳定
可重复

因此应该谨慎依赖:

  • 当前时间
  • 随机数据
  • 网络状态
  • 外部服务
  • 其他测试留下的数据
  • 测试执行顺序

5.5 测试之间不要相互依赖

错误设计:

test1 创建用户
      ↓
test2 必须依赖 test1 创建的用户
      ↓
test3 再删除这个用户

一旦只运行:

test2

它可能立即失败。

测试应该尽可能做到:

任何一个测试都可以单独运行

5.6 一个完整示例

业务类:

public class StringUtil {

    public static int getLastIndex(String text) {
        if (text == null) {
            throw new IllegalArgumentException("text不能为null");
        }

        if (text.isEmpty()) {
            return -1;
        }

        return text.length() - 1;
    }
}

测试:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class StringUtilTest {

    @Test
    void normalStringShouldReturnLastIndex() {
        int result = StringUtil.getLastIndex("abcdef");

        assertEquals(5, result);
    }

    @Test
    void singleCharacterShouldReturnZero() {
        int result = StringUtil.getLastIndex("a");

        assertEquals(0, result);
    }

    @Test
    void emptyStringShouldReturnMinusOne() {
        int result = StringUtil.getLastIndex("");

        assertEquals(-1, result);
    }

    @Test
    void nullShouldThrowException() {
        IllegalArgumentException exception =
                assertThrows(
                        IllegalArgumentException.class,
                        () -> StringUtil.getLastIndex(null)
                );

        assertEquals("text不能为null", exception.getMessage());
    }
}

现在测试覆盖了:

普通值
边界值
特殊值
异常值

比单纯写:

System.out.println(StringUtil.getLastIndex("abcdef"));

可靠得多。


六、常见问题

6.1 assertEquals 的两个参数谁在前?

推荐:

assertEquals(expected, actual);

即:

期望值在前
实际值在后

例如:

assertEquals(6, result);

而不是:

assertEquals(result, 6);

虽然很多情况下结果一样,但前者能让失败报告语义更加自然。


6.2 System.out.println 能不能代替断言?

不能。

System.out.println(result);

只是:

展示结果

而:

assertEquals(expected, result);

才是:

验证结果

这是人工测试与自动化测试之间的关键区别。


6.3 异常出现是不是一定说明测试失败?

不是。

如果需求规定:

非法参数必须抛 IllegalArgumentException

那么:

assertThrows(
    IllegalArgumentException.class,
    () -> method()
);

成功捕获预期异常意味着:

测试通过

所以:

异常本身不等于错误。

关键在于它是不是预期行为。


6.4 是否应该一个测试写几十个 assert?

通常不推荐。

如果几十个断言描述的是完全不同业务规则,应该拆分测试。

测试失败以后,我们应该能够快速知道:

哪条行为规则被破坏了

6.5 测试覆盖率越高是不是项目质量一定越高?

不是。

例如你可以让很多代码:

被测试执行过

却没有真正验证正确结果。

所以:

测试覆盖率高

不等于:

测试质量高

真正重要的是:

测试是否覆盖了关键行为、边界和错误场景。


6.6 浮点数能直接 assertEquals 吗?

浮点计算可能存在精度误差。

例如:

0.1 + 0.2

并不一定精确等于:

0.3

对于需要允许误差的浮点结果,可以指定:

assertEquals(expected, actual, delta);

例如:

assertEquals(0.3, 0.1 + 0.2, 0.000001);

其中:

delta

表示允许误差。

而涉及金额等精确十进制业务时,更应该首先考虑之前学习过的:

BigDecimal

七、练习与验收

7.1 知识问答

  1. 什么是断言?
  2. 为什么打印测试结果不能称为完整自动化测试?
  3. assertEquals() 的两个核心参数分别是什么?
  4. assertTrue()assertFalse() 分别适合什么情况?
  5. assertNull()assertNotNull() 有什么区别?
  6. assertSame()assertEquals() 有什么区别?
  7. assertThrows() 用于验证什么?
  8. 什么是正常值、边界值、异常值?
  9. 什么是 AAA 测试结构?
  10. 为什么测试之间不应该存在执行顺序依赖?

7.2 代码阅读

阅读:

@Test
void divideByZero() {
    IllegalArgumentException exception =
            assertThrows(
                    IllegalArgumentException.class,
                    () -> calculator.divide(10, 0)
            );

    assertEquals("除数不能为0", exception.getMessage());
}

回答:

  1. 这个测试验证了几个条件?
  2. 为什么出现异常以后测试反而可能 PASS?
  3. assertThrows() 的返回值是什么?
  4. Lambda 中的代码什么时候执行?
  5. 如果实际抛出 NullPointerException,结果如何?

7.3 手写代码

为:

public static boolean isAdult(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("年龄不能小于0");
    }

    return age >= 18;
}

设计测试。

至少覆盖:

17
18
19
0
-1

要求:

  • 使用 assertTrue
  • 使用 assertFalse
  • 使用 assertThrows

7.4 Debug

找出下面测试的问题:

@Test
void testAge() {
    assertTrue(isAdult(30));
    assertTrue(isAdult(20));
    assertFalse(isAdult(15));
    assertFalse(isAdult(10));
    assertThrows(
            IllegalArgumentException.class,
            () -> isAdult(-1)
    );
}

思考:

  1. 语法是否一定有问题?
  2. 测试设计是否清晰?
  3. 如果第三个断言失败,你能否快速从测试名判断失败的业务规则?
  4. 是否值得拆成多个测试方法?
  5. 应该如何命名?

7.5 综合训练

实现一个简单业务方法:

public class PasswordValidator {

    public static boolean isValid(String password) {
        // 自行完成
        return false;
    }
}

规定:

password == null → 抛 IllegalArgumentException
长度 < 8         → false
长度 >= 8        → true

要求设计完整测试:

null
""
"1234567"
"12345678"
"123456789"

先写:

测试场景表

再写代码。

不要一上来就敲测试。


7.6 本章验收

不查看资料完成以下任务:

  • 手写 assertEquals()assertTrue()assertFalse()assertThrows() 示例。
  • 解释 Expected 与 Actual。
  • 给一个具有边界条件的方法设计至少 5 个测试数据。
  • 使用 AAA 描述一个完整测试。
  • 判断一个测试是否存在其他测试依赖。
  • 为异常行为编写测试。
  • 能够解释为什么“测试运行了”不等于“测试有效”。
  • 能够把一个杂乱的大测试拆分成多个职责清晰的小测试。