欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

搞定Java单元测试,一步到位!

最编程 2024-08-05 18:39:47
...

单元测试编写

单元测试编写是开发工程师的日常工作之一 利用好各种测试框架并掌握好单元
测试编写技巧 往往可以达到事半功倍的效果。本节主要介绍如何编写 JUnit 测试用例。

我们先简要了解一下JUnit单元测试框架。

JUnit 单元测试框架

Java语言的单元测试框架相对统-。JUnit和TestNG几乎始终处于市场前两位。其中 JUnit以较长的发展历史和源源不断的功能演进得到了大多数用户的青睐,也是阿里内部目前使用最多的单元测试框架。

JUnit项目的起源可以追溯到1997年。两位参加"面向对象程序系统语言和应用大会"(Conference for Object Orient Programming Systems, Languages & Applications)的极客开发者Kent Beck和Erich Gamma在从瑞士苏黎世飞往美国亚特兰大的飞机上为了打发长途飞行的无聊时间,他们聊起了对当时Java测试过程中缺乏成熟工具的无奈,然后决定起设计一款更好用的测试框架。于是采用结对编程的方式在飞机上完成了JUnit的雏形,以及世界上第一个JUnit单元测试用例。经过20余年的发展和几次重大版本的跃迁, JUnit于2017年9月正式发布了5.0稳定版本。JUnit5对JDK8及以上版本有了更好的支持(如增加了对lambda表达式的支持),并且加入了更多的测试形式,如重复测试、参数化测试等。下面的测试用例会使用JUnit5来编写,部分写法如果在JUnit4中不兼容,则会提前说明。

JUnit5.x 由以下三个主要模块组成:

  1. JUnit Platform: 用于在JVM上启动测试框架,统一命令行、 Gradle和Maven等方式执行测试的入口。
  2. JUnit Jupiter:包含 JUnit5.x 全新的编程模型和扩展机制。
  3. JUnit Vintage:用于在新的框架中兼容运行JUnit3.x、JUnit4.x测试用例。

为了便于开发者将注意力放在测试编写上,即不必关心测试的执行流程和结果展示,JUnit提供了一些辅助测试的注解,常用的测试注解说明如下表所示:

注解 释义
@Test 注明一个方法是测试方法, JUnit框架会在测试阶段自动找出所有使用该注解标明的测试方法并运行,需要注意的是,在JUnit5版本中,取消了该注解的timeout参数的支持
@TestFactory 注明一个方法是基于数据驱动的动态测试数据源
@ParameterizedTest 注明一个方法是测试方法,这一点同@Test注解作用一样,该注解还可以让一个测试方法使用不同的入参运行多次
@RepeatedTest 从字面意思就可以看出,这个注解可以让测试方法自定义重复运行次数
@BeforeEach 与JUnit4中的@Before类似,可以在每一个测试方法运行后,都运行一个指定的方法。在JUnit5中, 除了运行@Test注解的方法,还额外支持运行@ParameterizedTest和@RepeatedTest注解的方法
@AfterEach 与JUnit4中的@After类似,可以在每一个测试方法运行后,都运行一个指定的方法。在Junit5中,除了运行@Test注解的方法,还额外支持运行@ParameterizedTest和@RepeatedTest注解的方法
@BeforeAll 与JUnit4中的@BeforeClass类似,可以在每一个测试类运行前,都运行一个指定的方法
@AfterAll 与JUnit4中的@AfterClass类似,可以在每一个测试类运行前,都运行一个指定的方法
@Disabled 与JUnit4中的@Ignore类似,注明一个测试的类或方法不再运行
@Nested 为测试添加嵌套层级,以便组织用例结构
@Tag 为测试类或方法添加标签,以便有选择性地执行

下面是个典型的JUnit测试类结构:
TicketSeller.java

import lombok.Data;
import java.time.LocalTime;
/**
 * @author hll[yellowdradra@foxmail.com]
 **/
@Data
public class TicketSeller {
    private int inventory;
    private LocalTime closeTime;

    public boolean cloudSellAt(LocalTime time) {
        return this.closeTime.compareTo(time) >= 0;
    }

    public void sell(int m) {
        this.inventory -= m;
    }

    public void refund(int m) {
        this.inventory += m;
    }
}

TicketSellerTest.java

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

/**
 * 定义一个测试类并指定用例在测试报告中展示名称
 * @author hll
 */
@DisplayName("售票器类型测试")
public class TicketSellerTest {
    /**
     * 定义一个待测类的实例
     */
    private TicketSeller ticketSeller;

    /**
     * 定义在整个测试类开始前执行的操作
     * 通常包括全局和外部资源(包括测试桩)的创建和初始化
     */
    @BeforeAll
    public static void init() {
        // doSomeThing...
    }

    /**
     * 定义在整个测试类完成后执行的操作
     * 通常包括全局和外部资源的释放或销毁
     */
    @AfterAll
    public static void cleanup() {
        // doSomeThing...
    }

    /**
     * 定义在每个测试用例开始前执行的操作
     * 通常包括基础数据和运行环境的准备
     */
    @BeforeEach
    public void create() {
        this.ticketSeller = new TicketSeller();
        // doSomeThing...
    }

    /**
     * 定义在每个测试用例完成后执行的操作
     * 通常包括运行环境的清理
     */
    @AfterEach
    public void destroy() {
        // doSomeThing...
    }

    /**
     * 测试用例,当车票售出后余票应减少
     */
    @Test
    @DisplayName("售票后余票应减少")
    public void shouldReduceInventoryWhenTicketSoldOut() {
        ticketSeller.setInventory(10);
        ticketSeller.sell(1);
        assertThat(ticketSeller.getInventory()).isEqualTo(9);
    }

