单元测试:JUnit与PowerMockito的实践与应用

My email address: zrg1390556486@gmail.com

1. 单元测试概述

1.1. 什么是单元测试?

单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。
单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。

1.2. 为什么要写单元测试?

随着 DevOps、敏捷、微服务的发展,产品的迭代速度越来越快,“速度” 与 “质量” 经常被视为互相矛盾点。由于业务逐渐服务化,导致微服务野蛮生长。企业在实施传统的分层自动化测试也由 “金字塔模型” 转变为 “橄榄球模型”,如下图所示。由于 UI、API 自动化测试都需要依赖被测系统正常运行,且经常出现由于测试数据、环境 (前端、后端) 部署、用例依赖等原因导致自动化测试执行失败。即使环境正常的情况下由于测试用例的数量由开始的核心场景覆盖延伸到各个分支覆盖,数量成几何指数的倍增,带来了测试用例执行失败定位问题极为困难。基于这种背景下衍生出了对 “分层自动化测试” 更加深入的思考。

auto-testing-pyramid.png

Figure 1: 金字塔模型->橄榄球模型

金字塔模型中指出自动化测试策略应该首先将更多的测试投入到底层 “单元测试”,其次是 “集成测试”,最后是 “端到端测试”。然而当前微服务现状中,企业将自动化测试更多放在 “集成测试 (API 测试)” 环节,这也是接口测试最近几年爆发式增长原因之一。

auto-testing-pyramid-bug.png 从 “缺陷的产生率” 倒三角模型来看,缺陷更多产生在 “单元级别”,而且在单元级别修复缺陷的成本更低、效率更高。通过从单元层面消灭缺陷使得 “集成/端到端测试” 缺陷会明显减少,而且排查问题的效率也更。特别是当需要对代码模块进行重构或重新设计时单元测试是保证质量的最重要手段之一。从单元级别解决缺陷是最快的反馈机制,执行效率也最高,结合 “测试替身 (Test Double)” 等技术进行依赖的隔离能降低单元之间的依赖性,提高测试用例的执行成功率,使得持续测试变为可能。

unit-test-3.png

Figure 2: 自动化测试金字塔

UI/端到端测试(UI/End-to-End testing)
UI 测试适用于确保用户界面的正确性和功能性,它主要关注用户交互和界面元素的行为,成熟的自动化工具有QTP、Selenium、微信小程序自动化框架minium;端到端测试(E2E)也被称为功能测试(Functional Testing),用于模拟真实用户场景,将整个系统作为一个整体,然后从用户的角度进行测试的,测试应用程序的整个流程。端到端测试的目的是测试系统在实际使用的是否正常的, 因此通常来说是不需要测试替身的(Test Double)。UI 测试主要关注用户界面的正确性和功能性,而端到端测试关注整个应用程序的各个界面和组件之间的交互。
  • UI测试通常涉及以下方面:
    • 验证用户界面元素是否正确显示和布局,例如按钮、文本框、下拉菜单等。
    • 验证用户输入是否能够正确处理和响应,例如通过输入文本后是否能够正确搜索或提交表单。
    • 验证用户界面上的交互是否按预期工作,例如点击按钮后是否触发正确的操作。
  • 端到端测试通常涉及以下方面:
    • 验证用户界面的正确性和功能性,类似于UI测试。
    • 验证后端服务和数据库的正确性,例如数据的读写操作是否正常。
    • 验证整个应用程序的各个模块和组件之间的交互是否按预期工作。
集成/接口测试(Integration/Interface testing)
集成/接口测试是现在在企业中应用最广泛的自动化测试之一,它的优点在于规避了UI层自动化测试的缺点,一旦形成较为稳定、完整的框架后基本上是比较通用的,并且接口测试关注的重点更多在于数据(数据处理、数据状态、数据传递),不论是在Web端还是移动端都可以使用。缺点也很明显,就是对测试工程师的编码能力要求较高,一般接口自动化测试都会用Python、Java等语言开发。
单元测试(Unit testing)
单元测试关注的重点更多在于代码的实现与内部逻辑关系。对于不同产品的开发技术栈,都会有对应的单元测试框架,如Java有JUnit、testNG,C#有NUnit,Python有UnitTest、Pytest等。基本上可以肯定的是,单元测试是成本最低的,也是最容易推广,见效最大的。


关于分层自动化测试的更多内容,在附录A-分层自动化测试中查看。

1.2.1. 单元测试的好处

使用单元测试可以有效地降低程序出错的机率,提供准确的文档,并帮助我们改进设计方案等等。
  • 允许你对代码做出任何改变,因为你了解单元测试会在你的预期之中。
  • 单元测试可以有效地降低程序出现BUG的机率。
  • 帮助你更深入地理解代码–因为在写单元测试的时候,你需要明确程序所有的执行流程及对应的执行结果等等。
  • 允许在任何时候代码重构,而不必担心破坏现有的代码。这使得我们编写程序更灵活。
  • 确保你的代码的健壮性,因为所有的测试都是通过了的。
  • 文档记录。单元测试就是一种无价的文档,它是展示函数或类如何使用的最佳文档,这份文档是可编译、可运行的、并且它保持最新,永远与代码同步。
  • 具有回归性::自动化的单元测试避免了代码出现回归,编写完成之后,可以随时随地地快速运行测试,而不是将代码部署到设备之后,然后再手动地覆盖各种执行路径,这样的行为效率低下,浪费时间。

1.3. 什么时候写单元测试?

写单元测试的三种时机:

  • 一是在具体实现代码之前,这是测试驱动开发(TDD)所提倡的。
  • 二是与具体实现代码同步进行。先写少量功能代码,紧接着写单元测试(重复这两个过程,直到完成功能代码开发)。其实这种方案跟第一种已经很接近,基本上功能代码开发完,单元测试也差不多完成了。
  • 三是编写完功能代码再写单元测试。根据实践经验,事后编写的单元测试“粒度”都比较粗。

推荐单元测试与具体实现代码同步进行。

1.4. 单元测试要写多细?

单元测试不是越多越好,而是越有效越好!需要有单元测试覆盖的地方:

  • 逻辑复杂的
  • 容易出错的
  • 不易理解的,即使是自己过段时间也会遗忘的,看不懂自己的代码,单元测试代码有助于理解代码的功能和需求
  • 公共代码。比如自定义的所有http请求都会经过的拦截器;工具类等。
  • 核心业务代码。一个产品里最核心最有业务价值的代码应该要有较高的单元测试覆盖率。

1.4.1. 单元测试的覆盖率

单测覆盖率是指业务代码被单测测试的比例和程度,它是衡量单元测试好坏的一个很重要的指标,各类覆盖率指标从粗到细、从弱到强排列如下

  • 粗粒度的覆盖:包括类覆盖和方法覆盖两种。
  • 细粒度的覆盖:
    • 分支覆盖(Branch Coverage ):分支覆盖率的计算公式中的分子是代码中被执行到的分支数,分母是代码中所有分支的 总数。
    • 条件判定覆盖(Condition Decision Coverage):条件判定覆盖要求设计足够的测试用例,能够让判定中每个条件的所有可能情况 至少被执行一次, 同时每个判定本身的所有可能结果也至少执行一次。
    • 条件组合覆盖( Multiple Condition Coverage):条件组合覆盖是指判定中所有条件的各种组合情况都出现至少一次。
    • 路径覆盖( Path Coverage ):路径覆盖要求能够测试到程序中所有可能的路径。

1.5. 单元测试相关概念

unit-testing-sut.png

1.5.1. 四阶段测试模式

上图中左侧部分表示四阶段测试模式:Setup - Exercise - Verify - Teardown

  1. Setup :: 测试准备阶段(Fixture Setup),准备测试所依赖的外部环境(例如被测类要读写文件,就先在文件系统中创建这个文件,必要时还写入特定的内容),创建被测类(System Under Test,简称 SUT)的实例,设置被测类的内部状态,注入它的外部依赖(一般用测试替身替代),等等。一句话:为测试的执行满足各种内外条件,使被测系统(SUT)和环境达到一个确定的状态(称为 Fixture)。
  2. Exercise :: 执行测试阶段(exercise SUT),与被测类(SUT)交互,执行测试。
  3. Verify :: 结果验证阶段(Result verification),验证测试的结果是否符合方方面面的预期:方法的返回值是否和我们的期望值相同?SUT 的内部状态是否更新到我们期望的值?是否向文件或数据库写入了预期的数据?是否按照契约调用了外部依赖的指定方法?等等。
  4. Teardown :: 环境清理阶段(Fixture teardown),在这个阶段将系统和环境恢复到测试前的状态--测试不应该对系统和环境造成持久性的改变 ,包括:从数据库中删除测试插入的数据,从文件系统删除测试创建的文件,将修改了的数据行恢复原状等等。


下面对图中右侧部分做详细介绍。

1.5.2. 被测系统(SUT)

被测系统(System under test, SUT)表示正在被测试的系统, 目的是测试系统能否正确操作. 根据测试类型的不同, SUT 指代的内容也不同, 例如 SUT 可以是一个类甚至是一整个系统。

1.5.3. 测试依赖组件(DOC)

被测系统所依赖的组件, 例如进程 UserService 的单元测试时, UserService 会依赖 UserDao, 因此 UserDao 就是 DOC。

SUT & DOC
在做单元测试的时候,测试对象是SUT,但因为SUT会呼叫其他物件,使得SUT相依于DOC。
换句话说,要测试SUT,DOC也必须存在,这使得测试变得更复杂。例如,请参考下图的观察者设计模式(Observer Pattern),假设要测试Subject的notify函数,因此Subject的notify函数是SUT,Observer是DOC(因为notify函数会呼叫Observer的update函数)。 notify函数所影响的对象是Observer,透过测试notify无法直接观察到Observer的update函数是否有真的被呼叫,这样的相依性使得测试notify变得困难。 unit-testing-observer-design-pattern.png

