一、什么是单元测试

1.1 什么是软件测试

软件测试(英语:Software Testing),描述一种用来促进鉴定软件的正确性、完整性、安全性和质量的过程。换句话说,软件测试是一种实际输出与预期输出之间的审核或者比较过程。软件测试的经典定义是:在规定的条件下对程序进行操作,以发现程序错误,衡量软件质量,并对其是否能满足设计要求进行评估的过程。

1.2 什么是单元测试

单元测试(英语:Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
通常来说,程序员每修改一次程序就会进行最少一次单元测试,在编写程序的过程中前后很可能要进行多次单元测试,以证实程序达到软件规格书要求的工作目标,没有程序错误;虽然单元测试不是什么必须的,但也不坏,这牵涉到项目管理的政策决定。

1.3 单元测试的优点

  • 提升软件质量

优质的单元测试可以保障开发质量和程序的鲁棒性。在大多数互联网企业中开发工程师在研发过程中都会频繁地执行测试用例,运行失败的单测能帮助我们快速排查和定位问题 使问题在被带到线上之前完成修复。正如软件工程界的一条金科玉律----越早发现的缺陷,其修复成本越低。一流的测试能发现未发生的故障;二流的测试能快速定位故障的发生点;三流的测试则疲于奔命,一直跟在故障后面进行功能回归。

  • 促进代码优化

单元测试是由开发工程师编写和维护的,这会促使开发工程师不断重新审视自己的代码,白盒地去思考代码逻辑 更好地对代码进行设计,甚至想方设法地优化测试用例的执行效率。这个过程会促使我们不断地优化自己的代码,有时候这种优化的冲动是潜意识的。

  • 增加重构自信

代码重构往往是牵一发而动全身的。当修改底层数据结构时,上层服务经常会受到影响。有时候只是简单地修改一个字段名称,就会引起一系列错误。但是在有单元测试保障的前提下,重构代码时我们会很自然地多一分勇气,看到单元测试100%执行通过的刹那充满自信和成就感。

1.4 单元测试的基本原则

宏观上,单元测试要符合AIR原则;微观上,单元测试的代码层面要符合BCDE原则。AIR 即空气 单元测试亦是如此。当业务代码在线上运行时可能感觉不到测试用例的存在和价值,但在代码质量的保障上,却是非常关键的。新增代码应该同步增加测试用例,修改代码逻辑时也应该同步保证测试用例成功执行。AIR具体包括: - A : Automatic (自动化) - I : Independent (独立性) - R : Repeatable (可重复)

单元测试应该是全自动执行的。测试用例通常会被频繁地触发执行,执行过程必须完全自动化才有意义。如果单元测试的输出结果需要人工介入检查,那么它一定是不合格的。单元测试中不允许使用 System.out 来进行人工验证,而必须使用断言来验证。 为了保证单元测试稳定可靠且便于维护,需要保证其独立性。用例之间不允许互相调用,也不允许出现执行次序的先后依赖。如下警示代码所示testMethod2 需要调用 testMethod1。在执行相调用,也不允许出现执行次序的先后依赖。如下警示代码所示testMethod2 时会重复执行验证testMethod1 ,导致运行效率降低。更严重的是,testMethod1 的验证失败会影响 testMethod2的执行。

@Test
public void testMethodl() {
  ...
}
@Test
public void testMethod2() {
  testMethodl ();
...
}

编写单元测试时要保证测试粒度足够小,这样有助于精确定位问题,单元测试用例默认是方法级别的。单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试需要覆盖的范围。编写单元测试用例时,为了保证被测模块的交付质量,需要符合BCDE原则。 - B: Border 边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。 - C: Correct 正确的输入,并得到预期的结果。 - D: Design 与设计文档相结合,来编写单元测试。 - E : Error 单元测试的目标是证明程序有错,而不是程序无错。为了发现代码中潜在的错误,我们需要在编写测试用例时有一些强制的错误输入(如非法数据、异常流程、非业务允许输入等)来得到预期的错误结果。

1.5 单元测试的覆盖率

单元测试是一种白盒测试 测试者依据程序的内部结构来实现测试代码。单测覆盖率是指业务代码被单测测试的比例和程度,它是衡量单元测试好坏的一个很重要的指标,各类覆盖率指标从粗到细、从弱到强排列如下。 1. 粗粒度的覆盖率 粗粒度的覆盖包括类覆盖和方法覆盖两种。类覆盖是指类中只要有方法或变量被测试用例调用或执行到,那么就说这个类被测试覆盖了。方法覆盖同理 只要在测试用例执行过程中,某个方法被调用了,则无论执行了该方法中的多少行代码,都可以认为该方法被覆盖了。从实际测试场景来看,无论是以类覆盖率还是方法覆盖率来衡量测试覆盖范围,其粒度都太粗了。 2. 细粒度的覆盖 细粒度的覆盖包括以下几种。 - 行覆盖(Line Coverage) 行覆盖也称为语句覆盖,用来度量可执行的语旬是否被执行到。行覆盖率的计算公式的分子是执行到的语句行数,分母是总的可执行语句行数。示例代码如下:

package com.daming.junit_test;

public class CoverageSampleMethods {
    public Boolean testMethod(int a, int b, int c) {

        boolean result = false;
        if (a == 1 && b == 2 || c == 3) {
            result = true;
        }
        return result;
    }
}

以上方法中有5行可执行语句和3个入参,针对此方法编写测试用例如下:

@Test
@DisplayName("line coverage sample test")
void testCoverageSample() {

    CoverageSampleMethods coverageSampleMethods = new CoverageSampleMethods();
    Assertions.assertTrue(coverageSampleMethods.testMethod(1, 2, 0));
}

以上测试用例的行覆盖率是 100% ,但是在执行过程中 c==3 的条件判断根本没有被执行到,a!=1并且c!=3 的情况难道不应该测试一下吗?由此可见,行覆盖的覆盖强度并不高,但由于容易计算,因此在主流的覆盖率工具中,它依然是一个十分常见的参考指标。 - 分支覆盖(Branch Coverage) 分支覆盖也称为判定覆盖,用来度量程序中每一个判定分支是否都被执行到。分支覆盖率的计算公式中的分子是代码中被执行到的分支数,分母是代码中所有分支的总数。譬如前面例子中,(a== 1 && b == 2 || c = 3)整个条件为一个判定,测试数据应至少保证此判定为真和为假的情况都被覆盖到。分支覆盖容易与下面要说的条件判定覆盖混淆,因此我们先介绍条件判定覆盖的定义,然后再对比介绍两者的区别。 - 条件判定覆盖( Condition Decision Coverage ) 条件判定覆盖要求设计足够的测试用例,能够让判定中每个条件的所有可能情况至少被执行一次,同时每个判定本身的所有可能结果也至少执行一次。例如(a== 1 && b == 2 ||c=3) 这个判定中包含了3种条件,即a== 1 、 b == 2 、c=3。为了便于理解 ,下面我们仍使用行覆盖率中的 testMethod 方法作为被测方法,测试用例如下:

@ParameterizedTest
@DisplayName("Condition Decision coverage sample test result true")
@CsvSource({ "0,2,3", "1,0,3" })
void testConditionDecisionCoverageTrue(int a, int b, int c) {

    CoverageSampleMethods coverageSampleMethods = new CoverageSampleMethods();
    Assertions.assertTrue(coverageSampleMethods.testMethod(a, b, c));
}

@Test
@DisplayName("Condition Decisior coverage sample test result false")
void testConditionDecisionCoverageFalse() {

    CoverageSampleMethods coverageSampleMethods = new CoverageSampleMethods();
    Assertions.assertTrue(coverageSampleMethods.testMethod(0, 0, 0));
}

通过@ParameterizedTest 我们可以定义一个参数化测试,@CsvSource 注解使得我们可以通过定义一个 String 数组来定义多次运行测试时的参数列表,而每一个String 值通过逗号分隔后的结果,就是每一次测试运行时的实际参数值。我们通过两个测试用例分别测试判定结果为 true和false 这两种情况。第一个测试用例 testConditionDecisionCoverageTrue 会运行两次, a、b、c这三个参数的值分别为0、2、3和1、0、3;第二个测试用例 testConditionDecisionCoverageFalse 3个参数的值都为0。在被测方法testMethod中,有个判定(a== 1 && b == 2 || c == 3)包含了三个条件(a == 1、b == 2、c==3),判定的结果显而易见有两种(true、 false ), 我们已经都覆盖到了。另外,我们设计的测试用例,也使得上述三个条件真和假的结果都取到了。因此,这个测试用例满足了条件判定覆盖。条件覆盖不是将判定中的每个条件表达式的结果进行排列组合,而是只要每个条件表达式的结果true和false测试到了就OK了。因此,我们可以这样推论:完全的条件覆盖并不能保证完全的判定覆盖。 - 条件组合覆盖( Multiple Condition Coverage) 条件组合覆盖是指判定中所有条件的各种组合情况都出现至少一次。还是以 (a == 1 && b == 2 || c == 3)这个判定为例,我们在介绍条件判定覆盖时,忽略了如a==1、b ==2、c == 3等诸多情况。针对被测方法 testMethod ,满足条件组合覆盖的一个测试用例如下:

@ParameterizedTest
    @DisplayName("Mult ple Condition Coverage sample test result true")
    @CsvSource({ "1,2,3", "1,2,0", "1,0,3", "0,2,3", "0,0,3" })
    void testMultipleConditionCoverageSampleTrue(int a, int b, int c) {

        CoverageSampleMethods coverageSampleMethods = new CoverageSampleMethods();
        Assertions.assertTrue(coverageSampleMethods.testMethod(a, b, c));
    }

    @ParameterizedTest
    @DisplayName( "Multiple Condit on Coverage sample test result false " )
    @CsvSource({
            "1,0,0",
            "0,0,0",
            "0,2,0"
    })
    void testMultipleConditionCoverageSampleFalse(int a , int b , int c) {
        CoverageSampleMethods coverageSampleMethods
                = new CoverageSampleMethods();
        Assertions.assertFalse(coverageSampleMethods. testMethod(a , b , c));
    }

这组测试用例同时满足了( a==1, b == 2, c ==3 )为( true,true, true )、( true,true, false )、( true, false, true )、( true, false, false )、( false, true, true )、( false,true, false )、( false, false, true )、( false, false , false )这种情况。对于一个包含了n个条件的判定 至少需要2个测试用例才可以。虽然这种覆盖足够严谨,但无疑给编写测试用例增加了指数级的工作量。 - 路径覆盖( Path Coverage) 路径覆盖要求能够测试到程序中所有可能的路径。在 testMethod 方法中,可能的路径有① a=1 ,b=2②a=1,b!=2,c=3③a=1,b!=2,c!=3④a!=1,c=3⑤a!=1,c=3这五种。当存在“||”时 如果第一个条件已经为 true ,则不再计算后边表达式的值。而当存在“&&” 时,如果第一个条件已经为 false , 则同样不再计算后边表达式的值。满足路径覆盖的测试用例如下:

@ParameterizedTest
@DisplayName("Path coverage sample test result true ")
@CsvSource({ "1,2,0", "1,0,3", "0,0,3" })
void testPathCoverageSampleTrue(int a, int b, int c) {

  CoverageSampleMethods coverageSampleMethods = new CoverageSampleMethods();
  Assertions.assertTrue(coverageSampleMethods.testMethod(a, b, c));
}

@ParameterizedTest
@DisplayName("Path coverage sample test result false ")
@CsvSource({ "1,0,0", "0,0,0" })
void testPathCoverageSampleFalse(int a, int b, int c) {

  CoverageSampleMethods coverageSampleMethods = new CoverageSampleMethods();
  Assertions.assertFalse(coverageSampleMethods.testMethod(a, b, c));
}

总结 1. 覆盖率数据只能代表你测试过哪些代码,不能代表你是否测试好这些代码。

  1. 不要过于相信覆盖率数据。
  2. 不要只拿语句覆盖率(行覆盖率)来考核你的测试人员。
  3. 路径覆盖率 > 判定覆盖 > 语句覆盖
  4. 测试人员不能盲目追求代码覆盖率,而应该想办法设计更多更好的案例,哪怕多设计出来的案例对覆盖率一点影响也没有

1.6 关于Junit5

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

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

Junit5 由以下3个模块组成: 1. Platform,从名字也可以看出来,Junit已不仅仅想简单作为一个测试框架,更多是希望能作为一个测试平台,也就是说通过JUnit platform,其他的自动化测试引擎或自己定制的引擎都可以接入这个平台实现对接和执行。试想下TestNG运行在Junit上,是不是有点意思了。 2. Jupiter,则是Junit5的核心,它包含了很多丰富的新特性来使JUnit自动化测试更加方便、功能更加丰富和强大。
3. Vintage,则是一个对JUnit3,JUnit4兼容的测试引擎,使旧版本junit的自动化测试脚本可以顺畅运行在junit5下,其实也可以看作是基于junit platform实现的接入范例。 新特性 - 断言:除了之前版本的各种常规断言,新版本还支持AssertThrow和AssertAll两种新的断言; - 新版本提供了tag特性,类似RobotFramework的tag一样可以给测试打标签。便于在测试执行时根据tag来选择执行; - Junit5中支持给测试方法提供参数,提高了测试数据管理的便利性。内建的几种数据源; - JUnit5全面支持JDK8 lambda语法表达; - 嵌套测试:可以更好的把相关的测试方法组织在一起。在内部类中可以使用@BeforeEach 和@AfterEach 注解,而且嵌套的层次没有限制. - 动态测试。

1.7 关于命名

单元测试代码必须写在工程目录 src/test/java 下,不允许写在业务代码目录下,因为主流 Java 测试框架如 JUnit, TestNG 测试代码都是默认放在 src/test/java下的。测试资源文件则放在 src/test/resouces下,这样有利于代码目录标准化。良好的方法命名能够让开发者在测试发生错误时,快速了解出现问题的位置和影响。试比较以下两个错误信息。

> Task :test
com.daming.demo.DemoServiceTest > test83 FAILED
java.lang . AssertionError at DemoServ ceTest java: 177
200 tests completed, 1 failed

示例2:

> Task : test
com.daming.demo.DemoServiceTest > shouldSuccessWhenDecodeUserToken
FAILED
java.lang.AssertionError at DemoServiceTest . java : l77
200 tests completed , 1 failed

主流的 Java 单元测试方法命名规范有两种,一种是传统的以"test"开头,然后加待测场景和期待结果的命名方式,例如 testDecodeUserToknSuccess;另一种则是更易于阅读的 should ... When”结构 它类似于行为测试的"Given ... When ... Then "叙述,只是将 Then 部分的结果前 由于 Given中的前提通常已在测试准备的@BeforeEach 或@BeforeAll 方法中体现,因此不必在各个测试方法名中重复, 例如shouldSuccessWhenDecodeUserToken。
在命名时,应当在不影响表意的情况下适当精简描述语旬长度(通常控制在5个单词内),例如 将"shouldReturnTicketlnfomationlncludi ngOrderNumberToUserWhenAllDataisValidAndTokenIsNotExpired"缩短为"shouldGetTicketInfoWhenAllParametersValid"。过长的命名容易产生信息量过载 反而给阅读和理解带来负担。

二、第一个单元测试

这一部分将编写一个简单的单元测试用例,并看一看使用单元测试和不使用单元测试的区别。接下来学习如何进行单元测试,使用的Junit版本为JUnit5。首先打开Idea,创建一个Java项目junit_demo。

2.1 Add.java

在com.daming包下创建类Add.java,在其中编写一个Add方法,用于将参数a,b的值相加并且返回结果。

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

2.2 手动测试

编写好方法后一般会测试这个方法能不能行得通,在不使用Junit的情况下,一般使用如下的方法进行测试。

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

    public static void main(String[] args) {
        Add add = new Add();
        if (add.add(1, 1) == 2) {
            System.out.println("Test pass");
        } else {
            System.out.println("Test fail");
        }
    }
}

