JUnit 单元测试实战

本文介绍 JUnit 单元测试在 Java 项目中的实战用法,覆盖测试入门、断言、常用注解、测试命名规范、AAA 结构、AI 辅助生成测试和 Maven 测试依赖范围等内容。文章帮助读者建立可维护的测试习惯,让业务代码在修改、重构和扩展时更可靠。

第一章 单元测试概述

1.1 什么是单元测试

单元测试就是针对程序中最小可测试单元编写的测试代码。在 Java 中,最常见的测试单元是一个类中的一个方法。

比如有一个加法方法,就写测试验证:

  • 正数相加是否正确。
  • 负数相加是否正确。
  • 零参与计算是否正确。
  • 异常输入是否按预期处理。

1.2 为什么要写单元测试

单元测试的价值主要有四点:

价值说明
及时发现问题修改代码后能快速知道哪里坏了
保护重构重构前后测试都通过,说明行为大概率没变
沉淀业务规则测试用例能表达方法应该如何工作
提升协作效率别人改代码时能用测试验证影响范围

单元测试不是为了“多写点代码”,而是为了让项目在持续变化中仍然可靠。

1.3 JUnit 版本说明

当前 Java 项目常用 JUnit 5。它的常见包名是:

Java
org.junit.jupiter.api.Test
org.junit.jupiter.api.Assertions

如果看到 org.junit.Test,通常是 JUnit 4 的写法。


第二章 单元测试入门

2.1 添加测试依赖

普通 Maven 项目可以引入 JUnit 5:

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

Spring Boot 项目通常使用:

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

它通常包含 JUnit、AssertJ、Mockito、Spring Test 等测试常用能力。

2.2 被测试类

Java
public class Calculator {
    /**
     * 计算两个整数之和。
     *
     * @param a 第一个整数
     * @param b 第二个整数
     * @return 两个整数的和
     */
    public int add(int a, int b) {
        return a + b;
    }

    /**
     * 计算两个整数相除的结果。
     *
     * @param a 被除数
     * @param b 除数
     * @return 相除结果
     */
    public int divide(int a, int b) {
        return a / b;
    }
}

2.3 测试类

测试类放在 src/test/java 下:

Java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class CalculatorTest {
    @Test
    public void shouldReturnSumWhenAddTwoNumbers() {
        Calculator calculator = new Calculator();

        assertEquals(5, calculator.add(2, 3));
    }

    @Test
    public void shouldThrowExceptionWhenDivideByZero() {
        Calculator calculator = new Calculator();

        assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0));
    }
}

测试方法名建议表达清楚“什么场景,期望什么结果”。这样测试失败时,只看方法名就能大概知道业务哪里出问题。

2.4 运行测试

可以在 IDEA 中点击测试方法左侧的运行按钮,也可以使用 Maven 命令:

Bash
mvn test

如果测试通过,说明当前代码行为符合测试预期。如果测试失败,要先看失败信息,而不是急着改测试。


第三章 单元测试断言

3.1 断言是什么

断言就是“我期望结果应该是什么”。如果实际结果和期望不一致,测试就失败。

常用断言:

断言作用
assertEquals判断两个值相等
assertNotEquals判断两个值不相等
assertTrue判断条件为真
assertFalse判断条件为假
assertNull判断对象为空
assertNotNull判断对象不为空
assertThrows判断会抛出指定异常
assertAll一组断言全部执行

3.2 断言示例

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

public class AssertionDemoTest {
    @Test
    public void shouldVerifyCommonAssertions() {
        String username = "admin";
        Integer age = 18;

        assertNotNull(username);
        assertEquals("admin", username);
        assertTrue(age >= 18);
        assertThrows(NumberFormatException.class, () -> Integer.parseInt("abc"));
    }
}

3.3 多断言 assertAll

Java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class UserProfileTest {
    @Test
    public void shouldVerifyUserProfile() {
        UserProfile profile = new UserProfile("zhangsan", 20);

        assertAll(
                () -> assertEquals("zhangsan", profile.getUsername()),
                () -> assertEquals(20, profile.getAge())
        );
    }
}

assertAll 的好处是多个断言会一起执行,能一次看到多个失败点。


第四章 单元测试常见注解

4.1 常用注解表

注解作用
@Test标记测试方法
@BeforeEach每个测试方法执行前运行
@AfterEach每个测试方法执行后运行
@BeforeAll所有测试方法执行前运行一次
@AfterAll所有测试方法执行后运行一次
@DisplayName给测试类或方法设置展示名称
@Disabled临时禁用测试

4.2 注解执行顺序

Java
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class UserServiceTest {
    private UserService userService;

    @BeforeEach
    public void setUp() {
        userService = new UserService();
    }

    @AfterEach
    public void tearDown() {
        userService = null;
    }

    @Test
    public void shouldCreateUserSuccessfully() {
        boolean result = userService.createUser("lisi");

        assert result;
    }
}

@BeforeEach 适合准备测试对象、初始化测试数据;@AfterEach 适合清理资源。

4.3 BeforeAll 和 AfterAll

Java
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

public class DatabaseConfigTest {
    @BeforeAll
    public static void initOnce() {
        System.out.println("所有测试开始前执行一次");
    }

    @AfterAll
    public static void closeOnce() {
        System.out.println("所有测试结束后执行一次");
    }

    @Test
    public void shouldLoadConfig() {
        System.out.println("执行测试");
    }
}

默认情况下,@BeforeAll@AfterAll 方法需要是 static

4.4 DisplayName 和 Disabled