1.5.4. 测试替身(Test Double)

一个实际的系统会依赖多个外部对象, 但是在进行单元测试时, 我们会用一些功能较为简单的并且其行为和实际对象类似的假对象来作为 SUT 的依赖对象, 以此来降低单元测试的复杂性和可实现性,在这里, 这些假对象就被称为测试替身(Test Double)。测试替身有如下 5 种类型:

Test stub
为 SUT 提供数据的假对象。具体举例:假设我们的一个模块需要从 HTTP 接口中获取商品价格数据, 这个获取数据的接口被封装为 getPrice 方法. 在对这个模块进行测试时, 我们显然不太可能专门开一个 HTTP 服务器来提供此接口, 而是提供一个带有 getPrice 方法的假对象, 从这个假对象中获取数据. 在这个例子中, 提供数据的假对象就叫做 Test stub。
Fake object
实现了简单功能的一个假对象。Fake object 和 Test stub 的主要区别就是 Test stub 侧重于用于提供数据的假对象, 而 Fake object 没有这层含义。使用 Fake object 的最主要的原因就是在测试时某些组件不可用或运行速度太慢, 因而使用 Fake object 来代替它们。
Mock object
用于模拟实际的对象, 并且能够校验对这个 Mock object 的方法调用是否符合预期。Mock object 是 Test stub 或 Fake object 一种, 但是 Mock object 有 Test stub/Fake object 没有的特性, Mock object 可以很灵活地配置所调用的方法所产生的行为, 并且它可以追踪方法调用, 例如一个 Mock Object 方法调用时传递了哪些参数, 方法调用了几次等。
Dummy object
在测试中并不使用的, 但是为了测试代码能够正常编译/运行而添加的对象。 例如我们调用一个 Test Double 对象的一个方法, 这个方法需要传递几个参数, 但是其中某个参数无论是什么值都不会影响测试的结果, 那么这个参数就是一个 Dummy object. Dummy object 可以是一个空引用, 一个空对象或者是一个常量等。
简单的说, Dummy object 就是那些没有使用到的, 仅仅是为了填充参数列表的对象。
Test Spy
可以包装一个真实的 Java 对象, 并返回一个包装后的新对象。若没有特别配置的话, 对这个新对象的所有方法调用, 都会委派给实际的 Java 对象。
mock 和 spy 的区别 是:mock 是无中生有地生出一个完全虚拟的对象, 它的所有方法都是虚拟的; 而 spy 是在现有类的基础上包装了一个对象, 即如果我们没有重写 spy 的方法, 那么这些方法的实现其实都是调用的被包装的对象的方法。

1.5.5. Test Fixture

所谓 test fixture, 就是运行测试程序所需要的先决条件(precondition)。即对被测对象进行测试时锁需要的一切东西(The test fixture is everything we need to have in place to exercise the SUT)。不单单指的是数据, 同时包括对被测对象的配置, 被测对象所需要的依赖对象等。JUnit4 通过 setUp 方法完成。

1.5.6. 测试用例(Test Case)

JUnit4 只要在每个测试方法标注 @Test 注解。

1.5.7. 测试套件(Test Suite)

通过@RunWith 和@SuteClass 两个注解, 我们可以创建一个测试套件。通过@RunWith 指定一个特殊的运行器,并通过@SuiteClasses 注解, 将需要进行测试的类列表作作为参数传入。

1.6. 流行的测试框架

Java中存在很多单元测试框架,每种框架有着自己独特的特点,目前主流的测试框架有且不仅有以下几种:

框架 描述
JUnit JUnit 是 Java 中最常用的单元测试框架。该框架提供了丰富的测试与断言方法,例如:assertNull、assertTrue、assertEquals等,使用方法比较简单。JUnit 目前已经更新到 JUnit5 版本,该版本的新特性,例如:动态测试,依赖注入等,使得该框架更为健壮。
TestNG TestNG 是Java中的另一种测试框架,集团内使用的较为小众。该框架较JUnit相比,功能更加强大,提供了更多的高级特性,例如:测试套件、数据驱动测试、依赖测试、并行测试等。在更复杂的测试场景(如参数化测试、依赖测试等)中,TestNG的表现更加优异。
Spock Spock是基于Groovy语言编写的测试框架,该框架可以用来测试Java和Groovy的代码程序。Spock用来写测试代码的语言十分优美、表达力强,这一优点大大提高了测试代码的可读性和可维护性。Spock框架融合了JUnit、jMock、RSpec、Groovy、Scala和Vulcans等多种框架和语言的优点,旨在提供一套强大的测试平台。
Mockito Mockito不是一个完整的单元测试框架,而是专注于mock对象的创建、验证。它通常与JUnit或TestNG结合使用来简化对复杂依赖的测试。
EasyMock EasyMock是一套通过简单方法对于给定的接口生成mock对象的类库,通过使用Java代理机制动态生成模拟对象。该框架提供对接口的模拟,能够通过录制、回放、检查三步来完成大体的测试过程,可以验证方法的调用种类、次数、顺序等,还可以令mock对象返回指定的值或抛出指定异常。开发者通过EasyMock可以方便的构造mock对象而忽略对象背后真正的业务逻辑。一般情况下,EasyMock与JUnit或TestNG配合使用。
PowerMock PowerMock是一种用于Java单元测试的框架,它扩展了其他mocking框架的能力,比如EasyMock和Mockito。PowerMock的主要特点是它可以mock静态方法、私有方法、final方法、构造函数,甚至系统类(如System、String等),这些通常是传统mocking框架所做不到的。虽然PowerMock提供了强大的功能,但由于它修改了类加载器和字节码操作,可能会导致一些测试方法与JVM或第三方库之间的兼容性问题。所以,在使用PowerMock时需要权衡其提供的功能和可能带来的复杂性。
JMock JMock是一种用于Java单元测试的框架,属于一种轻量级框架,该框架采用了行为驱动开发(BDD)的测试风格。用来在单元测试中mock接口或类的依赖项,对代码进行隔离测试,而无需关心整个系统的其他部分。JMock支持通过声明式的方式来指定对象间的交互行为。
Spring Test & Spring Boot Test Spring Boot 应用程序功能集成化测试支持。

2. 单元测试框架

2.1. JUnit

2.1.1. JUnit 简介

JUint是Java编程语言的单元测试框架,用于编写和运行可重复的自动化测试。


JUnit 特点

  • 提供注解来识别测试方法。
  • 提供断言来测试预期结果。
  • JUnit 测试允许你编写代码更快,并能提高质量。
  • JUnit 优雅简洁。没那么复杂,花费时间较少。
  • JUnit 测试可以自动运行并且检查自身结果并提供即时反馈。所以也没有必要人工梳理测试结果的报告。
  • JUnit 测试可以被组织为测试套件,包含测试用例,甚至其他的测试套件。
  • JUnit 在一个条中显示进度。如果运行良好则是绿色;如果运行失败,则变成红色。

2.1.2. 常用注解(JUnit 4.x,【】表示JUnit5)

注解 描述
@Test 标注测试方法。注意:测试方法必须是public void,即公共、无返回数据。可以抛出异常。
@Ignore【@Disabled】 有时候我们想暂时不运行某些测试方法\测试类,可以在方法前加上这个注解。在运行结果中,junit会统计忽略的用例数,来提醒你。但是不建议经常这么做,因为这样的坏处时,容易忘记去更新这些测试方法,导致代码不够干净,用例遗漏。使用此标注的时候不能与其它标注一起使用。
@BeforeClass【@BeforeAll】 当我们运行几个有关联的用例时,可能会在数据准备或其它前期准备中执行一些相同的命令,这个时候为了让代码更清晰,更少冗余,可以将公用的部分提取出来,放在一个方法里,并为这个方法注解@BeforeClass。意思是在测试类里所有用例运行之前,运行一次这个方法。例如创建数据库连接、读取文件等。注意:方法名可以任意,但必须是public static void,即公开、静态、无返回。这个方法只会运行一次
@AfterClass【@AfterAll】 跟@BeforeClass对应,在测试类里所有用例运行之后,运行一次。用于处理一些测试后续工作,例如清理数据,恢复现场。
@Before【@BeforeEach】 与@BeforeClass的区别在于,@Before不止运行一次,它会在每个用例运行之前都运行一次。主要用于一些独立于用例之间的准备工作。注意:必须是public void,不能为static。不止运行一次,根据用例数而定。
@After【@AfterEach】 与@Before对应。
@Runwith【@ExtendWith】 放在测试类名之前,用来确定这个类怎么运行的。
@Parameters 用于使用参数化功能

2.1.3. 常用的断言(JUnit 5.x)

方法 释义
fail 断言测试失败
assertTrue/assertFalse 断言条件为真或为假
assertEquals/assertNotFalse 断言指定两个值相等或不相等, 对于基本数据类型,使用值比较;对于对象,使用equals方法比较。
orgassertArrayEquals 断言数组元素全部相等
assertSame/assertNotSame 断言指定两个对象是否为同一个对象
assertThrows/assertDoesNotThrow 断言是否抛出了一个特定类型的异常
assertlimeout/assertTimeoutPreemptively 断言是否执行超时,区别在于测试程序是否在同一个线程内
assertlterableEquals 断言迭代器中的元素全部相等
assertLinesMatch 断言字符串列表元素全部正则匹配
assertAll 断言多个条件同时满足

2.1.4. JUnit 使用

2.1.4.1. maven 包依赖引入
<!-- JUnit 4 -->
<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.13.2</version>
  <scope>test</scope>
</dependency>
<!-- JUnit 5 -->
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter-api</artifactId>
  <version>5.8.2</version>
  <scope>test</scope>
</dependency>

注意: JUnit 4.x 是junit;JUnit 5.x 是junit-jupiter-api