手动测试需要新建一个实例,并且调用对应的方法,然后对结果进行比较判断,最后输出测试结果。

2.3 使用Junit进行测试

在com.daming包下创建一个Junit测试类AddTest.java。

package com.daming;

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

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

class AddTest {
    public static Add add;

    @BeforeAll // 在所有测试方法运行前运行,并且只能修饰静态方法(除非修改测试实例生命周期)
    public static void setUp() throws Exception {

        add = new Add();
    }

    @Test // 表示这是个测试方法
    void add() {

        // 断言相等,比较2和add.add(1,1)的返回值是否相等
        assertEquals(2, add.add(1, 1));
    }
}

接着运行测试方法,可以看到控制台自动打印出了相关信息,包括运行的测试用例的总数,测试成功数量,测试失败数量,并且可以快速的定位到测试方法,方便进行修改。可以看到单元测试和手动测试相比要简单快捷不少

三、Junit注解

这一部分将介绍 JUnit5 中的注解,以及常用注解的使用方法。

3.1 JUnit常用注解

|注解| 描述| |----|----| |@Test| 表示方法是一种测试方法。 与JUnit 4的@Test注解不同,此注释不会声明任何属性。| |@ParameterizedTest |表示方法是参数化测试| |@RepeatedTest |表示方法是重复测试模板| |@TestFactory |表示方法是动态测试的测试工程| |@DisplayName |为测试类或者测试方法自定义一个名称| |@BeforeEach| 表示方法在每个测试方法运行前都会运行| |@AfterEach |表示方法在每个测试方法运行之后都会运行| |@BeforeAll| 表示方法在所有测试方法之前运行| |@AfterAll| 表示方法在所有测试方法之后运行| |@Nested| 表示带注解的类是嵌套的非静态测试类,@BeforeAll和@AfterAll方法不能直接在@Nested测试类中使用,除非修改测试实例生命周期。| |@Tag |用于在类或方法级别声明用于过滤测试的标记| |@Disabled| 用于禁用测试类或测试方法| |@ExtendWith| 用于注册自定义扩展,该注解可以继承|

