JUnit 单元测试基础 | JavaSE

JUnit 单元测试基础

一、学习目标

学完本章,你应该能够:

  • 解释什么是单元测试(Unit Testing),以及“单元”通常指什么。
  • 说明只使用 main() 方法人工测试代码存在的问题。
  • 理解 JUnit、JUnit Platform、JUnit Jupiter 之间的基本关系。
  • 在 Java 项目中正确引入 JUnit。
  • 使用 @Test 编写并运行一个基本测试方法。
  • 理解测试代码与业务代码为什么应该分离。
  • 能够独立为一个简单业务方法创建对应的测试类。

二、核心知识

2.1 什么是软件测试

我们编写程序以后,一个非常重要的问题是:

怎么证明程序真的按照我们的预期工作?

例如有这样一个方法:

public static int add(int a, int b) {
    return a + b;
}

代码看起来非常简单。

但真正开发一个系统的时候,一个方法可能包含:

  • 参数校验
  • 条件判断
  • 集合处理
  • 数据计算
  • 异常处理
  • 数据库调用
  • 网络调用

如果修改了一行代码,我们怎么知道有没有破坏之前已经正常工作的功能?

这就是软件测试需要解决的问题。


2.2 什么是单元测试

单元测试(Unit Testing)是:

对程序中的一个较小、相对独立的功能单元进行自动化验证。

在 Java 入门阶段,这个“单元”通常可以理解为:

一个方法

例如:

public class Calculator {

    public int add(int a, int b) {
        return a + b;
    }
}

我们希望验证:

add(1, 2) → 3
add(0, 0) → 0
add(-1, 1) → 0

这些验证就是针对 add() 方法进行的单元测试。

不过在真实工程中,“单元”并没有绝对固定的定义,也可能是:

  • 一个类
  • 一个组件中的局部逻辑
  • 一个具有明确职责的业务对象

本阶段先把它理解成:

测试一个方法的行为是否符合预期。


2.3 以前我们是怎么测试代码的

在学习 Java 基础时,我们经常这样测试:

public class Calculator {

    public static int add(int a, int b) {
        return a + b;
    }

    public static void main(String[] args) {
        int result = add(10, 20);
        System.out.println(result);
    }
}

运行程序后:

30

然后程序员自己观察:

嗯,30,应该对了。

这种方式当然可以使用,但它存在明显问题。


2.4 使用 main 方法测试的问题

问题一:需要人工观察结果

例如:

System.out.println(add(10, 20));

输出:

30

程序本身并不知道:

30 到底是不是正确答案

需要程序员自己判断。


问题二:测试代码污染业务代码

原本:

Calculator

应该负责计算。

结果里面出现大量:

main()
System.out.println()
测试数据

业务逻辑和测试逻辑混在了一起。


问题三:难以批量执行

假设现在有:

UserService
OrderService
PaymentService
FileUtil
StringUtil
Calculator

总共几百个方法。

如果全部依靠:

main()

逐个测试,效率会非常低。


问题四:修改代码以后无法快速回归

假设今天:

100 个方法全部正常

明天修改其中一个公共方法。

这个修改可能影响几十个地方。

如果没有自动化测试,就很难快速判断:

原来的功能有没有被改坏?


问题五:一个异常可能中断后续测试

例如:

public static void main(String[] args) {
    testA();
    testB();
    testC();
}

如果:

testA()

直接抛出异常,后面的测试可能根本没有执行。


2.5 JUnit 是什么

JUnit 是 Java 生态中最重要的自动化测试框架之一。

它允许我们写这样的代码:

@Test
void addShouldReturn30() {
    int result = Calculator.add(10, 20);

    assertEquals(30, result);
}

JUnit 会自动判断:

期望:30
实际:30

如果相同:

PASS

如果不同:

FAIL

于是:

测试是否成功不再依赖程序员盯着控制台。


2.6 使用 JUnit 的核心优势

可以概括为:

JUnit
│
├── 自动执行测试
├── 自动判断测试结果
├── 可以单独运行某个测试
├── 可以一次运行全部测试
├── 测试之间可以保持相对独立
├── IDE / Maven 可以生成测试结果
└── 可以不断重复运行

这也是自动化测试相比人工测试最大的价值。


2.7 JUnit 的现代体系

现代 JUnit 可以简单理解为三个重要部分:

JUnit
│
├── JUnit Platform
│      ↓
│   提供测试发现和执行的基础平台
│
├── JUnit Jupiter
│      ↓
│   编写现代 JUnit 测试使用的 API 与测试引擎
│
└── JUnit Vintage
       ↓
    主要用于兼容旧的 JUnit 3 / JUnit 4 测试

对于我们现在学习 Java:

主要学习 JUnit Jupiter。

我们经常使用的:

@Test
@BeforeEach
@AfterEach
Assertions.assertEquals(...)

都属于 JUnit Jupiter。


三、使用方法

3.1 Maven 项目目录结构

一个典型 Maven 项目:

