目录

单元测试

Unit Test

在计算机编程中,单元测试(英语:Unit Testing)又称为模块测试 ,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

指导思想

如何才能写出真正有价值的单元测试,而不是单纯为了绩效中的单元测试覆盖率?

阿里 Java开发手册

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
【强制】好的单元测试必须遵守 AIR原则。
说明:单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,
却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。 A:Automatic(自动化) I:Independent(独立性) R:Repeatable(可重复)
【强制】单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执
行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元
测试中不准使用 System.out来进行人肉验证,必须使用 assert来验证。
【强制】保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间
决不能互相调用,也不能依赖执行的先后次序。
反例:method2需要依赖 method1的执行,将执行结果作为 method2的输入。
【强制】单元测试是可以重复执行的,不能受到外界环境的影响。
说明:单元测试通常会被放到持续集成中,每次有代码 check in时单元测试都会被执行。如
果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
正例:为了不受外界环境影响,要求设计代码时就把 SUT的依赖改成注入,在测试时用 spring
这样的 DI框架注入一个本地(内存)实现或者 Mock实现。
【强制】对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级
别,一般是方法级别。
说明:只有测试粒度小才能在出错时尽快定位到出错位置。单测不负责检查跨类或者跨系统的
交互逻辑,那是集成测试的领域。

实现方式

  • 单层隔离
  • 内部穿透

单层隔离

正常代码分层会分为controller、service、dao等,在单层隔离的思想中,是针对每一层的代码做各自的单元测试,不向下穿透。这样的写法主要是保证单层的业务逻辑固化且正确。 实践过程中,例如针对controller层编写的单元测试需要将对应controller类代码文件外部所有的调用全部mock,包括对应的内部/外部的service。其他层的代码也是如此。

这样做的优点:

  • 单元测试代码极其轻量,运行速度快。由于只保证单个类内部的逻辑正确,其他全部mock,所以可以放弃中间件的mock,甚至Spring的注入都可以放弃,专注在单元测试逻辑验证的编写。这样整套单元测试代码运行完成应该也是轮秒计时,相对来讲Spring容器初始化完成可能都需要20秒。
  • 真正符合了单元测试的原则,可以在断网的情况下进行运行。单层逻辑中可以屏蔽服务注册和配置管理,各种中间件的影响。
  • 单元测试质量更高。针对单层逻辑的验证和断言能够更加清晰,如果要覆盖多层,可能会忽略丢失中间的各种验证环节,如果加上可能条件规模是一个笛卡尔乘积过于复杂。

缺点也是存在:

  • 单元测试的代码量比较大,因为是针对每层单独编写单元测试,而且需要mock掉的外部依赖也是比较多的。
  • 学习曲线相对较高,由于程序员的习惯针对单元测试是给定输入验证输出。所以没有了底层的输出,单纯验证过程逻辑要存在一个思维上的转变。
  • 对于低复杂度的项目比较不友好。如果你的项目大部分都是单纯的分层之后的CRUD,那单元测试其实可验证的东西不太多。但是如果是代码当中执行了复杂逻辑,这样的写法就能够起到比较好的质量保证。

在这个项目中,最终没有采用这样的方法,而是采用了穿透的方式。项目的场景、人员组成、复杂度的实际情况,我觉得用这种方式不算很合适。

内部穿透

穿透,自然就是从顶层一直调用到底层。为什么还要加上内部二字?就是除了项目内的方法可以穿透,项目外部依赖还是要mock掉的。 实践过程中,就是单元测试针对controller层编写,但是会完整调用service、dao,最终对落地结果进行验证。

优点:

  • 代码量相对较小,由于进行了穿透所以多层代码的覆盖仅需要从顶层的单元测试验证即可。
  • 学习曲线低,穿透的单元测试更偏向黑盒,开发人员构造输入条件,然后从落地结果中(存储,例如数据库)验证预期结果。

缺点:

  • 整体较重,启动Spring容器,中间件mock,整体单元测试运行预计需要是需要分钟级别。所以基本是要在CI的时候来执行。

技术实现

JUnit 5

The 5th major version of the programmer-friendly testing framework for Java and the JVM

与以前的 JUnit 版本不同,JUnit 5 由来自三个不同子项目的几个不同模块组成。

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage:

JUnit Platform 是在 JVM 上启动测试框架的基础。 TestEngine API 此外,该平台还提供一个控制台启动器,从命令行启动平台。 JUnit Platform Suite Engine 使用平台上一个或多个测试引擎运行一个自定测试套件(test suite)。 支持 JUnit Platform 的有 流行的 IDE (IntelliJ IDEA, Eclipse, NetBeans, and Visual Studio Code) 和构建工具 (Gradle, Maven, and Ant)。

JUnit Jupiter 是用于在 JUnit 5 中编写测试和扩展的新编程模型和扩展模型的组合。 Jupiter 子项目提供了一个TestEngine用于在平台上运行基于 Jupiter 的测试。

JUnit Vintage 提供了一个TestEngine用于在平台上运行基于 JUnit 3 和 JUnit 4 的测试。它要求 JUnit 4.12 或更高版本出现在类路径或模块路径中。