3.1 JUnit常用注解的使用

创建一个测试用例AnnotationsTest.java。

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

//常用注解测试
@DisplayName("Common annotation test")
public class AnnotationsTest {

    private static Add add;

    @BeforeAll
    public static void beforeAll() {
        add=new Add();
        //在所有测试方法运行前运行
        System.out.println("Run before all test methods run");
    }

    @BeforeEach
    public void beforeEach() {
        //每个测试方法运行前运行
        System.out.println("Run before each test method runs");
    }

    @AfterEach
    public void afterEach() {
        //每个测试方法运行完毕后运行
        System.out.println("Run after each test method finishes running");
    }

    @AfterAll
    public static void afterAll() {
        //在所有测试方法运行完毕后运行
        System.out.println("Run after all test methods have finished running");
    }

    @Disabled
    @Test
    @DisplayName("Ignore the test")
    public void disabledTest() {
        //这个测试不会运行
        System.out.println("This test will not run");
    }

    @Test
    @DisplayName("Test Methods 1+1")
    public void testAdd1() {
        System.out.println("Running test method1+1");
        Assertions.assertEquals(2,add.add(1,1));
    }

    @Test
    @DisplayName("Test Methods 2+2")
    public void testAdd2() {
        System.out.println("Running test method2+2");
        Assertions.assertEquals(4,add.add(2,2));
    }


}

运行测试类查看结果。

Junit5指南_测试用例

四、断言

这一部分将介绍 JUnit5 中的断言,以及常用断言的使用。

编写代码时,我们总是会做出一些假设,断言就是用于在代码中捕捉这些假设。断言表示为一些布尔表达式,程序员相信在程序中的某个特定点该表达式值为真,可以在任何时候启用和禁用断言验证,因此可以在测试时启用断言而在部署时禁用断言。同样,程序投入运行后,最终用户在遇到问题时可以重新启用断言。
使用断言可以创建更稳定、品质更好且 不易于出错的代码。当需要在一个值为FALSE时中断当前操作的话,可以使用断言。单元测试必须使用断言(Junit/JunitX)

4.1 Junit常用断言

|断言|描述| |----|----| |assertEquals|断言预期值和实际值相等| |assertAll |分组断言,执行其中包含的所有断言| |assertArrayEquals |断言预期数组和实际数组相等| |assertFalse| 断言条件为假| |assertNotNull| 断言不为空| |assertSame| 断言两个对象相等| |assertTimeout| 断言超时| |fail| 使单元测试失败|

4.2 常用断言的使用

package com.daming;


import org.junit.jupiter.api.Test;

import static java.time.Duration.ofMillis;
import static java.time.Duration.ofMinutes;
import static org.junit.jupiter.api.Assertions.*;

class Assert {
    
    @Test
    void standardAssertions() {

        assertEquals(2, 2);
        assertEquals(4, 4, "error message");
        assertTrue(2 == 2, () -> "error message");
    }

    @Test
    void groupedAssertions() {

        // 分组断言,执行分组中所有断言,分组中任何一个断言错误都会一起报告
        assertAll("person", () -> assertEquals("John", "John"), () -> assertEquals("Doe", "Doe"));
    }

    @Test
    void dependentAssertions() {

        // 分组断言
        assertAll("properties", () -> {
            // 在代码块中,如果断言失败,后面的代码将不会运行
            String firstName = "John";
            assertNotNull(firstName);
            // 只有前一个断言通过才会运行
            assertAll("first name", () -> assertTrue(firstName.startsWith("J")),
                    () -> assertTrue(firstName.endsWith("n")));
        }, () -> {
            // 分组断言,不会受到first Name代码块的影响,所以即使上面的断言执行失败,这里的依旧会执行
            String lastName = "Doe";
            assertNotNull(lastName);
            // 只有前一个断言通过才会运行
            assertAll("last name", () -> assertTrue(lastName.startsWith("D")),
                    () -> assertTrue(lastName.endsWith("e")));
        });
    }