2.1.4.2. 简单示例(JUnit 4)
public class Factorial {
    public static long fact(long n) {
        long r = 1;
        for (long i = 1; i <= n; i++) {
            r = r * i;
        }
        return r;
    }
}

以 Factorial.java 文件为例,对其进行测试:

import org.junit.Test;

public class FactorialTest {
    @Test
    void testFact() {
        Assert.assertEquals(1, Factorial.fact(1));
        Assert.assertEquals(2, Factorial.fact(2));
        Assert.assertEquals(6, Factorial.fact(3));
        Assert.assertEquals(3628800, Factorial.fact(10));
        Assert.assertEquals(2432902008176640000L, Factorial.fact(20));
    }
}

其他测试还有:

  • 测试:生命周期
  • 测试:禁用测试
  • 测试:断言测试
  • 测试:异常测试
  • 测试:时间测试
  • 测试:参数化测试
  • 测试:套件测试
  • 测试:测试顺序


更多 JUnit 4 代码示例:https://www.pdai.tech/md/develop/ut/dev-ut-x-junit.html

2.2. Mockito

2.2.1. Mockito 简介

  1. Mockito 是最流行的Java mock框架之一;
  2. PowerMockito 是一个用于 Java 单元测试的框架,它扩展了Mockitod的能力。举个例子,你在使用 JUnit 进行单元测试时,并不想让测试数据进入数据库,怎么办?这个时候就可以使用PowerMock,拦截数据库操作,并模拟返回参数
  3. PowerMockito 与 Mockito 的关系
    • PowerMockito 是 Mockito 和 PowerMock 的结合体,旨在扩展 Mockito 的功能,使其能够模拟静态方法、final类、私有方法等无法被常规Mockito框架所模拟的场景。
    • PowerMockito 通过修改字节码来实现对这些场景的模拟,从而使得在单元测试中能够覆盖更多的情况。
    • 使用 PowerMockito 时,通常需要额外添加相关的依赖,并结合JUnit一起使用。它提供了一些特定的注解和方法,用于标记被测试的类和方法,并进行模拟和验证。
  4. Mockito 官方网站: https://site.mockito.org/
  5. PowerMockito Github: https://github.com/powermock/powermock/
2.2.1.1. 节外生枝:什么是 Mock 测试
  • Mock通常是指,在测试一个对象A时,我们构造一些假的对象来模拟与A之间的交互,而这些Mock对象的行为是我们事先设定且符合预期。通过这些Mock对象来测试A在正常逻辑,异常逻辑或压力情况下工作是否正常。
  • Mock 测试就是在测试过程中,对于某些不容易构造(如 HttpServletRequest 必须在Servlet 容器中才能构造出来)或者不容易获取比较复杂的对象(如 JDBC 中的ResultSet 对象),用一个虚拟的对象(Mock 对象)来创建以便测试的测试方法。Mock 最大的功能是帮你把单元测试的耦合分解开,如果你的代码对另一个类或者接口有依赖,它能够帮你模拟这些依赖,并帮你验证所调用的依赖的行为。
  • 定义总结 :mock测试就是在测试过程中,对那些不容易构建的对象用一个虚拟对象来代替测试的方法就叫mock测试。
  • Mock 适用在什么场景
    • 真实对象具有不可确定的行为(产生不可预测的结果,如股票的行情)
    • 真实对象很难被创建(比如具体的web容器)
    • 真实对象的某些行为很难触发(比如网络错误)
    • 真实情况令程序的运行速度很慢
    • 真实对象有用户界面
    • 测试需要询问真实对象它是如何被调用的(比如测试可能需要验证某个回调函数是否被调用了)
    • 真实对象实际上并不存在(当需要和其他开发小组,或者新的硬件系统打交道的时候,这是一个普遍的问题)
    • 一些比较难构造的Object:这类Object通常有很多依赖,在单元测试中构造出这样类通常花费的成本太大。
    • 执行操作的时间较长Object:有一些Object的操作费时,而被测对象依赖于这一个操作的执行结果,例如大文件写操作,数据的更新等等,出于测试的需求,通常将这类操作进行Mock。
    • 异常逻辑:一些异常的逻辑往往在正常测试中是很难触发的,通过Mock可以人为的控制触发异常逻辑。

2.2.2. Mockito 使用

mockito.png

2.2.2.1. Maven 包依赖引入
<properties>
  <mockito-core.version>3.12.4</mockito-core.version>