类似的框架还有 TestNG。

Mockito

借助 Mock(模拟) 框架进行单元测试一直被认为是一种有用的实践,尤其是Mockito框架近年来主导了这个市场。

spring-boot-starter-test 自带 Mockito

PowerMock

PowerMock 由两个扩展 API 组成。

一个用于EasyMock,一个用于Mockito。要使用 PowerMock,您需要依赖这些 API 之一以及测试框架。

目前 PowerMock 支持 JUnit 和 TestNG。

它提供了以一种简单的方式使用 Java 反射 API 的功能,以克服 Mockito 的问题,例如缺乏模拟 final, static or private methods 的能力。

Testcontainers 数据库层

Dao 层单元测试

单元测试中使用数据库,可以考虑两种方案:

  • 搭建一个长期使用的测试数据库,作为单元测试,测试开始前或完成后清空无关数据,即可保证测试的可重复性。缺点是多个人同时运行单元测试时,可能会失败。
  • 使用内存数据库(如 H2)。优点是无需清空无关数据,缺点是要将数据库初始化过程(如建表语句)纳入单元测试中。如果初始化很复杂,也会影响单元测试的效率。
  • Testcontainers 数据库层
  • 使用 Spring 的事务控制回滚事务。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration({"classpath:applicationContext-database.xml",
        "classpath:applicationContext.xml"})
@Transactional
public abstract class BaseSpringTest {
    protected final Logger logger = LoggerFactory.getLogger(getClass());
  
  @Test
  @Rollback
  public void testDao() throws Exception {}
}

// Mybatis Plus 动态数据源
@MybatisPlusTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ImportAutoConfiguration(value = {DynamicDataSourceAutoConfiguration.class, MybatisPlusAutoConfiguration.class}, exclude = DataSourceAutoConfiguration.class)
public class BaseMapperTest {
}

H2

H2 是一个使用 Java 实现的内存内存数据库,支持标准的SQL语法,支持大部分的 MySQL 语法和函数,很适合依赖关系型数据库(比如MySQL, SQL Server, Oracle等)的单元测试。

1
2
3
4
5
6
 
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

Redis

如果依赖了中间件或数据库,做单元测试时,有两种思路:

embedded service (基于内存) TestsContainers(基于容器)

Spring Boot Test

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <version>2.5.0</version>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@SpringBootTest(
  SpringBootTest.WebEnvironment.MOCK,
  classes = Application.class)
@AutoConfigureMockMvc
@TestPropertySource(
  locations = "classpath:application-integrationtest.properties")
public class EmployeeRestControllerIntegrationTest {

    @Autowired
    private MockMvc mvc;

    @Autowired
    private EmployeeRepository repository;

    // write test cases here
    @Test
public void givenEmployees_whenGetEmployees_thenStatus200()
  throws Exception {

    createTestEmployee("bob");

    mvc.perform(get("/api/employees")
      .contentType(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andExpect(content()
      .contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
      .andExpect(jsonPath("$[0].name", is("bob")));
}
}

Feign

对 Feign 接口进行单元测试时,需要 import 两个 configuration,因此可以抽一个基类出来,如下所示:

1
2
3
4
5
@ExtendWith(SpringExtension.class)
@Import({FeignAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class})
public class BaseFeignTest {

}

然后继承这个基类,并 enable 要测试的 client 即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@EnableFeignClients(clients = {ExampleFeign.class})
public class ProductFieldTest extends BaseFeignTest {

    @Autowired
    ExampleFeign feign;

    @Test
    void testFeign() {
        TagValuesDto result = feign.getProductFieldValue("WWW3037", "XZ_MF10").getData();
        System.out.println(result);
    }
}

maven-surefire-plugin

1
2
3
4
5
6
7
8
9
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>${maven-surefire-plugin.version}</version>
  <configuration>
    <argLine>-Dfile.encoding=UTF-8</argLine>
    <testFailureIgnore>true</testFailureIgnore>
  </configuration>
</plugin>

http://maven.apache.org/plugins/maven-surefire-plugin/

可能是由于历史的原因,Maven 2/3中用于执行测试的插件不是maven-test-plugin,而是maven-surefire-plugin。其实大部分时间内,只要你的测试类遵循通用的命令约定(以Test结尾、以TestCase结尾、或者以Test开头),就几乎不用知晓该插件的存在。然而在当你想要跳过测试、排除某些测试类、或者使用一些TestNG特性的时候,了解maven-surefire-plugin的一些配置选项就很有用了。例如 mvn test -Dtest=FooTest 这样一条命令的效果是仅运行FooTest测试类,这是通过控制maven-surefire-plugin的test参数实现的。

http://maven.apache.org/surefire/maven-failsafe-plugin/

  • pre-integration-test for setting up the integration test environment.
  • integration-test for running the integration tests.
  • post-integration-test for tearing down the integration test environment.
  • verify for checking the results of the integration tests.

The Failsafe Plugin has only two goals: