【Maven 构建工具】Maven + JUnit5 单元测试实战:测试级别、注解、断言与 Maven test 阶段

掌握JUnit单元测试框架的使用,提高代码质量和开发效率

⚡ 快速参考

  • 测试级别:单元测试、集成测试、系统测试、验收测试
  • JUnit 5:最新版本的Java测试框架
  • 常用注解:@Test、@BeforeEach、@AfterEach、@BeforeAll、@AfterAll
  • 断言方法:assertEquals、assertTrue、assertThrows等
  • 测试作用域:JUnit依赖使用<scope>test</scope>
  • Maven test阶段:自动运行所有符合命名规范的测试类

📚 学习目标

  • 理解单元测试的概念和价值
  • 掌握JUnit 5的基本使用方法
  • 学会使用断言验证测试结果
  • 掌握JUnit常用注解的使用
  • 理解Maven生命周期中的test阶段
  • 掌握测试覆盖率的概念和查看方法

一、测试介绍

1.1 测试级别

软件测试按照测试的粒度和目的,通常分为以下几个级别:

1.1.1 单元测试(Unit Testing)
  • 介绍:对软件的基本组成单位进行测试,最小测试单位(通常是方法或函数)
  • 目的:检验软件基本组成单位的正确性
  • 测试人员:开发人员
  • 特点:测试速度快、隔离性强、容易定位问题
1.1.2 集成测试(Integration Testing)
  • 介绍:将已分别通过测试的单元,按设计要求组合成系统或子系统,再进行的测试
  • 目的:检查单元之间的协作是否正确,接口是否正常
  • 测试人员:开发人员
  • 特点:测试模块间的交互,验证接口和数据流
1.1.3 系统测试(System Testing)
  • 介绍:对已经集成好的软件系统进行彻底的测试
  • 目的:验证软件系统的正确性、性能是否满足指定的要求
  • 测试人员:测试人员
  • 特点:从用户角度测试整个系统,验证功能和非功能需求
1.1.4 验收测试(Acceptance Testing)
  • 介绍:交付测试,是针对用户需求、业务流程进行的正式的测试
  • 目的:验证软件系统是否满足验收标准
  • 测试人员:客户/需求方
  • 特点:验证系统是否满足用户需求,是否可以进行交付
1.2 测试方法

**测试方法:**白盒测试、黑盒测试 及 灰盒测试。

1.2.1 白盒测试(White Box Testing)
  • 特点:清楚软件内部结构、代码逻辑
  • 用途:用于验证代码、逻辑正确性
  • 优点:能够发现代码内部错误,覆盖率高
  • 缺点:需要了解代码实现,测试成本高
  • 适用场景:单元测试、代码审查
1.2.2 黑盒测试(Black Box Testing)
  • 特点:不清楚软件内部结构、代码逻辑,只关注输入和输出
  • 用途:用于验证软件的功能、兼容性、验收测试等方面
  • 优点:不需要了解代码实现,从用户角度测试
  • 缺点:可能无法发现内部错误
  • 适用场景:功能测试、系统测试、验收测试
1.2.3 灰盒测试(Gray Box Testing)
  • 特点:结合了白盒测试和黑盒测试的特点,既关注软件的内部结构又考虑外部表现(功能)
  • 用途:在了解部分内部结构的基础上进行功能测试
  • 优点:兼顾内部逻辑和外部功能
  • 适用场景:集成测试、接口测试
1.3 测试金字塔

测试金字塔是一个测试策略模型,说明了不同级别测试的数量关系:

  • 单元测试:数量最多,执行最快,成本最低(底层)
  • 集成测试:数量中等,执行较快(中层)
  • 系统测试:数量较少,执行较慢,成本较高(顶层)

原则:优先进行单元测试,确保代码质量的基础

二、单元测试概述

2.1 什么是单元测试

单元测试:是对软件中最小可测试单元(通常是方法或函数)进行测试的过程。

单元测试是软件开发中的基础测试,旨在验证每个独立的代码单元是否按照预期工作。

2.2 单元测试的特点

单元测试的核心特点:

  1. 隔离性:每个单元测试应该独立运行,不依赖其他测试
  2. 快速性:单元测试执行速度快,可以频繁运行
  3. 可重复性:测试结果应该是可重复的,不依赖外部状态
  4. 自动化:可以自动化执行,集成到构建流程中
  5. 覆盖率:尽可能覆盖所有代码路径和边界情况
2.3 单元测试的价值

单元测试带来的价值:

  1. 早期发现问题:在开发阶段就能发现bug,降低修复成本
  2. 提高代码质量:编写测试的过程本身就是对代码的审查
  3. 支持重构:有测试保障,可以安全地重构代码
  4. 文档作用:测试代码本身就是如何使用代码的文档
  5. 增强信心:通过测试后,对代码功能更有信心
2.4 单元测试的原则
  1. FIRST原则

    • Fast(快速):测试应该快速执行
    • Independent(独立):测试之间应该相互独立
    • Repeatable(可重复):测试结果应该可重复
    • Self-validating(自验证):测试应该有明确的通过/失败判断
    • Timely(及时):测试应该在代码编写之前或同时编写
  2. AAA模式

    • Arrange(准备):准备测试数据和测试环境
    • Act(执行):执行被测试的方法
    • Assert(断言):验证执行结果是否符合预期

三、JUnit入门

3.1 JUnit简介

JUnit:最流行的Java测试框架之一,提供了一些功能,方便程序进行单元测试(JUnit是由JUnit团队开发的开源测试框架)。

JUnit的作用

  • 提供测试框架和断言方法
  • 支持测试运行和结果报告
  • 支持测试组织和执行
  • 集成到IDE和构建工具中

JUnit版本

  • JUnit 4 :经典版本,使用@Test等注解
  • JUnit 5:最新版本(推荐),由JUnit Platform、JUnit Jupiter和JUnit Vintage组成
  • 本文档使用JUnit 5(JUnit Jupiter)
3.2 为什么使用JUnit

在之前操作中,我们进行程序的测试,都是在main方法中进行测试。如下图所示:

通过main方法测试的问题:

  1. 测试代码与源代码未分开:测试代码混在源代码中,难以维护
  2. 一个方法测试失败,影响后面方法:一个测试失败,后续测试无法执行
  3. 无法自动化测试:无法集成到构建流程中,无法持续集成
  4. 无法得到测试报告:无法统计测试通过率、覆盖率等信息
  5. 测试管理困难:难以组织和管理大量测试用例

使用JUnit单元测试框架的优势:

  1. 测试代码与源代码分开 :测试代码放在src/test/java目录,便于维护
  2. 可自动化测试:可以集成到Maven、Gradle等构建工具中
  3. 自动分析测试结果:自动统计测试结果,产出测试报告
  4. 测试隔离:每个测试方法独立运行,互不影响
  5. 丰富的断言方法:提供多种断言方法,方便验证结果
  6. 生命周期支持 :提供@BeforeEach@AfterEach等注解,管理测试生命周期
3.2 入门程序

需求:使用JUnit,对UserService中的业务方法进行单元测试,测试其正确性。

  1. pom.xml中,引入JUnit的依赖。
xml 复制代码
<!--Junit单元测试依赖-->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.9.1</version>
    <scope>test</scope>
</dependency>
  1. 在test/java目录下,创建测试类,并编写对应的测试方法,并在方法上声明@Test注解。
java 复制代码
@Test
public void testGetAge(){
    Integer age = new UserService().getAge("110002200505091218");
    System.out.println(age);
}
  1. 运行单元测试 (测试通过:绿色;测试失败:红色)。
  • 测试通过显示绿色
  • 测试失败显示红色

注意:

  • 测试类的命名规范XxxxTest(如:UserServiceTest
  • 测试方法的命名规范public void testXxx()或使用@DisplayName注解自定义名称
  • 测试类的位置 :放在src/test/java目录下,包名与源代码包名相同
  • 测试方法的可见性 :可以是publicpackage-private(JUnit 5支持)

四、断言介绍

4.1 什么是断言

断言(Assertion):是单元测试中用来验证测试结果是否符合预期的方式。

JUnit提供了一些辅助方法,用来帮我们确定被测试的方法是否按照预期的效果正常工作,这种方式称为断言

断言的作用

  • 验证方法返回值是否正确
  • 验证方法是否抛出预期的异常
  • 验证对象状态是否符合预期
  • 验证条件是否为真或假
4.2 断言的使用

基本使用方式:

java 复制代码
// 断言可以判断业务逻辑是否正确
@Test
public void testGenderWithAssert(){
    UserService userService = new UserService();
    String gender = userService.getGender("100000200010011011");
    // 断言:验证返回值是否符合预期
    Assertions.assertEquals("男", gender, "性别获取有问题");
}

// 断言异常:验证方法是否抛出预期的异常
@Test
public void testGenderWithException(){
    UserService userService = new UserService();
    // 断言:验证方法是否抛出IllegalArgumentException异常
    Assertions.assertThrows(IllegalArgumentException.class, () -> {
        userService.getGender(null);  // 传入null应该抛出异常
    }, "应该抛出IllegalArgumentException异常");
}

注意:

  • 断言失败时,测试会失败并显示错误信息
  • 最后一个参数msg是可选的,用于自定义错误提示信息
  • 可以使用静态导入简化代码:import static org.junit.jupiter.api.Assertions.*;
4.3 常用断言方法

JUnit 5提供了丰富的断言方法,常用的断言方法如下:

断言方法 描述 使用示例
assertEquals(Object exp, Object act, String msg) 检查两个值是否相等,不相等就报错 assertEquals(5, result)
assertNotEquals(Object unexp, Object act, String msg) 检查两个值是否不相等,相等就报错 assertNotEquals(0, result)
assertNull(Object act, String msg) 检查对象是否为null,不为null就报错 assertNull(object)
assertNotNull(Object act, String msg) 检查对象是否不为null,为null就报错 assertNotNull(object)
assertTrue(boolean condition, String msg) 检查条件是否为true,不为true就报错 assertTrue(result > 0)
assertFalse(boolean condition, String msg) 检查条件是否为false,不为false就报错 assertFalse(result < 0)
assertSame(Object exp, Object act, String msg) 检查两个对象引用是否相等(==),不相等就报错 assertSame(obj1, obj2)
assertNotSame(Object exp, Object act, String msg) 检查两个对象引用是否不相等,相等就报错 assertNotSame(obj1, obj2)
assertThrows(Class<T> expectedType, Executable executable, String msg) 检查是否抛出指定类型的异常 assertThrows(IllegalArgumentException.class, () -> method())
assertDoesNotThrow(Executable executable, String msg) 检查是否不抛出异常 assertDoesNotThrow(() -> method())
assertArrayEquals(Object[] expected, Object[] actual, String msg) 检查两个数组是否相等 assertArrayEquals(expected, actual)
assertIterableEquals(Iterable<?> expected, Iterable<?> actual, String msg) 检查两个可迭代对象是否相等 assertIterableEquals(expected, actual)
assertAll(Executable... executables) 执行多个断言,即使部分失败也会执行所有断言 assertAll(() -> assertEquals(...), () -> assertTrue(...))

注意事项:

  • 上述方法形参中的最后一个参数msg表示错误提示信息,是可选的(有对应的重载方法)
  • 建议提供有意义的错误信息,方便定位问题
  • 可以使用静态导入:import static org.junit.jupiter.api.Assertions.*; 简化代码
  • assertSame比较的是对象引用(==),assertEquals比较的是对象内容(equals方法)

示例演示:

UserService

java 复制代码
package com.project;

import java.time.LocalDate;
import java.time.Period;
import java.time.format.DateTimeFormatter;

public class UserService {

    /**
     * 给定一个身份证号, 计算出该用户的年龄
     * @param idCard 身份证号
     */
    public Integer getAge(String idCard){
        if (idCard == null || idCard.length() != 18) {
            throw new IllegalArgumentException("无效的身份证号码");
        }
        String birthday = idCard.substring(6, 14);
        LocalDate parse = LocalDate.parse(birthday, DateTimeFormatter.ofPattern("yyyyMMdd"));
        return Period.between(parse, LocalDate.now()).getYears();
    }

    /**
     * 给定一个身份证号, 计算出该用户的性别
     * @param idCard 身份证号
     */
    public String getGender(String idCard){
        if (idCard == null || idCard.length() != 18) {
            throw new IllegalArgumentException("无效的身份证号码");
        }
        return Integer.parseInt(idCard.substring(16,17)) % 2 == 1 ? "男" : "女";
    }

}

UserServiceTest

java 复制代码
package com.project;

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

//测试类
public class UserServiceTest {

    @Test
    public void testGetAge(){
        UserService userService = new UserService();
        Integer age = userService.getAge("100000200010011011");
        System.out.println(age);
    }

    @Test
    public void testGetName(){
        UserService userService = new UserService();
        String gender = userService.getGender("100000200010011011");
        System.out.println(gender);
    }
    
        // 断言可以判断业务逻辑是否正确
    @Test
    public void testGenderWithAssert(){
        UserService userService = new UserService();
        String gender = userService.getGender("100000200010011011");
        // 断言
        Assertions.assertEquals("男", gender,"性别获取有问题");
    }

    @Test
    public void testGenderWithException(){
        UserService userService = new UserService();
        // 断言:验证传入null时是否抛出IllegalArgumentException异常
        Assertions.assertThrows(IllegalArgumentException.class, () -> {
            userService.getGender(null);  // 传入null应该抛出异常
        }, "传入null应该抛出IllegalArgumentException异常");
    }
}

五、常见注解

在JUnit中还提供了一些注解,还增强其功能,常见的注解有以下几个:

注解 说明 备注
@Test 测试类中的方法用它修饰才能成为测试方法,才能启动执行 单元测试
@BeforeEach 用来修饰一个实例方法,该方法会在每一个测试方法执行之前执行一次。 初始化资源(准备工作)
@AfterEach 用来修饰一个实例方法,该方法会在每一个测试方法执行之后执行一次。 释放资源(清理工作)
@BeforeAll 用来修饰一个静态方法,该方法会在所有测试方法之前只执行一次。 初始化资源(准备工作)
@AfterAll 用来修饰一个静态方法,该方法会在所有测试方法之后只执行一次。 释放资源(清理工作)
@ParameterizedTest 参数化测试的注解 (可以让单个测试运行多次,每次运行时仅参数不同) 用了该注解,就不需要@Test注解了
@ValueSource 参数化测试的参数来源,赋予测试方法参数 与参数化测试注解配合使用
@DisplayName 指定测试类、测试方法显示的名称 (默认为类名、方法名)

演示 @BeforeEach@AfterEach@BeforeAll@AfterAll, @ParameterizedTest @ValueSource@DisplayName 注解:

java 复制代码
package com.project;

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

/**
 * UserService 的单元测试类
 */
public class UserServiceTest {

    /**
     * 在所有测试方法执行前运行一次(整个测试类开始前)
     */
    @BeforeAll
    static void beforeAll() {
        System.out.println("before All");
    }

    /**
     * 在所有测试方法执行后运行一次(整个测试类结束后)
     */
    @AfterAll
    static void afterAll() {
        System.out.println("after All");
    }

    /**
     * 在每个测试方法执行前运行一次
     */
    @BeforeEach
    void beforeEach() {
        System.out.println("before Each");
    }

    /**
     * 在每个测试方法执行后运行一次
     */
    @AfterEach
    void afterEach() {
        System.out.println("after Each");
    }

    /**
     * 参数化测试:验证身份证号解析性别功能
     */
    @DisplayName("测试用户性别")
    @ParameterizedTest
    @ValueSource(strings = {
        "100000200010011011",
        "100000200010011031",
        "100000200010011051"
    })
    void testGetGender(String idCard) {
        UserService userService = new UserService();
        String gender = userService.getGender(idCard);
        Assertions.assertEquals("男", gender, "性别获取有问题");
    }
}

输出结果如下:

六、依赖范围(Scope)

此时仅可以在测试程序中应用

java 复制代码
<!-- junit依赖 -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.1</version>
        <!-- 依赖范围 -->
            <scope>test</scope>
        </dependency>

如果对Junit单元测试的依赖,设置了scope为 test,就代表,该依赖,只是在测试程序中可以使用,在主程序中是无法使用的。所以我们会看到如下现象:

如上图所示,给junit依赖通过scope标签指定依赖的作用范围。 那么这个依赖就只能作用在测试环境,其他环境下不能使用。

scope的取值常见的如下:

scope值 主程序 测试程序 打包(运行) 范例
compile(默认) Y Y Y log4j
test - Y - junit
provided Y Y - servlet-api
runtime - Y Y jdbc驱动

七、单元测试-企业开发规范

7.1 判断测试方法的覆盖率

7.2 只判断某个类之中的覆盖率


7.3 AI生成单元测试

IDea安装通义灵码AI插件

点击标志后,点击单元测试

java 复制代码
package com.project;

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

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

public class UserServiceAiTest {

    private UserService userService;
    @BeforeEach // 在每个测试方法执行前执行
    public void setUp() {
        userService = new UserService();
    }

    @Test
    public void getGender_ValidMaleIdCard_ReturnsMale() {
        String gender = userService.getGender("100000200010011011");
        assertEquals("男", gender, "性别获取错误,应为男性");
    }

    @Test
    public void getGender_ValidFemaleIdCard_ReturnsFemale() {
        String gender = userService.getGender("100000200010011022");
        assertEquals("女", gender, "性别获取错误,应为女性");
    }

    @Test
    public void getGender_NullIdCard_ThrowsException() {
        assertThrows(IllegalArgumentException.class, () -> {
            userService.getGender(null);
        }, "无效的身份证号码");
    }

    @Test
    public void getGender_InvalidLengthIdCard_ThrowsException() {
        assertThrows(IllegalArgumentException.class, () -> {
            userService.getGender("10000020001001101");
        }, "无效的身份证号码");
    }

    @ParameterizedTest
    @ValueSource(strings = {"100000200010011011", "100000200010011031", "100000200010011051"})
    public void getGender_MultipleMaleIdCards_ReturnsMale(String idCard) {
        String gender = userService.getGender(idCard);
        assertEquals("男", gender, "性别获取错误,应为男性");
    }

    @ParameterizedTest
    @ValueSource(strings = {"100000200010011022", "100000200010011042", "100000200010011062"})
    public void getGender_MultipleFemaleIdCards_ReturnsFemale(String idCard) {
        String gender = userService.getGender(idCard);
        assertEquals("女", gender, "性别获取错误,应为女性");
    }
}

八、Maven生命周期中的test阶段

8.1 test阶段的作用

在Maven的default生命周期中,test阶段用于运行单元测试。

执行test阶段的命令:

bash 复制代码
mvn test

执行顺序:

执行mvn test时,Maven会自动执行以下阶段:

  1. validate - 验证项目
  2. initialize - 初始化
  3. compile - 编译源代码
  4. test-compile - 编译测试代码
  5. test - 运行测试
8.2 test阶段的工作机制

所有命名规范的test都会运行

Maven Surefire插件会自动识别并运行以下测试类和方法:

  1. 测试类命名

    • **/Test*.java - 以Test开头的类
    • **/*Test.java - 以Test结尾的类
    • **/*Tests.java - 以Tests结尾的类
    • **/*TestCase.java - 以TestCase结尾的类
  2. 测试方法识别

    • JUnit 4:使用@Test注解的方法
    • JUnit 5:使用@Test@ParameterizedTest等注解的方法
8.3 跳过测试

在某些情况下,可能需要跳过测试:

跳过测试(编译测试代码但不运行):

bash 复制代码
mvn package -DskipTests

完全跳过测试(不编译也不运行):

bash 复制代码
mvn package -Dmaven.test.skip=true

在pom.xml中配置跳过测试:

xml 复制代码
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <skipTests>true</skipTests>  <!-- 跳过测试 -->
    </configuration>
</plugin>
8.4 测试报告

Maven执行测试后,会生成测试报告:

  • 测试报告位置target/surefire-reports/
  • HTML报告target/site/surefire-report.html(使用mvn site生成)
  • 测试结果:在控制台输出测试结果摘要
8.5 最佳实践
  1. 开发阶段 :频繁运行mvn test验证代码
  2. 提交前:确保所有测试通过
  3. CI/CD:在持续集成中自动运行测试
  4. 生产构建:生产环境构建应该运行所有测试,确保代码质量
  5. 跳过测试:仅在特殊情况下跳过测试,并记录原因

相关推荐
Coder_Boy_2 小时前
以厨房连锁故事为引,梳理Java后端全技术脉络(JVM到云原生,总结篇)
java·jvm·spring boot·分布式·spring·云原生
Zhu_S W2 小时前
Docker 完全指南:Java 开发者的容器化实践
java·docker·容器
Zhu_S W2 小时前
EasyExcel动态表头详解
java·linux·windows
敲代码的哈吉蜂2 小时前
Nginx配置文件的管理及优化参数
java·服务器·nginx
XiaoLeisj2 小时前
Android RecyclerView 实战:从基础列表到多类型 Item、分割线与状态复用问题
android·java
勇往直前plus4 小时前
从文件到屏幕:Python/java 字符编码、解码、文本处理的底层逻辑解析
java·开发语言·python
Drifter_yh10 小时前
【黑马点评】Redisson 分布式锁核心原理剖析
java·数据库·redis·分布式·spring·缓存
莫寒清11 小时前
Spring MVC:@RequestParam 注解详解
java·spring·mvc
没有医保李先生12 小时前
字节对齐的总结
java·开发语言