</properties>
<dependencies>
  <!-- Mockito core -->
  <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>${mockito-core.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>
2.2.2.2. Hello Word
// DemoService
public interface DemoService {
    int getDemoStatus();
}

// DemoServiceImpl
public class DemoServiceImpl implements DemoService {

    private DemoDao demoDao;

    public DemoServiceImpl(DemoDao demoDao) {
        this.demoDao = demoDao;
    }

    @Override
    public int getDemoStatus() {
        return demoDao.getDemoStatus();
    }
}

// DemoDao
import java.util.Random;

public class DemoDao {
    public int getDemoStatus(){
        return new Random().nextInt();
    }
}
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.pdai.mockito.dao.DemoDao;
import tech.pdai.mockito.service.DemoService;

/**
 * Hello World Test.
 */
public class HelloWorldTest {

    @Test
    public void helloWorldTest() {
        // mock DemoDao instance
        DemoDao mockDemoDao = Mockito.mock(DemoDao.class);

        // 使用 mockito 对 getDemoStatus 方法打桩
        Mockito.when(mockDemoDao.getDemoStatus()).thenReturn(1);

        // 调用 mock 对象的 getDemoStatus 方法,结果永远是 1
        Assert.assertEquals(1, mockDemoDao.getDemoStatus());

        // mock DemoService
        DemoService mockDemoService = new DemoService(mockDemoDao);
        Assert.assertEquals(1, mockDemoService.getDemoStatus() );
    }
}
2.2.2.3. 一个完整的示例
2.2.2.3.1. 使用 mock 方法
import org.junit.Assert;
import org.junit.Test;

import java.util.List;
import java.util.Random;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
 * Mock Class Test.
 */
public class MockClassTest {

    @Test
    public void mockClassTest() {
        Random mockRandom = Mockito.mock(Random.class);

        // 默认值: mock 对象的方法的返回值默认都是返回类型的默认值
        System.out.println(mockRandom.nextBoolean()); // false
        System.out.println(mockRandom.nextInt()); // 0
        System.out.println(mockRandom.nextDouble()); // 0.0

        // mock: 指定调用 nextInt 方法时,永远返回 100
        Mockito.when(mockRandom.nextInt()).thenReturn(100);
        Assert.assertEquals(100, mockRandom.nextInt());
        Assert.assertEquals(100, mockRandom.nextInt());
    }

    @Test
    public void mockInterfaceTest() {
        List mockList = Mockito.mock(List.class);

        // 接口的默认值:和类方法一致,都是默认返回值
        Assert.assertEquals(0, mockList.size());
        Assert.assertEquals(null, mockList.get(0));

        // 注意:调用 mock 对象的写方法,是没有效果的
        mockList.add("a");
        Assert.assertEquals(0, mockList.size());      // 没有指定 size() 方法返回值,这里结果是默认值
        Assert.assertEquals(null, mockList.get(0));   // 没有指定 get(0) 返回值,这里结果是默认值

        // mock值测试
        Mockito.when(mockList.get(0)).thenReturn("a");          // 指定 get(0)时返回 a
        Assert.assertEquals(0, mockList.size());        // 没有指定 size() 方法返回值,这里结果是默认值
        Assert.assertEquals("a", mockList.get(0));      // 因为上面指定了 get(0) 返回 a,所以这里会返回 a
        Assert.assertEquals(null, mockList.get(1));     // 没有指定 get(1) 返回值,这里结果是默认值
    }
}
2.2.2.3.2. 使用 @Mock 注解
@Mock 注解可以理解为对 mock 方法的一个替代。

使用该注解时,要使用MockitoAnnotations.initMocks 方法,让注解生效, 比如放在@Before方法中初始化。
比较优雅优雅的写法是用MockitoJUnitRunner,它可以自动执行MockitoAnnotations.initMocks 方法。

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.Random;

import static org.mockito.Mockito.when;

/**
 * Mock Annotation
 */
@RunWith(MockitoJUnitRunner.class)
public class MockAnnotationTest {

    @Mock
    private Random random;

    @Test
    public void test() {
        Mockito.when(random.nextInt()).thenReturn(100);
        Assert.assertEquals(100, random.nextInt());
    }
}
2.2.2.3.3. 使用参数匹配
Mockito.when(testList.get(anyInt())).thenReturn("c");
Assert.assertEquals("c", testList.get(0));
Assert.assertEquals("c", testList.get(1));

目前 Mockito 有很多匹配函数,比如any()、anyInt()、anyLong()等等。

2.2.2.3.4. 使用 mock 异常方法
import org.junit.Assert;
import org.junit.Test;
import java.util.Random;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class ThrowTest {
    @Test
    public void throwTest1() {

        Random mockRandom = mock(Random.class);
        when(mockRandom.nextInt()).thenThrow(new RuntimeException("异常"));

        try {
            mockRandom.nextInt();
            Assert.fail();  // 上面会抛出异常,所以不会走到这里
        } catch (Exception ex) {
            Assert.assertTrue(ex instanceof RuntimeException);
            Assert.assertEquals("异常", ex.getMessage());
        }
    }

    /**
     * thenThrow 中可以指定多个异常。在调用时异常依次出现。若调用次数超过异常的数量,再次调用时抛出最后一个异常。
     */
    @Test
    public void throwTest2() {

        Random mockRandom = mock(Random.class);
        when(mockRandom.nextInt()).thenThrow(new RuntimeException("异常1"), new RuntimeException("异常2"));

        try {
            mockRandom.nextInt();
            Assert.fail();
        } catch (Exception ex) {
            Assert.assertTrue(ex instanceof RuntimeException);
            Assert.assertEquals("异常1", ex.getMessage());
        }

        try {
            mockRandom.nextInt();
            Assert.fail();
        } catch (Exception ex) {
            Assert.assertTrue(ex instanceof RuntimeException);
            Assert.assertEquals("异常2", ex.getMessage());
        }
    }
}

对应返回类型是 void 的函数,thenThrow 是无效的,要使用 doThrow。

import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.MockitoJUnitRunner;

import static org.mockito.Mockito.doThrow;

/**
 * Do Throw for void return.
 */
@RunWith(MockitoJUnitRunner.class)
public class DoThrowTest {

    static class ExampleService {

        public void hello() {
            System.out.println("Hello");
        }

    }

    @Mock
    private ExampleService exampleService;

    @Test
    public void test() {

        // 这种写法可以达到效果
        doThrow(new RuntimeException("异常")).when(exampleService).hello();

        try {
            exampleService.hello();
            Assert.fail();
        } catch (RuntimeException ex) {
            Assert.assertEquals("异常", ex.getMessage());
        }

    }
}
2.2.2.3.5. 使用 spy 和 @Spy 注解
import org.junit.Assert;
import org.junit.Test;
import static org.mockito.Mockito.*;

class ExampleService {
    int add(int a, int b) {
        return a+b;
    }
}

// MockitoDemo
public class MockitoDemo {
    // 测试 spy
    @Test
    public void test_spy() {

        ExampleService spyExampleService = Mockito.spy(new ExampleService());

        // 默认会走真实方法
        Assert.assertEquals(3, spyExampleService.add(1, 2));

        // 打桩后,不会走了
        Mockito.when(spyExampleService.add(1, 2)).thenReturn(10);
        Assert.assertEquals(10, spyExampleService.add(1, 2));

        // 但是参数比匹配的调用,依然走真实方法
        Assert.assertEquals(3, spyExampleService.add(2, 1));

    }

    // 测试 mock
    @Test
    public void test_mock() {

        ExampleService mockExampleService = Mockito.mock(ExampleService.class);

        // 默认返回结果是返回类型int的默认值
        Assert.assertEquals(0, mockExampleService.add(1, 2));
    }
}

对于@Spy,如果发现修饰的变量是 null,会自动调用类的无参构造函数来初始化。所以下面两种写法是等价的:

// 写法1
@Spy
private ExampleService spyExampleService;

// 写法2
@Spy
private ExampleService spyExampleService = new ExampleService();

如果没有无参构造函数,必须使用写法2。

2.2.3. 结合 PowerMock 使用

2.2.3.1. maven 包依赖引入

JUnit 4.4 or above:

<properties>
  <powermock.version>2.0.2</powermock.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-module-junit4</artifactId>
    <version>${powermock.version}</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-api-mockito2</artifactId>
    <version>${powermock.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>

更多 Maven 配置点击链接查看:https://github.com/powermock/powermock/wiki/Mockito#maven-configuration

2.2.3.2. 关键注解说明
// 告诉JUnit使用PowerMockRunner进行测试
@RunWith(PowerMockRunner.class)
// 所有需要测试的类列在此处,适用于模拟final类或有final, private, static, native方法的类
@PrepareForTest({RandomUtil.class})
// 为了解决使用powermock后,提示classloader错误
@PowerMockIgnore("javax.management.*")
public class MockitoDemo {
    @Test
    public void test() {
        PowerMockito.mockStatic(RandomUtil.class);

        PowerMockito.when(RandomUtil.nextInt(Mockito.any())).thenReturn(100);

        Assert.assertEquals(100, RandomUtil.nextInt(2));
    }
}
2.2.3.2.1. @Mock 和 @MockBean

`@Mock` 和 `@MockBean` 是用于模拟对象的注解,但它们之间有一些区别:

  1. @Mock:
    • 用于模拟不属于 Spring 上下文的对象。
    • 在普通的 JUnit 测试中使用。
    • 不知道 Spring 上下文,通常用于单元测试隔离组件,而不需要完整的 Spring 上下文设置。
    • 可以通过 Mockito 框架创建一个空的类,其中方法体都是空的,方法的返回值(如果有的话)都是 `null`。
    • 使代码更易读,且在出现失败时,可以更容易地找到问题所在的模拟对象。
  2. @MockBean:

    • 用于模拟属于 Spring 上下文的一部分的对象。
    • 在集成测试中很有用,当需要模拟特定的 Spring bean 时,例如外部的 Service。
    • 将 Mock 对象添加到 Spring 应用程序上下文中,它会替换掉相同类型的现有 bean,如果没有定义相同类型的 bean,它将添加一个新的 bean。

    总之,`@Mock` 适用于普通的 JUnit 测试,而 `@MockBean` 适用于集成测试,需要模拟 Spring 上下文中的特定 bean。

2.2.3.2.2. @Mock 和 @InjectMocks
  1. @InjectMocks
    • @InjectMocks 注解用于标记被测试类的实例,在测试中会自动创建该类的实例,并注入被@Mock注解标记的模拟对象。
    • 当测试类中的某个方法需要被测试时,使用@InjectMocks注解标记被测试的类实例,Mockito会自动将被标记为@Mock的模拟对象注入到被测试类的实例中。
    • 通常情况下,@InjectMocks用于测试目标类,即待测试的类,它会自动将依赖的模拟对象注入到目标类中。
  2. @Mock
    • @Mock注解用于标记需要模拟的对象,即需要在测试中替代的对象。通过@Mock注解,我们可以模拟外部依赖或者需要被测试类调用的其他对象。
    • 使用@Mock注解标记的对象会被Mockito框架创建为模拟对象,并在测试中被用于替代实际对象的行为。
    • 通常情况下,@Mock用于模拟测试类所依赖的其他类或者对象,以隔离测试对象与其依赖对象的关系。
    • 使用@Mock后,记得initMocks。

      MockitoAnnotations.initMocks(this);
      
2.2.3.2.3. Mockito 和 PowerMockito
  • Mockito
    1. 核心功能:
      • Mockito 主要用于模拟对象(实例方法)的行为,允许创建和配置模拟对象来替代真实对象,以便在测试中控制其输出和行为。
      • 它可以模拟非final类、非final方法、非static方法,以及具有可见性(public、protected、default)的方法。
    2. 模拟方式:
      • 使用Mockito.mock(Class<T>)方法创建模拟对象。
      • 通过when(…).thenReturn(…), doReturn(…).when(…)等方法设置模拟对象的方法调用返回值或行为。
    3. 限制:
      • Mockito 本身不能直接模拟静态方法、构造函数、final类或方法、私有方法,以及静态初始化块。
      • 对于依赖注入困难或设计不佳导致难以模拟的情况,可能需要重构代码以适应 Mockito。
  • PowerMockito
    1. 扩展功能:
      • PowerMockito 是基于 Mockito 构建的扩展库,它主要解决了 Mockito 不能模拟的一些特性,包括:
      • 静态方法:可以模拟类的静态方法,无论它们是否为final或私有。
      • 构造函数:可以模拟构造函数的行为,如返回特定的模拟对象或抑制构造函数的副作用。
      • final类与方法:可以模拟final类及其方法的行为。
      • 私有方法:可以模拟私有方法,使得它们在测试中可以被替换或控制其返回值。
      • 静态初始化块:可以抑制类的静态初始化块的执行。
    2. 模拟方式:
      • 使用PowerMockito.mockStatic(Class<T>)模拟静态方法。
      • 使用PowerMockito.whenNew(Constructor<T>)模拟构造函数。
      • 对于final类、方法或私有方法,仍然使用类似于 Mockito 的when(…).thenReturn(…)等方式设置模拟行为。
      • 有时需要配合@RunWith(PowerMockRunner.class)和@PrepareForTest(Class<T>)注解来启用PowerMockito的高级特性。
    3. 使用场景:
      • 适用于测试遗留代码、第三方库、框架代码或其他难以修改以适应标准单元测试的代码。
      • 当需要模拟上述Mockito无法处理的特性时,PowerMockito提供了强大的解决方案。


总结:

  1. Mockito 是一个轻量级、易于使用的模拟库,适用于大多数常规的单元测试场景,特别是在遵循良好设计原则(如依赖注入、接口隔离等)编写的代码中。
  2. PowerMockito 则提供了更强大的模拟能力,能够处理更复杂的场景,如模拟静态方法、构造函数、final类/方法、私有方法等。然而,由于其使用了类加载器替换和字节码操纵技术,可能会引入额外的复杂性和潜在风险,且对测试代码结构有一定要求(如使用特定的测试运行器和注解)。因此,PowerMockito通常是在必要时作为最后手段使用,特别是在面对难以修改或外部约束较多的遗留代码时。
  3. 选择使用哪一个库取决于项目的具体需求、代码结构以及对测试侵入性的接受程度。通常建议优先考虑使用 Mockito,只有在遇到其无法解决的模拟问题时才考虑使用 PowerMockito。同时,应尽量避免过度依赖PowerMockito,因为它可能掩盖代码设计上的问题,长期来看不利于代码的维护和演进。
2.2.3.3. 常见问题
2.2.3.3.1. java.lang.NoClassDefFoundError: Could not initialize class org.mockito.Mockito
  • 原因:`mockito-core`版本不兼容
  • 解决:指定mockito-core依赖版本,这里用`3.12.4`

    <mockito-core.version>3.12.4</mockito-core.version>
    
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>${mockito-core.version}</version>
      <scope>test</scope>
    </dependency>
    
  • 参考:https://github.com/mockito/mockito/issues/2568
2.2.3.3.2. ScriptEngineManager providers.next(): javax.script.ScriptEngineFactory: Provider jdk.nashorn.api.scripting.NashornScriptEngineFactory not a subtype
@PowerMockIgnore({"javax.script.*"})
2.2.3.3.3. Could not reconfigure JMX java.lang.LinkageError
@PowerMockIgnore({"javax.management.*"})
2.2.3.3.4. 解决用 @Value 注解注入的属性
ReflectionTestUtils.setField(invoiceTitleService, "invoiceTitleRegularExpression", "^[a-zA-Z0-9\\u4e00-\\u9fa5\\s\\uFF08\\uFF09\\u3001\\(\\)\\<\\>\\u300a\\u300b\\(\\)\\-]+$");
2.2.3.3.5. 解决通过 environment.getProperty("property") 获取配置文件中的配置项值
@Mock
Environment environment;

@BeforeMethod(alwaysRun = true)
public void init () {
    // 初始化当前测试类所有Mock注解模拟对象
    MockitoAnnotations.initMocks(this);
}

public void testXXX() {
    when(environment.getProperty("config.name")).thenReturn("tom");
}
2.2.3.3.6. 使用RestTemplate调用controller方法时,404错误
  • 检查controller类使用@RestController注解

3. 单元测试的最佳实践

3.1. 对 Controller 层的测试实践

模块 版本号 描述
Spring boot test 2.7.6 支持测试的核心内容
Spring boot test autoconfigure 2.7.6 支持测试的自动化配置
JUnit5 5.8.2  

3.1.1. maven 包依赖引入

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
  </dependency>

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

3.1.2. Springboot + JUnit

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringBootQuickStartApplicationTests {

    private MockMvc mvc;

    @Before
    public void setUp() throws Exception {
        mvc = MockMvcBuilders.standaloneSetup(new UserController()).build();
    }

    @Test
    public void contextLoads() throws Exception {
        RequestBuilder request = null;

        request = MockMvcRequestBuilders.get("/")
                .contentType(MediaType.APPLICATION_JSON);
        mvc.perform(request)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andDo(MockMvcResultHandlers.print())
                .andReturn();
   }
}
@SpringBootTest
// 使用spring的测试框架
@ExtendWith(SpringExtension.class)
class SpringbootQuickStartApplicationTests {

    private MockMvc mockMvc;

    @BeforeEach // 类似于junit4的@Before
    public void setUp() throws Exception {
        mockMvc = MockMvcBuilders.standaloneSetup(new UserController()).build();
    }

    @Test
    void contextLoads() throws Exception {
        RequestBuilder request = null;

        request = MockMvcRequestBuilders.get("/")
                .contentType(MediaType.APPLICATION_JSON);
        mockMvc.perform(request)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andDo(MockMvcResultHandlers.print())
                .andReturn();
    }
}

3.1.3. 使用随机端口测试

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MpServiceApplicationTests {

    @Autowired
    private TestRestTemplate testRestTemplate = null;

    @Test
    public void testApi() throws Exception {
        //一个键对应多个值, 如 put 方法: put(String, List<String>)
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("orderId", "ORDER20210312010000000046");
        //postForObject 默认只能映射 Map 类型返回,如果是实体类则映射不到属性的值,需要强转或者使用 postForEntity
        //Map orderMap = testRestTemplate.postForObject("/api/mp/order/info", params, Map.class);
        //if (!ObjectUtils.isEmpty(orderMap)) {
        //    MpOrder order = (MpOrder) orderMap;
        //    System.out.println("order = " + order);
        //}
        ResponseEntity<MpOrder> mpOrderResponseEntity = testRestTemplate.postForEntity("/api/mp/order/info", params, MpOrder.class);
        MpOrder order = mpOrderResponseEntity.getBody();
        System.out.println("order = " + order);
    }
}
3.1.3.1. TestRestTemplate 使用
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AccountControllerTests {
    @Autowired
    private TestRestTemplate restTemplate;
    private HttpEntity httpEntity;

    /**
     * 登录
     * @throws Exception
     */
    private void login() throws Exception {
        String expectStr = "{\"code\":0,\"msg\":\"success\"}";
        MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
        map.add("username", "183xxxxxxxx");
        map.add("password", "123456");
        ResponseEntity responseEntity = restTemplate.postForEntity("/api/account/sign_in", map, String.class);
        //添加cookie以保持状态
        HttpHeaders headers = new HttpHeaders();
        String headerValue = responseEntity.getHeaders().get("Set-Cookie").toString().replace("[", "");
        headerValue = headerValue.replace("]", "");
        headers.set("Cookie", headerValue);
        httpEntity = new HttpEntity(headers);
        assertThat(responseEntity.getBody()).isEqualTo(expectStr);
    }

    /**
     * 登出
     * @throws Exception
     */
    private void logout() throws Exception {
        String expectStr = "{\"code\":0,\"msg\":\"success\"}";
        String result = restTemplate.postForObject("/api/account/sign_out", null, String.class, httpEntity);
        httpEntity = null;
        assertThat(result).isEqualTo(expectStr);
    }

    /**
     * 获取信息
     * @throws Exception
     */
    private void getUserInfo() throws Exception {
        Detail detail = new Detail();
        detail.setNickname("疯狂的米老鼠");
        detail.setNicknamePinyin("fengkuangdemilaoshu");
        detail.setSex(1);
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        detail.setCreatedAt(sdf.parse("2017-11-03 16:43:27"));
        detail.setUpdatedAt(sdf.parse("2017-11-03 16:43:27"));
        Role role = new Role();
        role.setName("ROLE_USER_NORMAL");
        Set<Role> roles = new HashSet<>();
        roles.add(role);
        User user = new User();
        user.setId(1L);
        user.setPhone("183xxxxxxxx");
        user.setEmail("xxxxxx@gmail.com");
        user.setDetail(detail);
        user.setRoles(roles);
        ResultBean<User> resultBean = new ResultBean<>();
        resultBean.setData(user);
        ObjectMapper om = new ObjectMapper();
        String expectStr = om.writeValueAsString(resultBean);
        ResponseEntity<String> responseEntity = restTemplate.exchange("/api/user/get_user_info", HttpMethod.GET, httpEntity, String.class);
        assertThat(responseEntity.getBody()).isEqualTo(expectStr);
    }

    @Test
    public void testAccount() throws Exception {
        login();
        getUserInfo();
        logout();
    }
}
3.1.3.2. GET 请求测试
import org.junit.Assert;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;

import java.util.HashMap;
import java.util.Map;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MpServiceApplicationTests {

    @Autowired
    private TestRestTemplate testRestTemplate;

    @Test
    public void get() throws Exception {
        Map<String, String> multiValueMap = new HashMap<>();
        multiValueMap.put("username", "Jerry");
        Map result = testRestTemplate.getForObject("/test/getUser?username={username}", Map.class, multiValueMap);
        Assert.assertEquals(result, 0);
    }
}
3.1.3.3. POST 请求测试
import org.junit.Assert;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.Map;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MpServiceApplicationTests {

    @Autowired
    private TestRestTemplate testRestTemplate;

    @Test
    public void post() throws Exception {
        MultiValueMap multiValueMap = new LinkedMultiValueMap();
        multiValueMap.add("username", "Jerry");
        Map result = testRestTemplate.postForObject("/test/post", multiValueMap, Map.class);
        Assert.assertEquals(result, 0);
    }
}
3.1.3.4. 文件上传请求测试
import org.junit.Assert;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.util.Map;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MpServiceApplicationTests {

    @Autowired
    private TestRestTemplate testRestTemplate;

    @Test
    public void upload() throws Exception {
        Resource resource = new FileSystemResource("/home/javastack/test.jar");
        MultiValueMap multiValueMap = new LinkedMultiValueMap();
        multiValueMap.add("username", "Jerry");
        multiValueMap.add("files", resource);
        Map result = testRestTemplate.postForObject("/test/upload", multiValueMap, Map.class);
        Assert.assertEquals(result, 0);
    }
}
3.1.3.5. 文件下载请求测试
import com.google.common.io.Files;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;

import java.io.File;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MpServiceApplicationTests {

    @Autowired
    private TestRestTemplate testRestTemplate;

    @Test
    public void download() throws Exception {
        HttpHeaders headers = new HttpHeaders();
        headers.set("token", "Jerry");
        HttpEntity formEntity = new HttpEntity(headers);
        String[] urlVariables = new String[]{"admin"};
        ResponseEntity<byte[]> response = testRestTemplate.exchange("/test/download?username={1}", HttpMethod.GET, formEntity, byte[].class, urlVariables);
        if (response.getStatusCode() == HttpStatus.OK) {
            Files.write(response.getBody(), new File("/home/Jerry/test.jar"));
        }
    }
}

3.1.4. 使用Mock测试

使用 @MockBean 注解,以及虚拟数据进行测试,不会写入持久化数据库。(注意:这里仅做简单介绍和使用,在下一章节中详细介绍。)

import org.junit.jupiter.api.Test;
import org.mockito.BDDMockito;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MpServiceApplicationTests {

    @MockBean
    private MpUserrecvaddrService mpUserrecvaddrService;

    @Test
    public void testMock() {
        //构建虚拟对象
        MpUserrecvaddr mockAddr = new MpUserrecvaddr();
        mockAddr.setUraId("1");
        mockAddr.setUserId("001");
        mockAddr.setUraName("name_" + 1);
        mockAddr.setUraAddress("address_" + 1);
        //指定 Mock Bean 方法和参数,并返回虚拟对象
        BDDMockito.given(mpUserrecvaddrService.getById("1")).willReturn(mockAddr);
        //进行 Mock 测试
        MpUserrecvaddr addr = mpUserrecvaddrService.getById("1");
        System.out.println("addr = " + addr);
    }
}

3.2. Spring boot + Mockito 的测试实践

模块 版本号 描述
Springboot 2.7.6  
JUnit4 4.13.2  
Powermock 2.0.2 注意:目前PowerMock只支持JUnit4
Mockito-core 3.12.4  

3.2.1. maven 包依赖引入

<properties>
  <java.version>1.8</java.version>
  <spring-boot.version>2.7.6</spring-boot.version>
  <powermock.version>2.0.2</powermock.version>
  <mockito-core.version>3.12.4</mockito-core.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
      <exclusion>
        <artifactId>mockito-core</artifactId>
        <groupId>org.mockito</groupId>
      </exclusion>
    </exclusions>
  </dependency>
  <dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-module-junit4</artifactId>
    <version>${powermock.version}</version>
    <scope>test</scope>
    <exclusions>
      <exclusion>
        <artifactId>objenesis</artifactId>
        <groupId>org.objenesis</groupId>
      </exclusion>
    </exclusions>
  </dependency>
  <dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-api-mockito2</artifactId>
    <version>${powermock.version}</version>
    <scope>test</scope>
    <exclusions>
      <exclusion>
        <artifactId>mockito-core</artifactId>
        <groupId>org.mockito</groupId>
      </exclusion>
    </exclusions>
  </dependency>
  <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>${mockito-core.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>

3.2.2. 测试:获取用户信息接口

3.2.2.1. 业务代码
// User
import lombok.Data;

import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "user")
@Data
public class User {

    @Id
    private Long id;

    private String name;

    private Integer age;
}

// UserDao
import com.zrg.myspringbootdemo.demos.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

@Repository
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
}

// UserService
import com.zrg.myspringbootdemo.demos.entity.User;

public interface UserService {
    User getUserById(Long id);
}

// UserServiceImpl
import com.zrg.myspringbootdemo.demos.entity.User;
import com.zrg.myspringbootdemo.demos.dao.UserDao;
import com.zrg.myspringbootdemo.demos.services.MockMapper;
import com.zrg.myspringbootdemo.demos.services.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDao userDao;
    @Autowired
    private MockMapper mockMapper;

    @Override
    public User getUserById(Long id) {
        if (mockMapper.makeFile("/test")) {
            log.info("makeFile success");
        }
        return userDao.findById(id).orElse(null);
    }
}