    @Test
    void exceptionTesting() {

        // 断言异常,抛出指定的异常,测试才会通过
        Throwable exception = assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("a message");
        });
        assertEquals("a message", exception.getMessage());
    }

    @Test
    void timeoutNotExceeded() {

        // 断言超时
        assertTimeout(ofMinutes(2), () -> {
            // 完成任务小于2分钟时,测试通过。
        });
    }

    @Test
    void timeoutNotExceededWithResult() {

        // 断言成功并返回结果
        String actualResult = assertTimeout(ofMinutes(2), () -> {
            return "result";
        });
        assertEquals("result", actualResult);
    }

    @Test
    void timeoutExceeded() {

        // 断言超时,会在任务执行完毕后才返回,也就是1000毫秒后返回结果
        assertTimeout(ofMillis(10), () -> {
            // 执行任务花费时间1000毫秒
            Thread.sleep(1000);
        });
    }

    @Test
    void timeoutExceededWithPreemptiveTermination() {

        // 断言超时,如果在10毫秒内任务没有执行完毕,会立即返回断言失败,不会等到1000毫秒后
        assertTimeoutPreemptively(ofMillis(10), () -> {
            Thread.sleep(1000);
        });
    }

}

运行测试类查看结果。

Junit5指南_单元测试_02

可以看到其中有2个测试没有通过,这个是我们 Thread.sleep() 方法设置的时间超时导致,通过查看这两个测试方法的执行时间,我们可以很轻易的对比assertTimeoutPreemptively()和assertTimeout()的区别。

4.3 AssertJ

断言负责验证逻辑以及数据的合法性和完整性,所以有一种说法 在单元测试方法中没有断言就不是完整的测试!而在实际开发过程中仅使用JUnit的断言往往不能满足需求。要么是被局限在 JUnit 仅有的几种断言中 对于不支持的断言就不再写额外的判断逻辑。 要么花费很大的精力,对要判断的条件经过一系列改造后再使用 JUnit 现有的断言。有没有第三种选择?

答案是:有的!

AssertJ 的最大特点是流式断言(Fluent Assertions),与Build Chain 模式或Java stream&filter 写法类似。它允许一个目标对象通过各种Fluent Assert API连接判断进行多次断言并且对IDE更友好。 废话不多说,直接上代码,对比一下两者的优劣。

不使用 AssertJ时,我们是这样写的:

//使用Junit的断言
public class JUnitSampleTest {
    @Test
    public void testUsingJunitAssertThat() {

        // 字符串判断
        String s = "abcde";
        Assertions.assertTrue(s.startsWith("ab"));
        Assertions.assertTrue(s.endsWith("de"));
        Assertions.assertEquals(5, s.length());
        // 数字判断
        Integer i = 50;
        Assertions.assertTrue(i > 0);
        Assertions.assertTrue(i < 100);
        // 日期判断
        Date datel = new Date();
        Date date2 = new Date(datel.getTime() + 100);
        Date date3 = new Date(datel.getTime() - 100);
        Assertions.assertTrue(datel.before(date2));
        Assertions.assertTrue(datel.after(date3));
        // List判断
        List<String> list = Arrays.asList("a", "b", "c", "d");
        Assertions.assertEquals("a", list.get(0));
        Assertions.assertEquals(4, list.size());
        Assertions.assertEquals("d", list.get(list.size() - 1));
        // Map 判断
        Map<String, Object> map = new HashMap<>();
        map.put("A", 1);
        map.put("B", 2);
        map.put("C", 3);
        Set<String> set = map.keySet();
        Assertions.assertEquals(3, map.size());

        Assertions.assertTrue(set.containsAll(Arrays.asList("A", "B", "C")));
    }
}

下面,我们使用 AssertJ采完成同样的断言

import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import java.util.*;

// 使用 AssertJ的断言
public class AssertJSampleTest {
    @Test
    public void testUsingAssertJ() {

        // 子符串判断
        String s = "abcde";
        assertThat(s).as("字符串判断,判断首尾及长度").startsWith("ab").endsWith("de").hasSize(5);
        // 数字判断
        Integer i = 50;
        assertThat(i).as("数字判断,数字大小比较").isGreaterThan(10).isLessThan(100);
        // 日期判断
        Date date1 = new Date();
        Date date2 = new Date(date1.getTime() + 100);
        Date date3 = new Date(date1.getTime() - 100);
        assertThat(date1).as("日期判断:日期大小比较").isBefore(date2).isAfter(date3);
        // list比较
        List<String> list = Arrays.asList("a", "b", "c", "d");
        assertThat(list).as("list的首尾元素及长度").startsWith("a").endsWith("d").hasSize(4);
        // Map判断
        Map<String, Object> map = new HashMap<>();
        map.put("A", 1);
        map.put("B", 2);
        map.put("C", 3);
        assertThat(map).as("Map的长度及键值测试").hasSize(3).containsKeys("A", "B", "C");
    }
}

五、Junit条件和假设

这一部分将介绍 JUnit5 中假设以及条件测试。

5.1 什么是假设

JUnit5 中的假设可以用于控制测试用例是否执行,相当于条件,只有条件满足才会执行,如果条件不满足,那么将不会执行后续测试。 Junit 5 中的假设主要有如下内容:

|方法| 描述| |----|----| |assumeFalse| 假设为false时才会执行,如果为true,那么将会直接停止执行| |assumeTrue |假设为true时才会执行,如果为false,那么将会直接停止执行| |assumingThat| assumingThat接受一个函数式接口Executable,假设为true时执行,将会执行Executable,否则不会执行Executable。|

5.2 Junit假设示例

package com.daming;

import static org.junit.jupiter.api.Assumptions.assumeFalse;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assumptions.assumingThat;

import org.junit.jupiter.api.Test;

class Assumption {
         @Test
        void assumeTrueTest() {
             //如果假设传入的值为True,那么就会执行后面测试,否则直接停止执行
            assumeTrue(false);
            System.out.println("This will not be implemented.");
        }

        @Test
        void assumeFalseTest() {
            //如果假设传入的值为false,那么就会执行后面测试,否则直接停止执行
            assumeFalse(true);
            System.out.println("This will not be implemented.");
        }

        @Test
        void assumingThatTest() {
            //    assumingThat(boolean assumption, Executable executable)
            //assumingThat 接受一个boolean值assumption,如果assumption为true,那么将会执行executable,否则不会执行,
            //但是assumingThat即使为false也不会影响后续代码的执行,他和assumeFalse和assumeTrue不同,assumingThat只
            //决定Executable是否执行,Executable是一个函数式接口,接受一个没有参数和返回值的方法。
            assumingThat(false,
                () -> {
                    System.out.println("This will not be implemented.");
                });

            //下面的输出将会执行
            System.out.println("This will be implemented.");
        }
}

执行测试,

Junit5指南_单元测试_03

从测试结果中可以看到Runs:3/3(2 skipped),因为 assumeFalse 和 assumeTrue 的条件都不满足,所以执行被中止了,而 assumingThat 不会影响到后续代码,所以System.out.println("This will be implemented.");被执行,我们可以在控制台中看到输出。

六、JUnit禁用测试

这一部分将介绍 JUnit5中如何禁用不需要运行的测试。