project
├── pom.xml
└── src
    ├── main
    │   └── java
    │       └── com.example
    │           └── Calculator.java
    │
    └── test
        └── java
            └── com.example
                └── CalculatorTest.java

其中:

src/main/java

存放业务代码。

而:

src/test/java

专门存放测试代码。

这是非常重要的工程结构:

业务代码 ≠ 测试代码

3.2 引入 JUnit

JUnit 并不是 JDK 自带 API。

在 Maven 项目中,需要添加测试依赖。

例如:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.1.3</version>
    <scope>test</scope>
</dependency>

这里尤其注意:

<scope>test</scope>

表示这个依赖主要用于:

测试代码的编译和运行

而不会作为正常业务运行依赖使用。


3.3 准备一个业务类

例如:

package com.example;

public class Calculator {

    public int add(int a, int b) {
        return a + b;
    }
}

我们的目标是测试:

add()

3.4 创建测试类

在:

src/test/java

创建:

CalculatorTest

代码:

package com.example;

import org.junit.jupiter.api.Test;

class CalculatorTest {

    @Test
    void add() {

    }
}

注意:

@Test

表示:

这个方法是一个测试方法。

JUnit 测试引擎会发现它,并把它作为测试执行。


3.5 编写第一个测试

完整代码:

package com.example;

import org.junit.jupiter.api.Test;

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

class CalculatorTest {

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

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

        assertEquals(30, result);
    }
}

其中最关键的是:

assertEquals(30, result);

意思是:

我期望结果是 30

JUnit 会比较:

Expected:30
Actual:result

如果:

result == 30

测试通过。


3.6 故意制造一个错误

把业务代码改成:

public int add(int a, int b) {
    return a - b;
}

再次运行:

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

实际结果:

-10

JUnit 就会报告:

Expected :30
Actual   :-10

这就是自动化测试最核心的思想:

程序自动验证程序。


3.7 如何运行测试

在 IDEA 中通常可以:

方法级运行

点击:

@Test 方法旁边的运行按钮

只运行当前测试。


类级运行

运行:

CalculatorTest

可以执行整个测试类。


整个项目运行

可以执行项目中的全部测试。

Maven 项目还可以使用:

mvn test

Maven 会发现测试代码并执行。


3.8 一个业务方法可以有多个测试

错误示范:

@Test
void add() {
    assertEquals(3, calculator.add(1, 2));
}

只验证了一种情况。

更合理的是:

@Test
void addPositiveNumbers() {
    assertEquals(30, calculator.add(10, 20));
}

@Test
void addZero() {
    assertEquals(10, calculator.add(10, 0));
}

@Test
void addNegativeNumbers() {
    assertEquals(-30, calculator.add(-10, -20));
}

因为一个方法往往需要验证多个输入场景。


3.9 测试方法的基本要求

现代 JUnit Jupiter 中,普通测试方法通常写成:

@Test
void testSomething() {

}

一般无需写:

public

测试类也通常不需要:

public

例如:

class CalculatorTest {

    @Test
    void add() {
    }
}

测试方法:

  • 不能是 private
  • 普通 @Test 方法不能返回业务结果
  • 一般写成实例方法
  • 可以在特定扩展机制下接收由 JUnit 解析的参数

因此初学阶段推荐统一采用:

@Test
void methodName() {

}

这是最清晰的形式。


四、原理与进阶

4.1 @Test 到底做了什么

@Test 本质上是一个注解(Annotation)。

例如:

@Test
void add() {
}

JUnit 在运行测试时会:

扫描测试类
      ↓
发现 @Test
      ↓
确定测试方法
      ↓
创建测试实例
      ↓
调用测试方法
      ↓
记录执行结果

你可能已经发现:

JUnit 怎么能够“找到一个类中的方法并自动调用”?

这背后会涉及后面马上学习的:

注解 + 反射

所以这里实际上已经开始为:

反射
注解
框架思想

做铺垫。


4.2 为什么 JUnit 可以运行而不需要 main

普通 Java 程序:

main()
↓
程序入口

JUnit 测试并不是通过你自己编写的:

public static void main(String[] args)

启动。

而是:

IDE / Maven
    ↓
JUnit Platform
    ↓
测试引擎
    ↓
发现 @Test
    ↓
执行测试方法

因此:

测试框架本身充当了程序执行的控制者。

这是一种非常典型的框架思想。

过去:

你调用框架/API

现在:

框架调用你的代码

后续学习 Spring 时你会频繁看到类似思想。


4.3 测试代码也是代码

很多初学者容易产生一个误区:

“测试代码无所谓,反正不是正式代码。”

实际上大型项目中:

测试代码

也是工程代码的一部分。

它同样需要:

  • 清晰命名
  • 合理结构
  • 易于维护
  • 可重复运行
  • 尽量稳定
  • 避免无意义重复

高质量项目经常包含大量测试代码。


五、实践应用

5.1 为什么修改旧代码前最好有测试

假设:

public double calculatePrice(...) {
    ...
}

已经在线上运行一年。

现在你准备重构它。

最危险的问题并不是:

新代码能不能运行