    /**
     * 测试用例,当余票不足时应该报错
     */
    @Test
    @DisplayName("余票不足应报错")
    public void shouldThrowExceptionWhenNoEnoughInventory() {
        ticketSeller.setInventory(0);
        assertThatExceptionOfType(TicketException.class)
                .isThrownBy(() -> ticketSeller.sell(1))
                .withMessageContaining("all ticket sold out")
                .withNoCause();
    }

    /**
     * Disabled注解将禁用测试用例
     * 该测试用例会出现在最终的报告中,但不会被执行
     */
    @Disabled
    @Test
    @DisplayName("有退票时余票应增加")
    public void shouldIncreaseInventoryWhenTicketRefund() {
        ticketSeller.setInventory(10);
        ticketSeller.refund(1);
        assertThat(ticketSeller.getInventory()).isEqualTo(11);
    }
}

需要注意的是,@DisplayName注解仅仅对于采用IDE或图形化方式展示测试运行结果的场景有效,如下图(IntelliJ IDEA 2022.2.3 (Community Edition))所示:


IDEA单元测试@DisplayName.png

从上面的可视化结果展示可以看出,"售票后余票应减少"测试通过;"余票不足应报错"失败了,因为之前写的TicketSeller::sell()方法一个劲儿的卖票,不考虑还有没有票,也不抛出异常,不符合测试需要的结果;"有退票时余票应增加"测试被@Disabled注解注解了,所以这个测试被忽略了。

但对于使用Maven、Gradle等命令行方式运行单元测试的情况,该注解中的内容会被忽略,例如单元测试出错时,实际展示结果如下:
Maven命令行命令如下:

mvn test -Dtest=TicketSellerTest 
Maven命令行运行单元测试.png

当测试用例较多时,为了更好地组织测试的结构,推荐使用JUnit的@Nested注解来表达有层次关系的测试用例:

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

/**
 * @author hll[yellowdradra@foxmail.com]
 **/
@DisplayName("交易服务测试")
public class TransactionServiceTest {
    @Nested
    @DisplayName("用户交易测试")
    class UserTransactionTest {
        @Nested
        @DisplayName("正向测试用例")
        class PositiveCase {
            @Test
            @DisplayName("交易检查应通过")
            public void shouldPassCheckWhenParameterValid() {
                // TODO test
            }
        }
        @Nested
        @DisplayName("负向测试用例")
        class NegativeCase {
            // TODO test
        }
    }
    @Nested
    @DisplayName("商家交易测试")
    class CompanyTransactionTest {
        // TODO test
    }
}

JUnit没有限制嵌套的层级数,除非必要,一般不建议使用超过3级的嵌套用例,过于复杂的测试层级结构会增加开发者理解用例关系的难度。分组测试和数据驱动测试也是单元测试中十分实用的技巧。其中,分组测试能够实现测试在运行频率维度上的分层,例如,将所有单元测试用例分为"执行很快且很重要"的冒烟测试用例、 "执行很慢但同样比较重要"的曰常测试用例,以及"数量很多但不太重要"的回归测试用例。然后在不同的场景下选择性地执行相应的测试用例。使用JUnit的@Tag注解可以很容易地实现这种区分。示例代码如下:

import org.junit.jupiter.api.*;
import static org.assertj.core.api.Assertions.*;
@DisplayName("售票器类型测试")
public class TicketSellerTest {
    private TicketSeller ticketSeller;
    @Test
    @Tag("fast")
    @DisplayName("售票后余票应减少")
    public void shouldReduceInventoryWhenTicketSoldOut() {
        ...
    }

    @Test
    @Tag("slow")
    @DisplayName("一次性购买20张票")
    public void shouldSuccessWhenBuy20TicketsOnce() {
        ...
    }
}

通过标签选择执行的用例类型,Maven中可以通过配置maven-surefire-pIugin插件来实现:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>2.22.2</version>
            <configuration>
                <properties>
                    <includeTags>fast</includeTags>
                    <excludeTags>slow</excludeTags>
                </properties>
            </configuration>
        </plugin>
</build>

数据驱动测试适用于计算密集型的算法单元,这些功能单元内部逻辑复杂,对于不同的输入会得到截然不同的输出。倘若使用传统的测试用例写法,需要重复编写大量模板式的数据准备和方法调用代码,以便覆盖各种情况的测试场景。而使用JUnit的@TestFactory注解能将数据的输入和输出与测试逻辑分开,只需编写一段测试代码,就能一次性对各种类型的输入和输出结果进行验证。示例代码如下:

import org.assertj.core.util.Lists;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.time.LocalTime;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("售票器类型测试")
public class ExchangeRateConverterTest {

    private TicketSeller ticketSeller;

    @TestFactory
    @DisplayName("时间售票检查")
    Stream<DynamicTest> oddNumberDynamicTestWithStream() {
        ticketSeller = new TicketSeller(); 
        ticketSeller.setCloseTime(LocalTime.of(12, 20, 25, 0));
        return Stream.of(
                Lists.list("提前购票", LocalTime.of(12, 20, 24, 0), true),
                Lists.list("准点购买", LocalTime.of(12, 20, 25, 0), true),
                Lists.list("晚点购票", LocalTime.of(12, 20, 26, 0), false))
                .map(data -> DynamicTest.dynamicTest((String) data.get(0), 
                    () -> assertThat(ticketSeller.cloudSellAt((LocalTime) data.get(1)))
                        .isEqualTo(data.get(2))));
    }
}

测试结果如下图:


@TestFactory测试结果.png