// TestController
@RestController
@RequestMapping("/test")
public class TestController {
    @PostMapping("/getUser")
    public User getUser(@RequestBody GetUserDTO getUserDTO) {
        return userService.getUserById(Long.valueOf(getUserDTO.getUserId()));
    }
}
3.2.2.2. 测试类
import org.junit.Test;
import org.junit.Assert;
import org.junit.runner.RunWith;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class TestControllerTest {

    @Autowired
    private TestRestTemplate testRestTemplate;

    @MockBean
    private UserDao mockUserDao;

    @InjectMocks
    private TestController testController;

    @Before
    public void setUp() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void testGetUser() throws Exception {
        Long userId = 123L;
        User expectUser = new User();
        expectUser.setName("张三");
        expectUser.setAge(28);
        expectUser.setId(userId);
        GetUserDTO getUserDTO = new GetUserDTO();
        getUserDTO.setUserId(String.valueOf(userId));

        PowerMockito.when(mockUserDao.findById(Mockito.anyLong())).thenReturn(Optional.of(expectUser));

        ResponseEntity<User> result = testRestTemplate.postForEntity(
                                                                     "/test/getUser",
                                                                     new HttpEntity<>(getUserDTO),
                                                                     User.class);
        Assert.assertEquals(HttpStatus.OK, result.getStatusCode());
        Assert.assertNotNull(result.getBody());
    }
}

4. 附A-分层自动化测试

温馨提示:如果急于在项目中做单元测试实践和应用,可以先跳过本章节。

  1. 在有了持续交付、自动化测试等基本理念之后,我们梳理一下分层自动化实施的具体方法。
  2. 传统的 “集成测试” 更多是通过调用 “接口” 来完成自动化测试用例设计,而并非是单独测试 “接口” 本身。通过调用 “接口” 完成整个后端的业务逻辑测试与 “UI” 自动化测试相同都需要依赖整个后端服务器被正常的加载,且环境 “初始化” 成功。
  3. 当实施 UI、API 自动化测试时,团队经常会有一个疑问?为什么 UI 自动化已经保证了业务核心流程的正确性,还需要在通过 API 测试进行 “重复” 的用例开发?从代码覆盖率角度而言,相同的代码逻辑被不用的测试类型覆盖确实没有什么意义。这也引发了对于测试策略的思考,手工、UI、API、Unit 应该如何去分配资源。
  4. 通过合理的测试策略的划分减少测试过程种的浪费,这也是DevOps 三步法则之三 “精益” 思想的体现之一。长链路的测试带来测试设计上的难题,比如:用例之间的依赖、测试数据的依赖、环境的依赖等,虽然这些问题都可以通过 “工程化” 方式得以缓解,但是测试用例执行效率、如何精准发现被测代码具体问题依然没有能有效的解决,导致流水线执行失败时排查 “用例问题” 或 “被测代码问题” 的效率极其低下。

layer-architecture.png
针对上图的技术架构如果要进行分层自动化测试,测试大致可以分为前端测试、后端测试和集成测试。需要注意的是实际项目中后端往往是微服务架构。

4.1. 前端测试

  1. 传统的分层自动化测试对 UI 层更多的是使用 Selenium、Playwright、Nightwatch 等 “外置驱动” 的方式开展自动化测试,比如:启动浏览器,进行模拟用户真实的操作。而分层自动的思想是通过 “内置驱动” 的方式通过测试代码驱动研发代码的方式开展自动化测试。前端工程通过分层技术进行拆解之后可以达到独立隔离测试前端以提高持续测试的成功率。
  2. 以 Vue 项目为例,分层自动化在前端测试时会将自动化测试分层三个层面。函数级别 JavaScript、组件级别 component、端到端级别 End-to-End。在每一次层测试的重点及使用的技术栈不同。
    • 函数级别的测试通常会使用单元测试框架 jest 对函数本身进行测试,通过 “测试数据” 调用函数验证函数的执行逻辑是否符合预期,对于函数内部的调用链通过 jest.mock() 完成隔离。
    • 组件级别的测试通过 Vue 官方的 Vue Test Utils 工具对组件进行浅渲染(shallowMount)只渲染组件的第一层 DOM 结构,其嵌套的子组件不会被渲染出来,从而使得渲染的效率更高,单元测试执行的速度也会更快。
    • UI(E2E,端到端)自动化测试通过 Mock Server 作为前后端挡板可以实现无需启动后端服务器即可完成前端的 UI 自动化测试,极大的提高了 UI 自动化测试的稳定性,当然UI 自动化测试本身也依赖于被测代码的规范性。
  3. 通过分层自动化使得前端分层测试职责更加明确,也减少了外部依赖。关于函数、组件、端到端,其投入的测试比例可以参考 “测试金字塔模型”。前端分层自动化测试拆分之后如下图所示: frontend-auto-testing.png
    函数测试

    通过给定的一组数据校验输入、输出是否符合预期结果,及函数执行是否对其他资源产生了影响。比如:使用 JavaScript 单元测试框架 Jest 进行函数的测试。Jest 可以理解为与 JUnit 5 具有相同的作用。Jest 功能更加齐全,比如:内置函数 mock、代码覆盖率、快照测试等特性。Jest 支持前端主流框架,比如:TypeScript, Node, React, Angular, Vue 等等。使用 Jest 进行单元测试示例代码如下:

    /**
     * 使用 Mock 函数定义 Mock 函数的实现体
     */
    test('Test Mock function implements', () => {
      let sum = jest.fn().mockImplementation(
        () => {
          console.log('mockImplementation function be invoked!')
          return 'Miller_' + 30 + '_Male'
        }
      )
      expect(sum(1, 2, 3)).toMatch(/Miller/)
    })
    
    组件测试

    在 Vue 项目中组件就是一个个.vue 文件,组件包含三大块内容,HTML< template>、CSS< style>、Function< script>。通过模拟行为验证组件的函数、数据、事件是否正确。而不是像单元测试那样直接调用函数验证函数和数据的正确性。相对与单元测试,组件测试需要加载更多的代码进行测试。对于组件的测试通常还需要隔离组件与组件之间的依赖,以及 Mock 组件内部的网络请求等。在 Vue 项目中可以通过 Vue.extend 渲染组件,然后通过构造器挂载组件获取浏览器上下文对象。示例代码如下:

    import Vue from 'vue'
    import HelloWorld from '@/views/HelloWorld'
    describe('HelloWorld.vue', () => {
      // Vue 创建工程是自带的测试用例
      it('should render correct contents', () => {
        // 通过 Vue.extend 渲染 HelloWorld 组件
        const Constructor = Vue.extend(HelloWorld)
        // 获取浏览器上下文对象 vm, 这个 vm 对象包含了 HelloWorld 组件的所有信息
        const vm = new Constructor().$mount()
        expect(vm.$el.querySelector('.hello h1').textContent)
          .toEqual('Welcome to Your Vue.js App')
      })
    })
    

    也可以通过官方的 Vue Test Utils 工具包对组件进行测试,其支持浅渲染(shallowMount)的特性,可以只渲染组件的第一层 DOM 结构,其嵌套的子组件不会被渲染出来,从而使得渲染的效率更高,单元测试执行的速度也会更快,使用 VueTestUtils 工具包测试组件示例代码如下:

    // 导入Vue Test Utils 工具库
    import {shallowMount} from '@vue/test-utils'
    import HelloWorld from '@/views/HelloWorld'
    describe('TestSuite_HelloWorld', () => {
      test('TestCase_CheckChangeData', () => {
        // Given...测试用例初始化的条件和初始状态
        const wrapper = shallowMount(HelloWorld)
        // When...执行动作
        const message = wrapper.vm.$data.msg
        // Then...断言动作带来的结果
        expect(message).toMatch('Welcome')
        // 修改 data 属性内容
        wrapper.setData({msg: 'Miller'})
        expect(wrapper.vm.$data.msg).toMatch('Miller')
      })
    })
    
    端到端测试

    以自然人类的操作方式对被测系统进行模拟点击、输入等行为,校验系统是否符合预期结果。通常这种测试需要依赖被测系统的稳定性,需要使用完整的真实环境进行测试。Vue 创建完之后默认端到端测试工具为 Nightwatch。如果你是一个前端工程师那么使用 Nightwatch 是个不错的选择,它可以将测试代码打包到工程中,方便进行代码协作、版本管理是个非常好的实践,其底层使用的是 selenium 作为测试驱动框架。示例代码如下:

    module.exports = {
      'e2e_Login_Page_CheckElement': function (browser) {
        // 使用 nightwatch.conf.js 中的默认地址和端口
        const devServer = browser.globals.devServerURL
        browser
          .url(devServer)
          .waitForElementVisible('#app', 5000)
          .assert.containsText('h3', '持续测试-分层自动化')
          .assert.elementCount('h3', 1)
          .end()
      }
    }
    

    如果你是一个测试工程师那么推荐使用 Playwright 进行端到端的自动化测试,它支持一些很方便的特性能够快速、稳定的构建自动化测试用例,比如:智能等待、录制、运行中调试、回放等特性。示例代码如下:

    @DisplayName(value = "Playwright测试端到端用例集")
    public class PlaywrightEndToEndTests {
        @DisplayName("测试添加缺陷流程")
        @Test
        public void testAddIssueFlow() {
            // Given.
            try (Playwright playwright = Playwright.create(options)) {
                BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions().setHeadless(false).setSlowMo(200);
                // When.
                Browser browser = playwright.chromium().launch(launchOptions);
                // 启用追踪功能,这样可以在运行自动化脚本之后查看整个执行的过程
                BrowserContext context = browser.newContext();
                context.tracing().start(new Tracing.StartOptions().setScreenshots(true).setSnapshots(true).setSources(true));
                Page page = context.newPage();
                page.navigate(url);
                // 定位元素
                page.locator("#username").fill("admin@aliyun.com");
                page.locator("button.el-button.submit.el-button--primary").click();
                // 跳转到缺陷列表
                page.navigate(url + "/issues/list");
                page.locator("#addIssue").click();
                page.locator("#issueTitle").fill("playwright test by Miller");
                page.locator("#issueHandler").click();
               page.locator("li[id=\"miller.shan@aliyun.com\"]").click();
                page.locator("#submit").click();
                // Then
                assertThat(page.content(), Matchers.containsStringIgnoringCase("playwright test by Miller"));
                // 暂停启动调试、录制模式
                page.pause();
                context.tracing().stop(new Tracing.StopOptions().setPath(Paths.get("trace.zip")));
            }
        }
    }
    
  4. 综上,得出前端测试的渐进性图: unit-test.jpg


通过以上技术方案可以实现前端的分层自动化测试,但是这里需要注意的一点是当执行端到端自动化测试时由于需要依赖后端服务正常运行,所以需要使用 MockServer 技术进行前后端隔离。
为了在端到端的自动化测试过程中正确断言,保证后台服务器处于正常运行状态是必备的,前端开发、测试往往会因为后端的实现进度而滞后,而由于团队中数据的公用也会影响效率。

  • 比较理想的前后端分离研发,后端在项目初期就已将表结构定义完成,配套生成了 Java Bean 对象,并且将接口定义完成,提供至少一个 Mock 的数据返回;
  • 前端可以根据 Swagger 自动生成的文档进行联调,随着后端接口的逐步完成,前端也与之同步,达到业务逻辑的同步实现。
  • 当出现前端需要一个新的接口,后端还没有该接口的定义和实体的开发时,前端可以根据原型设计进行前端静态页面的开发,通过使用 Mock 技术来访问一个非真实的服务端接口。这样就可以在不依赖后端服务器,进行独立的前端开发及测试。


其内部处理流程可以参考如下图: internal-handle-flow.png MockServer 不单单可以作为前后端隔离服务,而且也可以用于后端的微服务隔离,它不但支持 JSON 文件数据的方式作为响应数据,而且也支持代码的方式,比如:通过 JUnit 方式对微服务之前的调用进行隔离。也可以作为一个代理服务器用于代理负载均衡器 (比如 Nginx)。作为代理服务其状态流程如下图: proxy-service-status-flow.png

4.2. 后端测试

后端根据被测系统的架构可以拆分为 Mapper、Service、Controller 等层测试,其分层策略大概如下图: backend-auto-testing.png

Mapper 测试

对于 Mapper 层的测试更多的是关注与 SQL 语句的正确性,还有就是如果使用了动态 SQL 特性,那么需要校验不同分支的逻辑正确性。Mapper 测试可以使用 mybatis-config-test.xml 文件配置数据源,然后使用原生的 SqlSessionFactory 创建与数据库的 session 连接,进而完成 CRUD 的操作。这种方式的在于无需依赖 SpringBoot 即可完成 Mapper 层的测试。示例代码如下:

@DisplayName("使用纯Java代码测试Mapper层接口及Xml")
public class CalculatorHasDBMapperTestByJavaCodeTests {
    private static SqlSession sqlSession;
    private CalculatorHasDBMapper mapper;
    @BeforeAll
    public static void beforeAll() throws IOException {
        // 从 mybatis-config-test.xml 读取 MyBatis 配置
        Reader reader = Resources.getResourceAsReader("mybatis-config-test.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);
        sqlSession = sqlSessionFactory.openSession();
    }
    @AfterAll
    public static void afterAll() {
        sqlSession.close();
    }
    @BeforeEach
    public void beforeEach() {
        mapper = sqlSession.getMapper(CalculatorHasDBMapper.class);
    }
    @AfterEach
    public void afterEach() {
        // 将修改提交到数据库,不提交则数据库不生效,如果仅仅只是测试 Mapper 接口可以不提交
        sqlSession.commit();
    }
@DisplayName("测试Insert语句")
    @Test
    public void testInsert() {
        Calculator calculator = new Calculator();
        calculator.setFirstNumber(1.0);
        calculator.setSecondNumber(2.0);
        calculator.setResult(3.0);
        Integer insert = mapper.insert(calculator);
        //System.out.println(calculator.getId());
        // 断言影响的记录数据为1
        assertThat(insert, Matchers.is(1));
    }
}

第二种方式是通过 MyBatis 官方提供的@MybatisTest注解测试 MyBatis,使用@MybatisTest注解会自动配置 SqlSessionFactory,并且自动配置一个内存数据库。使用@MyBatisTest注解编写的测试用例在测试结束时会自动进行事务的回滚,而且@MybatisTest运行时是不会加载其他的 Bean 组件到当前测试用例。

@MybatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DisplayName("使用@MyBatisTest测试Mapper")
public class CalculatorHasDBMapperTestByMyBatisTestAnnotationTests {
    @Autowired
    private CalculatorHasDBMapper calculatorHasDBMapper;
    @DisplayName("测试Insert语句")
    @Test
    public void testInsert() {
        Calculator calculator = new Calculator();
        calculator.setFirstNumber(1.0);
        calculator.setSecondNumber(2.0);
        calculator.setResult(3.0);
        Integer insert = calculatorHasDBMapper.insert(calculator);
        assertThat(insert, Matchers.is(1));
    }
}

第三种方式是通过 Spring 的事务功能进行 Mapper 的测试。使用 @Transactional 的方式数据并不会真实入库,可以理解为之前用纯 Java 代码的 SqlSession 的方式不做 commit,主键自增 ID 会被使用掉了,但数据会被回滚,这是事务的默认行为。

@Transactional
@SpringBootTest
@DisplayName("使用Spring的事务Transaction隔离数据")
public class CalculatorHasDBMapperTestByTransactionTests {
    @Autowired
    private CalculatorHasDBMapper calculatorHasDBMapper;
    @DisplayName("测试Insert语句")
    @Test
    public void testInsert() {
        Calculator calculator = new Calculator();
        calculator.setFirstNumber(1.0);
        calculator.setSecondNumber(2.0);
        calculator.setResult(3.0);
        Integer insert = calculatorHasDBMapper.insert(calculator);
        assertThat(insert, Matchers.is(1));
    }
}

第四种方式是使用 H2 Database 内存数据库,通过将数据源切换到 application-h2.properties 配置的方式让所有测试数据都指向内存数据库,通过会结合 flyway 一起使用,使用这种方式对于 SQL 语句的语法有一定要求。示例代码如下:

@ActiveProfiles("h2")
@SpringBootTest
@DisplayName("使用内存数据库H2")
public class CalculatorHasDBMapperTestByH2Tests {
    @Autowired
    private CalculatorHasDBMapper calculatorHasDBMapper;
    @DisplayName("测试Select语句")
    @Test
    public void testSelect() {
        List<Calculator> select = calculatorHasDBMapper.getCalculatorList();
        assertThat(select.size(), Matchers.greaterThanOrEqualTo(0));
        //select.forEach(System.out::println);
    }
}
Controller 测试

对于 Controller 层测试的重点应该关注于输入数据的正确性校验,以及返回数据的结构是否正确即可。因为 Controller 层调用的 Service 已经在 Service 层进行单元验证了。同理 Controller 层也是一个普通的 Java 类,所以我们可以通过 Mock 的方式把它当作普通类进行测试,但是这样就会丢失 HTTP 服务器的数据报文。将 Controller 作为普通 Java 类测试示例代码如下:

@DisplayName("测试计算器Controller层代码")
@ExtendWith(MockitoExtension.class)
public class CalculatorControllerTestByMockitoTests {
    @InjectMocks
    private CalculatorController calculatorController;
    @Mock
    private CalculatorServiceImpl calculatorService;
    // 测试数据
    private Calculator calculator;
    /**
     * 直接调用 Controller 对象的方法,把 Controller 当普通类进行测试,而不是 HTTP 接口
     */
    @DisplayName(value = "测试Restful的POST方法,参数为JSON")
    @Test
    public void testPostMethod() {
        when(calculatorService.add(anyDouble(), anyDouble())).thenReturn(calculator);
        assertAll(
                // 构造POST方法请求参数, 第一种情况:传null
                () -> {
                    String nullObject = calculatorController.testPostMethod(null);
                    assertThat(nullObject, equalToIgnoringCase("Request body can't be empty."));
                };
    }
}

第二种方式是使用 SpringBoot 中提供了@WebMvcTest 注解可以单独注入需要测试的 Controller,通过 MockMvc 模拟客户端发送请求到服务器,然后通过@MockBean注解 Mock 具体 Service 方法的行为,进而达到单独对 Controller 测试的目的。

package com.github.millergo.controller;
@WebMvcTest(value = {CalculatorController.class})
@DisplayName("Test CalculatorController by @WebMvcController")
public class CalculatorControllerTestByWebMvcTestTests {
    @MockBean
    private CalculatorServiceImpl calculatorService;
    @Autowired
    private MockMvc mockMvc;
    private Calculator calculator;
    @DisplayName("Test RESTFul GET Method")
    @Test
    public void testGetMethod() throws Exception {
        // Stubbing, 隔离 Service
        when(calculatorService.add(anyDouble(), anyDouble())).thenReturn(calculator);
        // MockMvc 拥有 Client 能力,可以对服务器发送请求
        ResultActions resultActions = mockMvc.perform(MockMvcRequestBuilders.get("/calc/add/1/2"));
resultActions.andExpect(MockMvcResultMatchers.status().isOk())
resultActions.andDo(MockMvcResultHandlers.print());
    }
}

对于Controller 层的测试推荐使用@WebMvcTest注解进行测试,当然使用@SpringBootTest、REST-Assured 等其他方式也支持测试 Controller。

Service 测试

Service 层测试是整个后端最重要的测试层,其关乎后端系统业务的正确性,在实际项目中大部分的后端测试都是在校验 Service 的正确性。目前大部分团队在进行 SpringBoot 单元测试基本上就是使用@SpringBootTest注解标注进行,这种方式测试用例执行期间会启动 Spring IOC 容器加载所有依赖的 Bean 对象,并且自动注入所有对象,测试用例通过调用 Service 层的方法进行单元测试,这样做带来的弊端是测试用例执行时需要被依赖的所有服务均处于正常运行,现在微服务下很多 RPC 的调用,我们以为是调用一个方法,实际可能调用了成千上万的服务。并且需要保证 Service 依赖的数据库、Kafka、Redis 等都正常运行,否则测试用例很可能导致失败。而分层自动化测试则不同,分层自动化通过 Test Double(测试替身) 的方式对 Service 进行隔离,通过 Mock 技术可以单独针对某一个方法甚至是某一行代码行进行单独的测试,而不依赖于方法内部的调用链路。由于 Mapper 层我们已经单独对其进行了测试,所以测试 Service 层时完全可以自己构造测试数据,Mock 掉 Mapper 层的依赖,达到独立测试 Service。Service 层测试可以使用 Mockito、PowerMock、spring-test 等技术手段。比如:下面的代码使用 spring-test 结合 Mockito 进行一个简单的计算器测试示例代码。

@ExtendWith(MockitoExtension.class)
public class UseMockitoInJunit5ByAnnotation {
    private CalculatorServiceImpl calculatorService;
    @Mock
    private CalculatorMapper mockCalculatorMapper;
    @Test
    public void testGetCalcResultUseMockito() {
        this.calculatorService = new CalculatorServiceImpl();
        ReflectionTestUtils.setField(calculatorService, "calculatorMapper", mockCalculatorMapper);
Mockito.when(mockCalculatorMapper.getCalcResultByDesc("desc")).thenReturn(4.0);
        calculatorService.getCalcResult("desc");
        // 验证 Mock 的对象 CalculatorMapper 被调用的次数为1
        Mockito.verify(mockCalculatorMapper, Mockito.times(1)).getCalcResultByDesc("desc");
    }
}

CalculatorServiceImpl 里需要注入 CalculatorMapper 所以这里需要借助 Spring-test 的反射测试工具来实现将 Mock 出来的 CalculatorMapper 注入到 CalculatorServiceImpl 类中。当然也可以不使用 spring-test 包的反射工具类注入对象而使用 Mockito 的@InjectMocks注解自动注入 CalculatorServiceImpl 的一个 Mock 对象。示例代码如下:

@ExtendWith(MockitoExtension.class)
public class MockitoInjectMocksTests {
    // 将 @Mock 注解的对象注入到 @InjectMocks 对象中的属性
    @InjectMocks
    private CalculatorServiceImpl calculatorService;
    @Mock
    private CalculatorMapper mockCalculatorMapper;