而是:

以前正确的功能有没有被你改坏

如果存在完整测试:

修改代码
   ↓
运行全部测试
   ↓
全部通过

你就获得了一层重要保障。

这叫:

回归测试(Regression Testing)


5.2 单元测试与 Debug 的区别

两者不是同一个概念。

Debug

主要解决:

程序为什么错?

例如使用断点:

变量现在是什么值?
程序走到了哪个分支?
异常在哪里出现?

Unit Test

主要解决:

程序现在对不对?以后改完以后还对不对?

可以这样理解:

JUnit → 自动发现问题

Debug → 人工定位问题

两者通常配合使用。


5.3 单元测试与集成测试

单元测试通常关注:

一个较小功能单元

例如:

calculatePrice()

集成测试(Integration Testing)关注多个组件协作,例如:

Controller
   ↓
Service
   ↓
Mapper
   ↓
Database

整个调用链是否正确。

所以:

单元测试 → 局部正确性
集成测试 → 组件协作正确性

六、常见问题

6.1 JUnit 是 JDK 自带的吗?

不是。

JDK 21 提供 Java 标准平台。

JUnit 是独立测试框架,需要通过:

Maven
Gradle
IDE

引入项目。


6.2 为什么 IDEA 可以直接提示添加 JUnit?

IDEA 可以帮助:

添加测试框架依赖
生成测试类
运行测试
展示测试结果

但这不意味着:

JUnit 属于 JDK。


6.3 测试通过是不是说明程序绝对没有 Bug?

不是。

测试只能说明:

你设计并执行的这些测试场景通过了。

假设只测试:

add(1, 2)

并不能证明:

所有输入都正确

所以后面需要学习:

正常值
边界值
异常值

等测试设计方法。


6.4 测试方法一定必须 public 吗?

现代 JUnit Jupiter:

不需要。

通常推荐:

class CalculatorTest {

    @Test
    void add() {
    }
}

即可。

不要机械套用旧 JUnit 教程中的:

public void testXXX()

规则。


6.5 一个测试失败会导致其他测试都失败吗?

正常情况下:

一个测试 FAIL

不会自动意味着其他测试也是 FAIL。

JUnit 会分别记录测试结果。

但是如果你的测试之间共享了错误的全局状态,就可能出现:

A 测试影响 B 测试

因此测试之间应该尽可能保持独立。


6.6 测试到底应该输出 System.out.println 吗?

可以用于临时调试。

但是:

System.out.println(result);

不能代替:

assertEquals(expected, actual);

测试是否正确应该由:

断言

自动判断,而不是依赖人眼观察。


七、练习与验收

7.1 知识问答

  1. 什么是单元测试?“单元”通常表示什么粒度?
  2. 为什么只在 main() 方法中测试代码不适合大型项目?
  3. JUnit 能解决传统人工测试中的哪些问题?
  4. @Test 的作用是什么?
  5. 为什么测试代码通常放在 src/test/java
  6. JUnit 是不是 JDK 21 的组成部分?
  7. 为什么测试方法应该尽可能相互独立?
  8. 单元测试和 Debug 有什么区别?
  9. 单元测试和集成测试有什么区别?
  10. 为什么修改旧代码时自动化测试尤其重要?

7.2 代码阅读

阅读代码:

class CalculatorTest {

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

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

        assertEquals(30, result);
    }
}

回答:

  1. @Test 的作用是什么?
  2. 哪一行真正调用了业务代码?
  3. 哪一行负责判断结果是否正确?
  4. 如果 result29,测试结果是什么?
  5. 是否需要自己写 main() 方法?

7.3 手写代码

定义:

public class MathUtil {

    public static int max(int a, int b) {
        return Math.max(a, b);
    }
}

要求:

  1. 创建 MathUtilTest
  2. 编写至少三个 @Test 方法。
  3. 分别测试:
    • 第一个数较大
    • 第二个数较大
    • 两个数相等

7.4 Debug

下面代码存在测试设计问题:

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

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

    System.out.println(result);
}

回答:

  1. 代码能否运行?
  2. 它能否自动判断结果是否正确?
  3. 问题的根本原因是什么?
  4. 应该使用什么机制修改?

7.5 综合训练

编写:

public class StringUtil {

    public static int getLastIndex(String text) {
        if (text == null || text.isEmpty()) {
            return -1;
        }
        return text.length() - 1;
    }
}

为它编写 JUnit 测试,至少考虑:

普通字符串
单字符字符串
空字符串
null

暂时不要只追求测试数量。

重点思考:

每个测试究竟在验证什么业务规则?


7.6 本章验收

你应该能够在不查看资料的情况下完成:

  • 口述什么是单元测试。
  • 说出使用 main() 手工测试的至少三个问题。
  • 解释 JUnit 的核心价值。
  • 手写一个 @Test 测试方法。
  • 在 Maven 项目中指出业务代码与测试代码分别放在哪里。
  • 使用 IDEA 或 mvn test 执行测试。
  • 解释为什么打印结果不能代替断言。