@DisplayName 可以让测试报告更易读,@Disabled 可以临时禁用测试。

Java
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class OrderServiceTest {
    @Test
    @DisplayName("库存充足时应创建订单成功")
    public void shouldCreateOrderWhenStockEnough() {
    }

    @Test
    @Disabled("等待优惠券规则确认后再补充")
    public void shouldCalculateCouponDiscount() {
    }
}

被禁用的测试要写清楚原因,避免长期遗忘。


第五章 企业开发单元测试规范

5.1 测试类命名

常见命名规范:

类型示例
被测试类UserService
测试类UserServiceTest
测试目录src/test/java

测试类包名通常和被测试类保持一致,方便快速定位。

5.2 测试方法命名

推荐方法名表达清楚业务语义:

Java
@Test
public void shouldReturnUserWhenUserExists() {
}

@Test
public void shouldThrowExceptionWhenUsernameIsBlank() {
}

不要只写 test1testAdd 这种信息量很少的名字。项目越大,清晰命名越重要。

5.3 AAA 测试结构

企业开发中常用 AAA 结构:

阶段说明
Arrange准备数据和对象
Act调用被测试方法
Assert验证结果

示例:

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

public class PriceServiceTest {
    @Test
    public void shouldCalculateDiscountPrice() {
        PriceService priceService = new PriceService();
        int originalPrice = 100;

        int actualPrice = priceService.discount(originalPrice, 20);

        assertEquals(80, actualPrice);
    }
}

5.4 单元测试应该测什么

重点测试:

  • 有分支判断的方法。
  • 有边界值的方法。
  • 有金额、库存、状态流转等关键业务的方法。
  • 曾经出现过 bug 的方法。
  • 公共工具类和复杂转换逻辑。

不建议为了覆盖率机械测试 getter、setter,也不建议把单元测试写成依赖真实数据库、真实网络接口的大集成测试。


第六章 基于 AI 辅助生成单元测试

6.1 AI 生成测试的正确姿势

AI 可以帮助我们更快生成测试用例,但不能完全替代开发者判断。你应该把被测试方法、业务规则、边界条件告诉 AI,然后检查生成结果是否符合真实业务。

适合让 AI 辅助生成:

  • 简单工具类测试。
  • 参数校验测试。
  • 边界值测试。
  • 异常场景测试。
  • 重复结构较多的测试。

6.2 示例:给业务方法生成测试思路

被测试方法:

Java
public class PasswordService {
    /**
     * 判断密码是否符合基础规则。
     *
     * @param password 密码
     * @return 符合规则返回 true,否则返回 false
     */
    public boolean isValid(String password) {
        if (password == null || password.length() < 8) {
            return false;
        }

        // 关键业务逻辑:密码必须同时包含字母和数字,避免过于简单的纯数字或纯字母密码。
        return password.matches(".*[A-Za-z].*") && password.matches(".*\\d.*");
    }
}

可以要求 AI 生成这些测试场景:

场景期望
nullfalse
长度小于 8false
只有字母false
只有数字false
同时包含字母和数字true

生成后要人工检查:测试是否真的覆盖业务规则,断言是否写反,方法名是否清晰。


第七章 单元测试和 Maven 依赖范围

7.1 为什么测试依赖要用 test

测试框架只在测试阶段使用,不应该进入正式运行环境。所以 JUnit、Mockito 等依赖应该使用 test 范围。

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

这样做的好处:

  • 正式包更干净。
  • 减少无关依赖。
  • 避免运行环境出现测试框架类。

7.2 Maven 生命周期中的测试阶段

Maven 执行 testpackageinstall 时都会涉及测试阶段。常用命令:

Bash
mvn test
mvn package
mvn install

如果测试失败,后续打包通常会中断。这是 Maven 在保护构建质量。

7.3 临时跳过测试

如果临时只想打包,不运行测试:

Bash
mvn package -DskipTests

如果连测试代码也不编译:

Bash
mvn package -Dmaven.test.skip=true

长期建议还是修复测试。测试失败不是 Maven 的问题,而是构建过程提醒你代码行为不符合预期。


第八章 常见问题

8.1 测试方法没有运行

常见原因:

  • 没有添加 @Test
  • 导入了错误的 JUnit 包。
  • 测试类没有放在 src/test/java
  • Maven 测试插件版本过旧。

JUnit 5 常用导入:

Java
import org.junit.jupiter.api.Test;

8.2 断言写反

assertEquals(expected, actual) 中,通常第一个参数写期望值,第二个参数写实际结果。

Java
assertEquals(100, actualPrice);

虽然写反也能比较,但失败信息会变得不好读。

8.3 单元测试依赖真实环境

单元测试应尽量少依赖真实数据库、真实网络、真实文件系统。依赖太多时,测试会变慢、变脆弱,也更难定位问题。

如果必须测试数据库或接口,通常更接近集成测试,需要单独规划。


总结

单元测试是保护业务代码的一层安全网。它最核心的价值不是追求覆盖率数字,而是把关键业务规则用代码固定下来。

你需要重点掌握:

  • JUnit 5 的基本使用方式。
  • @Test@BeforeEach@AfterEach@BeforeAll@AfterAll 等常见注解。
  • assertEqualsassertTrueassertThrowsassertAll 等常见断言。
  • 测试类和测试方法的命名规范。
  • AAA 测试结构。
  • Maven 中测试依赖的 test 范围。

当你能为关键业务方法稳定写出单元测试时,后续重构、改需求、排查 bug 都会轻松很多。