    @Test
    public void testInjectMocks() {
       calculatorService.getCalcResult(null);
    }
}

InjectMocks 注解会尝试通过构造方法自动注入需要的 Mock 对象,如果没有构造方法,则会使用 setXxx() 尝试进行注入。在后面的实践章节将详细介绍使用方法。

4.3. 集成测试

  1. 集成测试的 “集成 “是一个相对概念,一般我们将测试会分为几个级别,单元测试、集成测试、系统测试、验收测试等。
  2. 集成测试更多指的是在单元被组合在一起之后的测试。比如:方法之间的集成、类之间的集成、模块之间的集成、微服务之间的集成、服务与中间件的集成、前后端的集成、系统之间的集成等。在上面的前端测试中组件测试和端到端测试可以理解为是一种集成测试。后端测试中对 Controller 使用 REST-Assured 也可以理解为是一种集成测试。
  3. 在集成测试中更多关注的是模块之间组合在一起之后的正确性。如果直接使用 Postman、HttpClient、REST-Assured 就可以认为是后端的集成测试或者叫服务端测试。但是如果只是想测试 Controller 与 Service 之间的集成测试,那么则需要隔离 Mapper,这种方式通常在调式中完成,而非自动化的集成测试,或者使用 H2 Database 进行后端的集成测试。 integration-auto-testing.png

4.4. 分层测试架构

通过使用分层自动化中的这些技术能有有效的进行 “真正” 分层自动化测试,但是对于传统的端到端测试、服务端测试也是有其价值的,具体还需要根据项目及团队进行取舍。传统的分层自动化与分层自动化也可以通过配置化的方式选择是否使用测试替身 Test Double 的切换,不过这个属于测试框架、测试平台层面需要去配合实现。关于分层自动化测试架构完整图可以参考如下: layer-auto-testing-architecture.png

5. 附B-开发模式

TDD

测试驱动开发,英文为 Testing Driven Development,强调的是一种开发方式,以测试来驱动整个项目,即先根据接口完成测试编写,然后在完成功能是要不断通过测试,最终目的是通过所有测试。本质上,我们重复遵循三个简单的步骤:

  1. 为要添加的下一个功能编写测试。
  2. 编写功能代码直到测试通过。
  3. 重构新旧代码以使其结构良好。

test-driven-development.png

tdd.jpg

BDD
行为驱动开发,英文为 Behavior Driven Development,BDD 的核心价值是体现在正确的对系统行为进行设计,所以它并非一种行之有效的测试方法。它强调的是系统最终的实现与用户期望的行为是一致的、验证代码实现是否符合设计目标。但是它本身并不强调对系统功能、性能以及边界值等的健全性做保证,无法像完整的测试一样发现系统的各种问题。但 BDD 倡导的用简洁的自然语言描述系统行为的理念,可以明确的根据设计产生测试,并保障测试用例的质量。 bdd.jpg
ATDD
验收测试驱动开发,英文为 Acceptance Test Driven Development,通过单元测试用例来驱动功能代码的实现,团队需要定义出期望的质量标准和验收细则,以明确而且达成共识的验收测试计划(包含一系列测试场景)来驱动开发人员的TDD实践和测试人员的测试脚本开发。面向开发人员,强调如何实现系统以及如何检验。
DDD

Domain-drive Design,领域驱动设计。其目的是以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型,再有该模型驱动软件设计和开发。 ddd.jpg 领域模型:

  • 领域模型是是对具有某个边界的领域的一个抽象,反映了领域内用户需求的本质
  • 领域模型只反映业务,和技术无关
  • 领域模型可以反映领域中的实体和过程
  • 领域模型确保业务逻辑都在一个模型中,有助于提高应用的维护性和可重用性
  • 领域模型可以让开发人员相对平滑地将业务知识转换为软件架构
  • 领域模型贯穿软件分析、设计,以及开发的整个过程
  • 建立正确的领域模型需要领域专家、设计、开发人员积极沟通共同努力,是大家对领域内的业务不断深入,从而不断细化和完善领域模型
  • 领域模型的表达方式有多种
  • 领域模型是整个软件的核心,设计足够精良且符合业务需求的领域模型能够更快速的响应需求变化

领域驱动设计的分成架构:

  • 用户界面/表现层
  • 应用层
  • 领域层 - 表达业务概念,业务信息和业务规则
  • 基础设施层

业务对象的职责和策略:

  • 实体(Entities):具备唯一ID,能够被持久化,具备业务逻辑,对应业务对象
  • 值对象(Value objects):不具有唯一ID,由对象的属性描述,一般为内存中的临时对象,可以用来传递参数或对实体进行补充描述。
  • 工厂(Factories):主要用来创建实体,目前架构实践中一般采用IOC容器来实现工厂的功能
  • 仓库(Repositories):用来管理实体的集合,封装持久化框架
  • 服务(Services):为上层建筑提供可操作的接口,负责对领域对象进行调度和封装,同时可以对外提供各种形式的服务


TDD 和 BDD 有各自的使用场景,BDD 一般偏向于系统功能和业务逻辑的自动化测试设计;而 TDD 在快速开发并测试功能模块的过程中则更加高效,以快速完成开发为目的。DDD 则较为复杂

mixing-dev.jpg