一、什么是单元测试?

单元测试,就是针对代码里最小的可测试功能单元(单个方法),编写独立的测试代码,验证这个方法的输入、输出、逻辑是否完全符合设计预期,给每个方法写对应的测试代码,就是单元测试。

二、为什么不用 main 方法?

main 方法测试的痛点:代码耦合,难以维护:测试代码和业务源代码混写在同一个类里,上线时需要手动清理,代码混乱,长期维护成本极高;测试不独立,连锁失败:所有测试逻辑都写在一个 main 方法里,前面的代码报错、抛出异常,后面的所有测试逻辑直接终止执行;无自动化能力,无标准化报告:只能手动运行 main 方法,靠人判断结果,无法做自动化测试,也无法自动统计。

三、JUnit 搭配 Maven 使用

1:在pom.xml中引入JUnit

通过<dependency>标签,自动下载引入JUnit的jar包,不用手动导入文件(前面已经提到是通过GAV实现的)。

<!-- JUnit 5 核心依赖 -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.9.1</version>
</dependency>

2:创建测试方法

Maven项目有固定的规范目录结构

  • 业务主代码放在src/main/java目录下;
  • 测试代码固定放在src/test/java目录下:

测试类UserServiceTest.java

@Test //必须引入Test,标记这个方法是JUnit单元测试方法
public void testGetAge(){
    // 1. 调用业务类UserService的getAge方法,传入身份证号,获取计算出的年龄
    Integer age = new UserService().getAge("110002200505091218");
    // 2. 打印结果,人工验证计算出的年龄是否符合预期
    System.out.println(age);
}
  • @Test:JUnit的注解和public void的无参方法,标记为可独立运行的单元测试方法,能单独运行测试。
  • 点击运行可以看到测试是否通过,多个测试函数可以看到每一个的具体情况:
    在这里插入图片描述

3:引入断言

为了进一步验证代码结果是否正确,引入 JUnit 中的断言功能:

public class Assertions {

    /**
     * 检查两个值是否相等,不相等就报错
     * @param expected 预期值
     * @param actual 实际值
     * @param message 【可选】错误提示信息
     */
    public static void assertEquals(Object expected, Object actual, String message) {}

    /**
     * 检查两个值是否不相等,相等就报错
     * @param unexpected 不期望的值
     * @param actual 实际值
     * @param message 【可选】错误提示信息
     */
    public static void assertNotEquals(Object unexpected, Object actual, String message) {}

    /**
     * 检查对象是否为null,不为null就报错
     * @param actual 待检查对象
     * @param message 【可选】错误提示信息
     */
    public static void assertNull(Object actual, String message) {}

    /**
     * 检查对象是否不为null,为null就报错
     * @param actual 待检查对象
     * @param message 【可选】错误提示信息
     */
    public static void assertNotNull(Object actual, String message) {}

    /**
     * 检查条件是否为true,不为true就报错
     * @param condition 布尔条件
     * @param message 【可选】错误提示信息
     */
    public static void assertTrue(boolean condition, String message) {}

    /**
     * 检查条件是否为false,不为false就报错
     * @param condition 布尔条件
     * @param message 【可选】错误提示信息
     */
    public static void assertFalse(boolean condition, String message) {}

    /**
     * 检查程序运行抛出的异常是否符合预期
     * @param expectedType 预期异常类型
     * @param executable 待执行代码块
     * @param message 【可选】错误提示信息
     * @param <T> 异常类型泛型
     * @return 抛出的异常对象
     */
    public static <T extends Throwable> T assertThrows(Class<T> expectedType, Executable executable, String message) {
        return null;
    }
}


 @Test
  void testAdd() {
      // 格式:assertEquals(预期值, 实际值, 错误提示信息)
      assertEquals(expectedResult, actualResult, "3 + 5 应该等于 8");
      
      System.out.println("测试通过:assertEquals 验证成功");
  }


  @Test
  void testDivideByZero() {
      // 格式:assertThrows(预期异常类型, 执行代码块)
      // 它会返回抛出的异常对象,方便我们后续验证异常信息
      ArithmeticException exception = assertThrows(
              ArithmeticException.class, // 预期会抛出的异常类型
              () -> calculator.divide(dividend, divisor), // 这里写会抛出异常的代码
              "除以 0 时应该抛出 ArithmeticException"
      );
  }