6.1 禁用测试

在测试过程中,可能有一些测试暂时还不需要运行,比如功能还没有完成,或者 Bug 短时间无法处理,我们希望这一段测试不会运行,那么可以采用@Disabled注解的方式。 @Disabled注解可以注解在方法上或者注解在类上,注解在方法上时禁用对应的方法,注解在类上的时候禁用该类中所有的测试。

package com.daming;

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

class DisabledTest {

    @Test
    // 使用@Disabled注解关闭
    @Disabled
    void disabled() {

        System.out.println("Not running");
    }

    @Test
    void open() {

        System.out.println("running");
    }
}

运行测试

Junit5指南_测试用例_04

可以看到注解了@Disabled的方法并没有运行。

七、重复测试

这一部分将介绍 JUnit5 中如何使用重复测试。在有些场景中,我们可要对一个测试运行多次,当然不可能手动运行多次!!因为 Junit 提供了重复测试,下面我们来如何使用它。

7.1 @RepeatedTest注解

通过@RepeatedTest注解可以完成重复测试的工作,@RepeatedTest中的value属性可以设置重复的次数,name属性可以自定义重复测试的显示名,显示名可以由占位符和静态文本组合,目前支持下面几种占位符: - {displayName}: 显示名 - {currentRepetition}: 当前重复次数 - {totalRepetitions}: 总重复次数

Junit 重复测试提供了默认的显示名的模式,repetition {currentRepetition} of {totalRepetitions},如果没有自定义的话,显示名就会是下面这种形式repetition 1 of 10,Junit 还提供了另外一种显示名RepeatedTest.LONG_DISPLAY_NAME他显示的形式为{displayName} :: repetition {currentRepetition} of {totalRepetitions},只需要将@RepeatedTest(value = 1, name = RepeatedTest.LONG_DISPLAY_NAME)注解到方法上就可以使用这种显示方式。如果想要用编程的方式获取当前循环测试的相关详细,可以将RepetitionInfo实例注入到相关的方法中。

import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.RepetitionInfo;
import org.junit.jupiter.api.TestInfo;


class RepeatTest {

    //自定义重复测试的显示名称
    @RepeatedTest(value=10,name="{displayName}-->{currentRepetition}/{totalRepetitions}")
    @DisplayName("repeatTest")
    void repeatedTest(TestInfo testInfo,RepetitionInfo repetitionInfo) {
        //我们可以通过TestInfo在测试中获取测试的相关信息,比如输出自定义的测试名
        System.out.println(testInfo.getDisplayName());
        //输出当前重复次数
        System.out.println("currentRepetition:"+repetitionInfo.getCurrentRepetition());

    }

}

运行结果如下所示:


Junit5指南_junit_05

八、通过@FixMethodOrder(MethodSorters.NAME_ASCENDING)控制测试类中方法执行的顺序

直接看示例:

package com.tntxia.junit.test;

import org.junit.FixMethodOrder;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.runners.MethodSorters;

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

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class OrderedTest {

    @Test
    // @Ignore
    public void testAdd() throws Exception {

        System.out.println("Add Method");

        assertEquals(1, 1);
    }

    @Test
    // @Ignore
    @Disabled
    public void testUpdate() throws Exception {

        System.out.println("Update Method");

        assertEquals(2, 2);
    }

    @Test
    // @Ignore
    public void testSelect() throws Exception {

        System.out.println("Select Method");
        assertEquals(2, 2);
    }

    @Test
    // @Ignore
    public void testDelete() throws Exception {

        System.out.println("Delete Method");
        assertEquals(1, 1);
    }

}

Junit5指南_java_06

这种测试方式将按方法名称的进行排序,由于是按字符的字典顺序,所以以这种方式指定执行顺序会始终保持一致;不过这种方式需要对测试方法有一定的命名规则,如 测试方法均以testNNN开头(NNN表示测试方法序列号 001-999)。

利用这个特性可以做一些事务性的测试,比如在测试过程中我们仅仅想观察测试结果的正确性,而不想污染数据库数据。在测试时可以按照“添加=>更新=>查询=>删除”控制测试方法的执行顺序,这样进行一轮测试后数据库不会增加测试用例的数据(MapNestedTest)

九、实战

如下是两个可以参考的测试用例

9.1 DAO层测试用例

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class ProductDaoTest extends BaseTest {

    @Autowired
    private ProductDao productDao;
    @Autowired
    private ProductImgDao productImgDao;

    @Test
    @Ignore
    public void testAInsertProduct() throws Exception {
        Shop shop1 = new Shop();
        shop1.setShopId(1L);
        ProductCategory pc1 = new ProductCategory();
        pc1.setProductCategoryId(1L);
        // 初始化三个商品实例并添加进shopId为1的店铺里,
        // 同时商品类别Id也为1
        Product product1 = new Product();
        product1.setProductName("测试1");
        product1.setProductDesc("测试Desc1");
        product1.setImgAddr("test1");
        product1.setPriority(1);
        product1.setEnableStatus(1);
        product1.setCreateTime(new Date());
        product1.setLastEditTime(new Date());
        product1.setShop(shop1);
        product1.setProductCategory(pc1);
        Product product2 = new Product();
        product2.setProductName("测试2");
        product2.setProductDesc("测试Desc2");
        product2.setImgAddr("test2");
        product2.setPriority(2);
        product2.setEnableStatus(0);
        product2.setCreateTime(new Date());
        product2.setLastEditTime(new Date());
        product2.setShop(shop1);
        product2.setProductCategory(pc1);
        Product product3 = new Product();
        product3.setProductName("test3");
        product3.setProductDesc("测试Desc3");
        product3.setImgAddr("test3");
        product3.setPriority(3);
        product3.setEnableStatus(1);
        product3.setCreateTime(new Date());
        product3.setLastEditTime(new Date());
        product3.setShop(shop1);
        product3.setProductCategory(pc1);
        // 判断添加是否成功
        int effectedNum = productDao.insertProduct(product1);
        assertEquals(1, effectedNum);
        effectedNum = productDao.insertProduct(product2);
        assertEquals(1, effectedNum);
        effectedNum = productDao.insertProduct(product3);
        assertEquals(1, effectedNum);
    }

    @Test
    @Ignore
    public void testBQueryProductList() throws Exception {
        Product productCondition = new Product();
        // 分页查询,预期返回三条结果
        List<Product> productList = productDao.queryProductList(productCondition, 0, 3);
        assertEquals(3, productList.size());
        // 查询名称为测试的商品总数
        int count = productDao.queryProductCount(productCondition);
        assertEquals(5, count);
        // 使用商品名称模糊查询,预期返回两条结果
        productCondition.setProductName("测试");
        productList = productDao.queryProductList(productCondition, 0, 3);
        assertEquals(2, productList.size());
        count = productDao.queryProductCount(productCondition);
        assertEquals(2, count);
    }

    @Test
    @Ignore
    public void testCQueryProductByProductId() throws Exception {
        long productId = 1;
        // 初始化两个商品详情图实例作为productId为1的商品下的详情图片
        // 批量插入到商品详情图表中
        ProductImg productImg1 = new ProductImg();
        productImg1.setImgAddr("图片1");
        productImg1.setImgDesc("测试图片1");
        productImg1.setPriority(1);
        productImg1.setCreateTime(new Date());
        productImg1.setProductId(productId);
        ProductImg productImg2 = new ProductImg();
        productImg2.setImgAddr("图片2");
        productImg2.setPriority(1);
        productImg2.setCreateTime(new Date());
        productImg2.setProductId(productId);
        List<ProductImg> productImgList = new ArrayList<ProductImg>();
        productImgList.add(productImg1);
        productImgList.add(productImg2);
        int effectedNum = productImgDao.batchInsertProductImg(productImgList);
        assertEquals(2, effectedNum);
        // 查询productId为1的商品信息并校验返回的详情图实例列表size是否为2
        Product product = productDao.queryProductById(productId);
        assertEquals(2, product.getProductImgList().size());
        // 删除新增的这两个商品详情图实例
        effectedNum = productImgDao.deleteProductImgByProductId(productId);
        assertEquals(2, effectedNum);
    }

    @Test
    @Ignore
    public void testDUpdateProduct() throws Exception {
        Product product = new Product();
        ProductCategory pc = new ProductCategory();
        Shop shop = new Shop();
        shop.setShopId(1L);
        pc.setProductCategoryId(2L);
        product.setProductId(1L);
        product.setShop(shop);
        product.setProductName("第二个产品");
        product.setProductCategory(pc);
        // 修改productId为1的商品的名称
        // 以及商品类别并校验影响的行数是否为1
        int effectedNum = productDao.updateProduct(product);
        assertEquals(1, effectedNum);
    }

    @Test
    public void testEUpdateProductCategoryToNull() {
        // 将productCategoryId为2的商品类别下面的商品的商品类别置为空
        int effectedNum = productDao.updateProductCategoryToNull(2L);
        assertEquals(1, effectedNum);
    }

    @Test
    @Ignore
    public void testFDeleteShopAuthMap() throws Exception {
        // 清除掉insert方法添加的商品
        Product productCondition = new Product();
        ProductCategory pc = new ProductCategory();
        pc.setProductCategoryId(1L);
        productCondition.setProductCategory(pc);
        // 通过输入productCategoryId为1去商品表中查出新增的三条测试数据
        List<Product> productList = productDao.queryProductList(productCondition, 0, 3);
        assertEquals(3, productList.size());
        // 循环删除这三条数据
        for (Product p : productList) {
            int effectedNum = productDao.deleteProduct(p.getProductId(), 1);
            assertEquals(1, effectedNum);
        }
    }
}

9.2 Service层测试用例

public class ShopServiceTest extends BaseTest {
    @Autowired
    private ShopService shopService;

    @Test
    public void testGetShopList() {
        Shop shopCondition = new Shop();
        ShopCategory sc = new ShopCategory();
        sc.setShopCategoryId(3L);
        shopCondition.setShopCategory(sc);
        ShopExecution se = shopService.getShopList(shopCondition, 2, 2);
        System.out.println("店铺列表数为:" + se.getShopList().size());
        System.out.println("店铺总数为:" + se.getCount());
    }

    @Test
    @Ignore
    public void testModifyShop() throws ShopOperationException, FileNotFoundException {
        Shop shop = new Shop();
        shop.setShopId(1L);
        shop.setShopName("修改后的店铺名称");
        File shopImg = new File("/Users/baidu/work/image/dabai.jpg");
        InputStream is = new FileInputStream(shopImg);
        ImageHolder imageHolder = new ImageHolder("dabai.jpg", is);
        ShopExecution shopExecution = shopService.modifyShop(shop, imageHolder);
        System.out.println("新的图片地址为:" + shopExecution.getShop().getShopImg());
    }

    @Test
    @Ignore
    public void testAddShop() throws ShopOperationException, FileNotFoundException {
        Shop shop = new Shop();
        PersonInfo owner = new PersonInfo();
        Area area = new Area();
        ShopCategory shopCategory = new ShopCategory();
        owner.setUserId(1L);
        area.setAreaId(2);
        shopCategory.setShopCategoryId(1L);
        shop.setOwner(owner);
        shop.setArea(area);
        shop.setShopCategory(shopCategory);
        shop.setShopName("测试的店铺3");
        shop.setShopDesc("test3");
        shop.setShopAddr("test3");
        shop.setPhone("test3");
        shop.setCreateTime(new Date());
        shop.setEnableStatus(ShopStateEnum.CHECK.getState());
        shop.setAdvice("审核中");
        File shopImg = new File("/Users/baidu/work/image/xiaohuangren.jpg");
        InputStream is = new FileInputStream(shopImg);
        ImageHolder imageHolder = new ImageHolder(shopImg.getName(), is);
        ShopExecution se = shopService.addShop(shop, imageHolder );
        assertEquals(ShopStateEnum.CHECK.getState(), se.getState());
    }

}

十、MockMvc

对模块进行集成测试时,希望能够通过输入URL对Controller进行测试,如果通过启动服务器,建立http client进行测试,这样会使得测试变得很麻烦,比如,启动速度慢,测试验证不方便,依赖网络环境等,这样会导致测试无法进行,为了可以对Controller进行测试,可以通过引入MockMVC进行解决。
MockMvc实现了对Http请求的模拟,能够直接使用网络的形式,转换到Controller的调用,这样可以使得测试速度快、不依赖网络环境,而且提供了一套验证的工具,这样可以使得请求的验证统一而且很方便。

10.1 先来看一个示例

public void getAllCategoryTest() throws Exception
   {
       String responseString = mockMvc.perform
               (
                       MockMvcRequestBuilders.post("http://127.0.0.1:8888/login")          //请求的url,请求的方法是post
                               //get("/user/showUser2")          //请求的url,请求的方法是get
                               .contentType(MediaType.APPLICATION_FORM_URLENCODED)//发送数据的格式
                               .param("username","hyh")   //添加参数(可以添加多个)
                       .param("password","123")   //添加参数(可以添加多个)
               )
               //.andExpect(status().isOk())    //返回的状态是200
               .andDo(print())         //打印出请求和相应的内容
               .andReturn().getResponse().getContentAsString();   //将相应的数据转换为字符串
       System.out.println("-----返回的json = " + responseString);
   }

过程: 1. mockMvc.perform执行一个RequestBuilder请求,会自动执行SpringMVC的流程并映射到相应的控制器执行处理; 2. MockMvcRequestBuilders.post(“http://127.0.0.1:8888/login“)构造一个请求 3. ResultActions.andExpect添加执行完成后的断言 4. ResultActions.andDo添加一个结果处理器,表示要对结果做点什么事情,比如此处使用MockMvcResultHandlers.print()输出整个响应结果信息。 5. ResultActions.andReturn表示执行完成后返回相应的结果。

整个测试过程非常有规律: 1. 准备测试环境

  1. 通过MockMvc执行请求
  2. 添加验证断言
  3. 添加结果处理器
  4. 得到MvcResult进行自定义断言/进行下一步的异步请求
  5. 卸载测试环境

10.2 类图分析


10.2.1 RequestBuilder

mockMvc.perform执行一个RequestBuilder请求,RequestBuilder主要有两个子类MockHttpServletRequestBuilder和MockMultipartHttpServletRequestBuilder(如文件上传使用).可以用MockMvcRequestBuilders 的静态方法构建RequestBuilder对象。 MockHttpServletRequestBuilder API:

  • MockHttpServletRequestBuilder header(String name, Object... values)/MockHttpServletRequestBuilder headers(HttpHeaders httpHeaders):添加头信息;
  • MockHttpServletRequestBuilder contentType(MediaType mediaType):指定请求的contentType头信息;
  • MockHttpServletRequestBuilder accept(MediaType... mediaTypes)/MockHttpServletRequestBuilder accept(String... mediaTypes):指定请求的Accept头信息;
  • MockHttpServletRequestBuilder content(byte[] content)/MockHttpServletRequestBuilder content(String content):指定请求Body体内容;
  • MockHttpServletRequestBuilder cookie(Cookie... cookies):指定请求的Cookie;
  • MockHttpServletRequestBuilder locale(Locale locale):指定请求的Locale;
  • MockHttpServletRequestBuilder characterEncoding(String encoding):指定请求字符编码;
  • MockHttpServletRequestBuilder requestAttr(String name, Object value) :设置请求属性数据;
  • MockHttpServletRequestBuilder sessionAttr(String name, Object value)/MockHttpServletRequestBuilder sessionAttrs(Map sessionAttributes):设置请求session属性数据;
  • MockHttpServletRequestBuilder flashAttr(String name, Object value)/MockHttpServletRequestBuilder flashAttrs(Map flashAttributes):指定请求的flash信息,比如重定向后的属性信息;
  • MockHttpServletRequestBuilder session(MockHttpSession session) :指定请求的Session;
  • MockHttpServletRequestBuilder principal(Principal principal) :指定请求的Principal;
  • MockHttpServletRequestBuilder contextPath(String contextPath) :指定请求的上下文路径,必须以“/”开头,且不能以“/”结尾;
  • MockHttpServletRequestBuilder pathInfo(String pathInfo) :请求的路径信息,必须以“/”开头;
  • MockHttpServletRequestBuilder secure(boolean secure):请求是否使用安全通道;
  • MockHttpServletRequestBuilder with(RequestPostProcessor postProcessor):请求的后处理器,用于自定义一些请求处理的扩展点; MockMultipartHttpServletRequestBuilder继承自MockHttpServletRequestBuilder,又提供了如下API:
  • MockMultipartHttpServletRequestBuilder file(String name, byte[] content)/MockMultipartHttpServletRequestBuilder file(MockMultipartFile file):指定要上传的文件;

10.2.2 MockMvcRequestBuilders的主要方法


  • MockHttpServletRequestBuilder get(String urlTemplate, Object... urlVariables):根据uri模板和uri变量值得到一个GET请求方式的MockHttpServletRequestBuilder;如get("/user/{id}", 1L);
  • MockHttpServletRequestBuilder post(String urlTemplate, Object... urlVariables):同get类似,但是是POST方法;
  • MockHttpServletRequestBuilder put(String urlTemplate, Object... urlVariables):同get类似,但是是PUT方法;
  • MockHttpServletRequestBuilder delete(String urlTemplate, Object... urlVariables) :同get类似,但是是DELETE方法;
  • MockHttpServletRequestBuilder options(String urlTemplate, Object... urlVariables):同get类似,但是是OPTIONS方法;
  • MockHttpServletRequestBuilder request(HttpMethod httpMethod, String urlTemplate, Object... urlVariables):提供自己的Http请求方法及uri模板和uri变量,如上API都是委托给这个API;
  • MockMultipartHttpServletRequestBuilder fileUpload(String urlTemplate, Object... urlVariables):提供文件上传方式的请求,得到MockMultipartHttpServletRequestBuilder;
  • RequestBuilder asyncDispatch(final MvcResult mvcResult):创建一个从启动异步处理的请求的MvcResult进行异步分派的RequestBuilder;

10.2.3 ResultActions

调用MockMvc.perform(RequestBuilder requestBuilder)后将得到ResultActions,通过ResultActions完成如下三件事:

  1. ResultActions andExpect(ResultMatcher matcher) :添加验证断言来判断执行请求后的结果是否是预期的;
  2. ResultActions andDo(ResultHandler handler) :添加结果处理器,用于对验证成功后执行的动作,如输出下请求/结果信息用于调试;
  3. MvcResult andReturn() :返回验证成功后的MvcResult;用于自定义验证/下一步的异步处理;

10.2.4 ResultMatcher


ResultMatcher用来匹配执行完请求后的结果验证,其就一个match(MvcResult result)断言方法,如果匹配失败将抛出相应的异常;spring mvc测试框架提供了很多xxxResultMatchers来满足测试需求。注意这些ResultMatchers并不是ResultMatcher的子类,而是返回ResultMatcher实例的。Spring mvc测试框架为了测试方便提供了MockMvcResultMatchers静态工厂方法方便操作;具体的API如下:

  • HandlerResultMatchers handler():请求的Handler验证器,比如验证处理器类型/方法名;此处的Handler其实就是处理请求的控制器;
  • RequestResultMatchers request():得到RequestResultMatchers验证器;
  • ModelResultMatchers model():得到模型验证器;
  • ViewResultMatchers view():得到视图验证器;
  • FlashAttributeResultMatchers flash():得到Flash属性验证;
  • StatusResultMatchers status():得到响应状态验证器;
  • HeaderResultMatchers header():得到响应Header验证器;
  • CookieResultMatchers cookie():得到响应Cookie验证器;
  • ContentResultMatchers content():得到响应内容验证器;
  • JsonPathResultMatchers jsonPath(String expression, Object ... args)/ResultMatcher jsonPath(String expression, Matcher matcher):得到Json表达式验证器;
  • XpathResultMatchers xpath(String expression, Object... args)/XpathResultMatchers xpath(String expression, Map namespaces, Object... args):得到Xpath表达式验证器;
  • ResultMatcher forwardedUrl(final String expectedUrl):验证处理完请求后转发的url(绝对匹配);
  • ResultMatcher forwardedUrlPattern(final String urlPattern):验证处理完请求后转发的url(Ant风格模式匹配,@since spring4);
  • ResultMatcher redirectedUrl(final String expectedUrl):验证处理完请求后重定向的url(绝对匹配);
  • ResultMatcher redirectedUrlPattern(final String expectedUrl):验证处理完请求后重定向的url(Ant风格模式匹配,@since spring4);

得到相应的xxxResultMatchers后,接着再调用其相应的API得到ResultMatcher,如ModelResultMatchers.attributeExists(final String... names)判断Model属性是否存在。具体请查看相应的API。再次就不一一列举了。

10.2.5 MvcResult

即执行完控制器后得到的整个结果,并不仅仅是返回值,其包含了测试时需要的所有信息,如:

  • MockHttpServletRequest getRequest():得到执行的请求;
  • MockHttpServletResponse getResponse():得到执行后的响应;
  • Object getHandler():得到执行的处理器,一般就是控制器;
  • HandlerInterceptor[] getInterceptors():得到对处理器进行拦截的拦截器;
  • ModelAndView getModelAndView():得到执行后的ModelAndView;
  • Exception getResolvedException():得到HandlerExceptionResolver解析后的异常;
  • FlashMap getFlashMap():得到FlashMap;
  • Object getAsyncResult()/Object getAsyncResult(long timeout):得到异步执行的结果;

10.3 测试实例

10.3.1 测试普通控制器

//测试普通控制器  
mockMvc.perform(get("/user/{id}", 1)) //执行请求  
        .andExpect(model().attributeExists("user")) //验证存储模型数据  
        .andExpect(view().name("user/view")) //验证viewName  
        .andExpect(forwardedUrl("/WEB-INF/jsp/user/view.jsp"))//验证视图渲染时forward到的jsp  
        .andExpect(status().isOk())//验证状态码  
        .andDo(print()); //输出MvcResult到控制台

10.3.2 测试普通控制器,但是URL错误,即404

//找不到控制器,404测试  
MvcResult result = mockMvc.perform(get("/user2/{id}", 1)) //执行请求  
        .andDo(print())  
        .andExpect(status().isNotFound()) //验证控制器不存在  
        .andReturn();  
Assert.assertNull(result.getModelAndView()); //自定义断言

10.3.3 得到MvcResult自定义验证

MvcResult result = mockMvc.perform(get("/user/{id}", 1))//执行请求  
        .andReturn(); //返回MvcResult  
Assert.assertNotNull(result.getModelAndView().getModel().get("user")); //自定义断言

10.3.4 验证请求参数验证失败出错

mockMvc.perform(post("/user").param("name", "admin")) //执行请求  
        .andExpect(model().hasErrors()) //验证模型有错误  
        .andExpect(model().attributeDoesNotExist("name")) //验证存在错误的属性  
        .andExpect(view().name("showCreateForm")); //验证视图

10.3.5 文件上传

//文件上传  
byte[] bytes = new byte[] {1, 2};  
mockMvc.perform(fileUpload("/user/{id}/icon", 1L).file("icon", bytes)) //执行文件上传  
        .andExpect(model().attribute("icon", bytes)) //验证属性相等性  
        .andExpect(view().name("success")); //验证视图

10.3.6 JSON请求/响应验证

String requestBody = "{\"id\":1, \"name\":\"zhang\"}";  
mockMvc.perform(post("/user")  
            .contentType(MediaType.APPLICATION_JSON).content(requestBody)  
            .accept(MediaType.APPLICATION_JSON)) //执行请求  
        .andExpect(content().contentType(MediaType.APPLICATION_JSON)) //验证响应contentType  
        .andExpect(jsonPath("$.id").value(1)); //使用Json path验证JSON 请参考http://goessner.net/articles/JsonPath/  

String errorBody = "{id:1, name:zhang}";  
MvcResult result = mockMvc.perform(post("/user")  
        .contentType(MediaType.APPLICATION_JSON).content(errorBody)  
        .accept(MediaType.APPLICATION_JSON)) //执行请求  
        .andExpect(status().isBadRequest()) //400错误请求  
        .andReturn();  

Assert.assertTrue(HttpMessageNotReadableException.class.isAssignableFrom(result.getResolvedException().getClass()));//错误的请求内容体

10.3.7 XML请求/响应验证

//XML请求/响应  
String requestBody = "<user><id>1</id><name>zhang</name></user>";  
mockMvc.perform(post("/user")  
        .contentType(MediaType.APPLICATION_XML).content(requestBody)  
        .accept(MediaType.APPLICATION_XML)) //执行请求  
        .andDo(print())  
        .andExpect(content().contentType(MediaType.APPLICATION_XML)) //验证响应contentType  
        .andExpect(xpath("/user/id/text()").string("1")); //使用XPath表达式验证XML 请参考http://www.w3school.com.cn/xpath/  

String errorBody = "<user><id>1</id><name>zhang</name>";  
MvcResult result = mockMvc.perform(post("/user")  
        .contentType(MediaType.APPLICATION_XML).content(errorBody)  
        .accept(MediaType.APPLICATION_XML)) //执行请求  
        .andExpect(status().isBadRequest()) //400错误请求  
        .andReturn();  

Assert.assertTrue(HttpMessageNotReadableException.class.isAssignableFrom(result.getResolvedException().getClass()));//错误的请求内容体

10.3.8 完整的代码示例

demo code.

10.4 实战

参考UserControllerTest,已经涵盖日常测试类80%的常用技巧。(Rest风格,包括文件上传测试等,照猫画虎即可)

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

    @Autowired
    private WebApplicationContext wac;

    private MockMvc mockMvc;


    // 这个方法在每个方法执行之前都会执行一遍
    @Before
    public void setup() {

        mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();   //指定WebApplicationContext,将会从该上下文获取相应的控制器并得到相应的MockMvc
    }

    @Test
    public void whenUploadSuccess() throws Exception {

        String result = mockMvc
                .perform(fileUpload("/file").file(new MockMultipartFile("file", "test.txt",
                        "multipart/form-data", "hello upload".getBytes("UTF-8"))))
                    .andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
        System.out.println(result);
    }

    @Test
    public void whenQuerySuccess() throws Exception {

        String result = mockMvc
                .perform(get("/user").param("username", "jojo").param("age", "18")
                        .param("ageTo", "60").param("xxx", "yyy")
                        // .param("size", "15")
                        // .param("page", "3")
                        // .param("sort", "age,desc")
                        .contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().isOk()).andExpect(jsonPath("$.length()").value(3)).andReturn()
                .getResponse().getContentAsString();

        System.out.println(result);
    }

    @Test
    public void whenGetInfoSuccess() throws Exception {

        String result = mockMvc.perform(get("/user/1").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().isOk()).andExpect(jsonPath("$.username").value("tom"))
                .andReturn().getResponse().getContentAsString();

        System.out.println(result);
    }

    @Test
    public void whenGetInfoFail() throws Exception {

        mockMvc.perform(get("/user/a").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().is4xxClientError());
    }

    @Test
    public void whenCreateSuccess() throws Exception {

        Date date = new Date();
        System.out.println(date.getTime());
        String content = "{\"username\":\"tom\",\"password\":\"root\",\"birthday\":" + date.getTime()
                + "}";
        String reuslt = mockMvc
                .perform(
                        post("/user").contentType(MediaType.APPLICATION_JSON_UTF8).content(content))
                .andExpect(status().isOk()).andExpect(jsonPath("$.id").value("1")).andReturn()
                .getResponse().getContentAsString();

        System.out.println(reuslt);
    }

    @Test
    public void whenCreateFail() throws Exception {

        Date date = new Date();
        System.out.println(date.getTime());
        String content = "{\"username\":\"tom\",\"password\":null,\"birthday\":" + date.getTime()
                + "}";
        String reuslt = mockMvc
                .perform(
                        post("/user").contentType(MediaType.APPLICATION_JSON_UTF8).content(content))
                // .andExpect(status().isOk())
                // .andExpect(jsonPath("$.id").value("1"))
                .andReturn().getResponse().getContentAsString();

        System.out.println(reuslt);
    }

    @Test
    public void whenUpdateSuccess() throws Exception {

        Date date = new Date(LocalDateTime.now().plusYears(1).atZone(ZoneId.systemDefault())
                .toInstant().toEpochMilli());
        System.out.println(date.getTime());
        String content = "{\"id\":\"1\", \"username\":\"tom\",\"password\":null,\"birthday\":"
                + date.getTime() + "}";
        String reuslt = mockMvc
                .perform(put("/user/1").contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(content))
                .andExpect(status().isOk()).andExpect(jsonPath("$.id").value("1")).andReturn()
                .getResponse().getContentAsString();

        System.out.println(reuslt);
    }

    @Test
    public void whenDeleteSuccess() throws Exception {

        mockMvc.perform(delete("/user/1").contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(status().isOk());
    }

}