4:引入注释

下面是JUnit中的常见注释,引入

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

常规使用示例:

public class LifecycleAnnotationDemo {
    @BeforeAll //在所有测试前执行
    static void initAll() {//一定要写static
        // 场景:比如连接数据库、启动服务器这种非常耗时的操作
        // 只需要在所有测试开始前做 1 次
        //。。。
    }

    @BeforeEach //在每一次测试前执行
    void init() {
        // 场景:比如打开浏览器、新建一个测试用的对象
        // 每个 @Test 方法运行前,都会重新执行一次
        //。。。
    }

    @AfterEach //每一次测试后执行
    void tearDown() {
        // 场景:比如关闭浏览器、删除测试产生的临时数据
        // 每个 @Test 方法运行后,都会执行一次
        //。。。
    }
    
    @AfterAll //在所有测试之后运行
    static void tearDownAll() { //一定是static
        // 场景:比如断开数据库连接、关闭服务器
        // 只需要在所有测试结束后做 1 次
        //。。。
    }
}

参数化测试:(省去了一个一个参数单独测试的麻烦)

// 这是 JUnit 定义的注解类,提供了ints、strings等定义
public @interface ValueSource {

    // 这里定义了一个属性,名字叫 strings,类型是 String 数组
    String[] strings() default {};

    // 这里定义了一个属性,名字叫 ints,类型是 int 数组
    int[] ints() default {};

    // 这里定义了一个属性,名字叫 longs,类型是 long 数组
    long[] longs() default {};

    // 这里定义了一个属性,名字叫 doubles,类型是 double 数组
    double[] doubles() default {};
    
    // ... 还有其他类型
}


 public class NumberUtilTest {

    /**
     * 参数化测试演示
     * 1. 用 @ParameterizedTest 代替 @Test
     * 2. 用 @ValueSource 传入一堆参数
     */
    @DisplayName("测试名称")
    @ParameterizedTest // 注意:这里不是 @Test
    @ValueSource(ints = {2, 4, 0, -6}) // 提供一组 int 类型的参数,这里的ints代表int数组
    void testIsEvenWithTrueCases(int number) {
        // 这个方法会运行 4 次,每次 number 分别等于 2, 4, 0, -6
        System.out.println("正在测试参数:" + number);
        
        // 断言:这些数都应该返回 true
        assertTrue(NumberUtil.isEven(number));
    }
}

只有参数化测试(@ParameterizedTest)能接收形参(如 @ValueSource 里的数字)。

在实际生产环境中使用JUnit进行单元测试时,应该尽可能考虑到所有可能发生的情况,不仅要测试正确的测试用例,还要将所有可能出现的测试用例都要进行测试,保证代码的完整性和健全性!

5:覆盖率测试

点击测试类使用覆盖率运行“”,即可看到测试代码的覆盖率情况:

在这里插入图片描述

覆盖率各个属性的含义如下:

  1. 类(%) → 类覆盖率:统计有多少个类,被单元测试触达执行过。只要这个类里的任意一行可执行代码被测试跑过,这个类就算被覆盖了

  2. 方法(%) → 方法覆盖率:统计类里有多少个方法,被单元测试触达执行过。只要方法里的任意一行可执行代码被跑过,这个方法就算被覆盖;空方法、接口方法、抽象方法不会被计入统计。

  3. 行(%) → 行覆盖率:统计代码里,有多少行可执行的逻辑代码,被单元测试执行过。

    • 不是所有行都会被统计:注释、空行、类定义、方法定义、单纯的变量声明(比如int a;,都不会计入分母。真正会被执行的逻辑(赋值、计算、方法调用、条件判断等)才会被统计。
    • 运行覆盖率后,你的业务代码左侧会有颜色标记:
      • 绿色 = 这行代码被测试完全覆盖了
      • 红色 = 这行代码完全没被测试执行过
      • 黄色 = 这行代码部分覆盖(比如if条件只走了一个分支)
  4. 分支(%) → 分支覆盖率:统计代码里的所有逻辑分支,有多少被单元测试覆盖到了。这里的分支,指的是if/elseswitch、三元运算符、for/while的条件判断等所有会产生不同执行路径的逻辑。

    • 如果测试只跑了if成立的情况,分支覆盖率就是50%;
    • 只有if和else都被测试执行过,分支覆盖率才是100%;
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