单元测试技术栈梳理

简介: > *keep the bar green to keep the code clean.* # 前言 单元测试是一个老生常谈的事情,可是能够深入开发人心,又能够喜欢写单元测试的同学少之又少。单元测试似乎功不在当下的事情,业务代码快速完成需求,才是王道,在工作量评估的时候,如果开发同学说要花上若干天时间来写单测,需要延后几天发布,那么PD可能就会:!#@%5*))~@#,单元测试

keep the bar green to keep the code clean.

前言

单元测试是一个老生常谈的事情,可是能够深入开发人心,又能够喜欢写单元测试的同学少之又少。单元测试似乎功不在当下的事情,业务代码快速完成需求,才是王道,在工作量评估的时候,如果开发同学说要花上若干天时间来写单测,需要延后几天发布,那么PD可能就会:!#@%5*))~@#,单元测试是一件有情怀,有技术素养,有远期收益的工作,《集团开发规约》中新增了单元测试规约,也直接说明了单元测试的重要性,推荐大家看看:https://www.atatech.org/articles/50331#20

目的

单元测试,是保证软件质量和效率的重要手段之一。能点进来看本文的,都是有质量追求的同学哈,这里不对单元测试的必要性作赘述,简单提一下单元测试的五点好处:

  • 监测软件质量
  • 提升项目效率
  • 促进代码优化
  • 增加重构自信
  • 软件行为文档化

本文书写的目的,是期望对当前单元测试相关技术做一个梳理和总结,在写法上给一些示例,帮助同学对单元测试相关的工具有大体了解,并能根据示例写出合理的单元测试。

提纲

单元测试书写的基本顺序大致包括:定义测试类、标记测试方法、创建被测试实例、执行被测试方法、结果验证。这个过程中有三个难点:

  • Runner。

这里不把Runner理解为JUnit的运行器,这里理解为单元测试的基础框架。框架的目的是为了帮助我们做资源加载等事情,定制单元测试的模板,让我们能够专注于单测case本身的书写。因此选择一个优秀的测试框架是必要的。
我们常用的框架如:JUnit4,TestNG,IntlTest(IntlTestBlockJUnit4ClassRunner),springTest & springbootTest & SpringContainer4Test(SpringJUnit4ClassRunner)等。

  • Mocks。

Mocks无疑是为了让我们更稳定的运行单元测试,它能隔离环境和外部数据对单元测试的影响,使得结果可预测。更重要的一点:mocks屏蔽了其他代码块对被测试块的影响,使得我们做的是真正的”单元“代码的测试。
所以就这个点而言,我个人是主张分层&纯内部逻辑测试的,举两个例子说明:

1. web/serviec client层;service层;manager/biz层;DAO/外部接口层,层与层之间尽量不要真正地依赖运行(甚至是utils工具类,除非你自己判断它是稳定的,完全可结果预测的)。一方面是为了运行稳定;另一方面是各个层保证自己逻辑正确即可,这也是单元测试的目标:仅做单元代码块的逻辑检测;还有个明显的好处:能轻易获得想要的数据,满足不同分支的测试。
2. HSF接口不建议依赖真正的服务,也要通过mock方式处理,除了上面的原因,个人认为单元测试不是集成测试,目标和做法是不同的。

常用的Mock工具:JMockit,此外还有EasyMock、jMock、Mockito、PowerMock等。

  • Assert

这里把assert也拿出来说一说,是因为我个人之前一直使用junit自带的Assert工具做断言,对于Hamcrest有一些了解,它自身封装了很多匹配器,也更加贴近自然语言,所以结合Junit的Assert能够在一定程度上支持复杂逻辑的断言。但是用过AssertJ以后,就决定只用它了。另外对专门做json断言的JSONassert也会做一些介绍。

1. Runner

// TODO By 4.15

2. Assert

JUnit Assert

依赖

<dependency>
  <groupId>junit</groupId>
  <artifactId>junit</artifactId>
  <version>4.12</version>
</dependency>

先看示例

import static org.junit.Assert.*;
import org.junit.Test;

public class JunitAssert {

    @Test
    public void test() {
        assertEquals(StringUtils.substring("aaabbb", 0, 3), "aaa");
        assertTrue(2 > 1);
    }

}

特点

  • 优点:它是基础的assert工具,能满足全部断言需求。提供的api为assertTrue/assertEquals/assertNull/assertSame/assertArrayEquals等。其中assertThat(T actual, Matcher<? super T> matcher) 方法提供了一个可扩展接口。
  • 缺点:JUnit的assert不对各类数据类型做逻辑封装(如String#substring()类似方法要自己调用解决),因此复杂数据类型或者复杂逻辑的校验,一方面需要我们自己实现断言的内容,另一方面要拆成多个独立的断言处理。因此使用起来比较麻烦,代码看上去也比较臃肿,语义不够直观。

Hamcrest

依赖

  <dependency>
        <groupId>org.hamcrest</groupId>
        <artifactId>hamcrest-all</artifactId>
        <version>1.3</version>
    </dependency>
    

先看示例

import org.junit.Test;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;

public class HamcrestAssert {

    @Test
    public void test() {
        // allOf:所有条件必须都成立,测试才通过
        assertThat(2, allOf(greaterThan(1), lessThan(3)));
        // anyOf:只要有一个条件成立,测试就通过
        assertThat(3, anyOf(greaterThan(1), lessThan(1)));
        // anything:无论什么条件,测试都通过
        assertThat(3, anything());
        // is:变量的值等于指定值时,测试通过
        assertThat(2, is(2));
        // not:和is相反,变量的值不等于指定值时,测试通过
        assertThat(2, not(1));

        // 数值
        // closeTo:浮点型变量的值在3.0±0.5范围内,测试通过
        assertThat(3.1, closeTo(3.0, 0.5));
        // greaterThan:变量的值大于指定值时,测试通过
        assertThat(3.1, greaterThan(3.0));
        // lessThan:变量的值小于指定值时,测试通过
        assertThat(3.1, lessThan(3.5));
        // greaterThanOrEuqalTo:变量的值大于等于指定值时,测试通过
        assertThat(3.3, greaterThanOrEqualTo(3.3));
        // lessThanOrEqualTo:变量的值小于等于指定值时,测试通过
        assertThat(3.3, lessThanOrEqualTo(3.4));

        // 字符串
        // containsString:字符串变量中包含指定字符串时,测试通过
        assertThat("Magci", containsString("ci"));
        // startsWith:字符串变量以指定字符串开头时,测试通过
        assertThat("Magci", startsWith("Ma"));
        // endsWith:字符串变量以指定字符串结尾时,测试通过
        assertThat("Magci", endsWith("i"));
        // euqalTo:字符串变量等于指定字符串时,测试通过
        assertThat("Magci", equalTo("Magci"));
        // equalToIgnoringCase:字符串变量在忽略大小写的情况下等于指定字符串时,测试通过
        assertThat("Magci", equalToIgnoringCase("magci"));
        // equalToIgnoringWhiteSpace:字符串变量在忽略头尾任意空格的情况下等于指定字符串时,测试通过
        assertThat("Magci", equalToIgnoringWhiteSpace(" Magci   "));

        // 集合
        List<String> l = Lists.newArrayList("Magci");
        // hasItem:Iterable变量中含有指定元素时,测试通过
        assertThat(l, hasItem("Magci"));

        Map<String, String> m = Maps.newHashMap();
        m.put("mgc", "Magci");
        // hasEntry:Map变量中含有指定键值对时,测试通过
        assertThat(m, hasEntry("mgc", "Magci"));
        // hasKey:Map变量中含有指定键时,测试通过
        assertThat(m, hasKey("mgc"));
        // hasValue:Map变量中含有指定值时,测试通过
        assertThat(m, hasValue("Magci"));
    }

}

特点

  • 定义。首先说hamcrest自身并不是一个单元测试框架,它本质上是一个包含很多有用的匹配器的库。可以使用在很多场景,尤其适合用于对:org.junit.Assert.assertThat(T actual, Matcher<? super T> matcher)做匹配功能的扩展,所以可以配合一起使用。
  • 优点。

    • 语义更加贴近自然语言,易于理解;
    • 起源于java,另外多种语言提供支持,如Java, C++, Objective-C, Python, PHP, Ruby, Swift等。
  • 缺点

    • 不支持流式检查,对于一个结果做多维度的判断仍需要拆分断言;
    • api不够丰富,很多领域对象没有对应方法,如Date,Exception等;
    • 发展较慢,对新技术的支持不到位。
  • api。下图为常用匹配器总览。

screenshot.png

AssertJ

依赖

  <dependency>
       <groupId>org.assertj</groupId>
       <artifactId>assertj-core</artifactId>
        <!-- 3.x for Java 8 -->
        <!-- 2.x for Java 7 -->
        <!-- 1.x for Java 6 -->
       <version>3.6.1</version>
       <scope>test</scope>
    </dependency>
    

先看示例

由于assert工具在这里推荐AssertJ,所以例子放多一些。

1.字符串断言

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

import java.util.regex.Pattern;

import org.junit.Test;

public class StringAssertJ {

    @Test
    public void string_assertions_examples() {
        // 检查:开头结尾和长度
        assertThat("Frodo").startsWith("Fro").endsWith("do").hasSize(5);
        assertThat("Frodo").doesNotStartWith("fro").doesNotEndWith("don");
        // 检查:包含
        assertThat("Frodo").contains("rod").doesNotContain("fro").contains("rod", "Fro");
        // 检查:被包含
        assertThat("Frodo").isSubstringOf("Frodon");
        // 检查:仅包含一次
        assertThat("Frodo").containsOnlyOnce("do");
        // 检查:按顺序检查包含
        String bookDescription = "{ 'title':'Games of Thrones', 'author':'George Martin'}";
        assertThat(bookDescription).containsSequence("{", "title", "Games of Thrones", "}");
        // 检查:忽略大小写;长度检查
        assertThat("Frodo").isEqualToIgnoringCase("FROdO").hasSameSizeAs("12345");
        assertThat("Frodo".length()).isGreaterThan("Sam".length());
        assertThat("C-3PO").hasSameSizeAs("R2-D2").hasSize(5);
        // 检查:正则匹配检查
        assertThat("Frodo").matches("..o.o").doesNotMatch(".*d");
        assertThat("Frodo").containsPattern("Fr.d");
        assertThat("Frodo").containsPattern(Pattern.compile("Fr.d"));
        // 检查:空串检查
        assertThat("").isEmpty();
        assertThat("").isNullOrEmpty();
        assertThat("not empty").isNotEmpty();
        // 检查:数字包含检查
        assertThat("3210").containsOnlyDigits();
    }
}

2.数值断言

        // 检查:等于;不等于;或者差值范围
        assertThat(38).isEqualTo(38).isCloseTo(40, within(10));
        assertThat(5.0).isCloseTo(6.0, withinPercentage(20.0));
        assertThat(33).isEqualTo(33).isNotEqualTo(34);

        // 检查: <= < > >=
        assertThat(55).isGreaterThan(44).isGreaterThanOrEqualTo(53);
        assertThat(44).isLessThan(55).isLessThanOrEqualTo(45);
        assertThat(44).isBetween(33, 55);

        // 检查:正数 0 负数
        assertThat(0).isZero();
        assertThat(-1).isNegative();
        assertThat(1).isPositive();

        assertThat(0).isNotNegative();
        assertThat(0).isNotPositive();
        assertThat(1).isNotNegative();
        assertThat(-1).isNotPositive();

        // 检查:数组 顺序检查
        assertThat(new int[] {-1, 2, 3}).contains(-1, 2);
        assertThat(new float[] {1.0f, 2.0f, 3.0f}).containsSubsequence(1.0f, 3.0f);

3.SoftAssert。

这是个其他断言没有的一个特色功能,所以说明一下。普通的单测方法在第一个检查失败时就结束跳出。SoftAssert提供了一个全部执行的功能,即全部断言都会运行,并打印失败结果。

        // use SoftAssertions instead of direct assertThat methods
        AutoCloseableSoftAssertions softly = new AutoCloseableSoftAssertions();
        softly.assertThat(7).as("Living Guests").isEqualTo(7);
        softly.assertThat("dirty").as("Kitchen").isEqualTo("clean");
        softly.assertThat("dirty").as("Library").isEqualTo("clean");
        softly.assertThat(5).as("Revolver Ammo").isEqualTo(6);
        softly.assertThat("pristine").as("Candlestick").isEqualTo("pristine");
        softly.assertThat("well kempt").as("Colonel").isEqualTo("well kempt");
        softly.assertThat("bad kempt").as("Professor").isEqualTo("well kempt");
        // Don't forget to call SoftAssertions global verification
        softly.assertAll();
        
        运行结果如下:
org.assertj.core.api.SoftAssertionError: 
The following 4 assertions failed:
1) [Kitchen] expected:<"[clean]"> but was:<"[dirty]">
at SoftAssertJ.test(SoftAssertJ.java:16) expected:<"[clean]"> but was:<"[dirty]">
2) [Library] expected:<"[clean]"> but was:<"[dirty]">
at SoftAssertJ.test(SoftAssertJ.java:17) expected:<"[clean]"> but was:<"[dirty]">
3) [Revolver Ammo] expected:<[6]> but was:<[5]>
at SoftAssertJ.test(SoftAssertJ.java:18) expected:<[6]> but was:<[5]>
4) [Professor] expected:<"[well] kempt"> but was:<"[bad] kempt">
at SoftAssertJ.test(SoftAssertJ.java:21) expected:<"[well] kempt"> but was:<"[bad] kempt">

4.List断言

        List<String> list = Lists.newArrayList("a", "b", "c");
        List<String> moreList = Lists.newArrayList("d", "a", "b", "c");

        // 校验: 数据存在的位置
        assertThat(list).contains("a", atIndex(0)).contains("b", atIndex(1)).contains("c", atIndex(2));
        // 校验: 存在唯一元素
        assertThat(list).containsOnlyOnce("a");
        // 校验: 数据子集
        assertThat(list).isSubsetOf(moreList);
        // 校验: 开头结尾元素
        assertThat(list).endsWith("c").startsWith("a");
        // 校验: 基于filter过滤校验
        assertThat(list).filteredOn(character -> character.contains("a")).containsOnly("a");

5.Map断言

        Map<String, String> map = Maps.newHashMap();
        map.put("Key1", "Value1");
        map.put("Key2", "Value2");
        map.put("Key3", "Value3");

        // 校验:key
        assertThat(map).as("map test info").containsOnlyKeys("Key1", "Key2", "Key3");
        // 校验:all entry
        assertThat(map).containsOnly(entry("Key1", "Value1"), entry("Key2", "Value2"), entry("Key3", "Value3"))
            .contains(entry("Key1", "Value1"));
        // 校验:entry
        assertThat(map).contains(entry("Key1", "Value1")).containsEntry("Key1", "Value1");
        // 校验:不包含
        assertThat(map).doesNotContainEntry("1", "2").doesNotContainKey("1").doesNotContainValue("2");
        // 校验:size
        assertThat(map).isNotEmpty().hasSize(3);

6.类断言(包括类属性判断和实例比较)

public class ClassAssertJ {
    @Test
    public void test() {
        // 类自身属性校验
        assertThat(Test.class).isAnnotation();
        assertThat(ClassAssertJ.class).isNotAnnotation();
        assertThat(ClassAssertJ.class).isNotInterface();
        assertThat(ClassAssertJ.class).isNotFinal();
        assertThat(ClassAssertJ.class).isNotOfAnyClassIn(Test.class);

        // 以下是实例对象的比较,以及实例属性的比较
        Person cPerson = new Person(null, 33, "M");
        Person aPerson = new Person("a", 33, "M");
        Person aPersonClone = new Person("a", 33, "M");
        // 每个属性都做比较
        assertThat(aPerson).isEqualToComparingFieldByField(aPerson).isEqualToComparingFieldByField(aPersonClone);
        // 忽略null值属性,其他每个属性都做比较
        assertThat(aPerson).isEqualToIgnoringNullFields(cPerson);
        // 忽略部分属性字段,其他字段做比较
        assertThat(aPerson).isEqualToIgnoringGivenFields(cPerson, "name", "age");
        // 只比较给出的属性字段,属性的属性也支持比较如:a.b
        assertThat(aPerson).isEqualToComparingOnlyGivenFields(cPerson, "sex");
    }
}

7.Date断言

        Date testDate = df.parse("2002-12-18 0:0:0");
        Date dateBefore = new Date(testDate.getTime() - 10);
        Date dateAfter = new Date(testDate.getTime() + 10);

        // 校验:日期相等、之前、之后,支持字符串参数
        assertThat(testDate).isEqualTo("2002-12-18").isAfter(dateBefore).isBefore(dateAfter).isNotEqualTo("2002-12-19")
            .isAfter("2002-12-17").isBefore("2002-12-19");
        // 校验:年份
        assertThat(testDate).isBeforeYear(2004).isAfterYear(2001);
        // 校验:in ; not in
        assertThat(testDate).isIn("2002-12-17", "2002-12-18", "2002-12-19").isNotIn("2002-12-17", "2002-12-19");
        // 校验:isBetween ; isNotBetween
        assertThat(testDate).isBetween("2002-12-17", "2002-12-19").isNotBetween("2002-12-17", "2002-12-18");
        // 校验:fastTime 毫秒检查
        assertThat(new Date(42)).hasTime(42);
        // 校验:过去时间判断
        assertThat(new Date(new Date().getTime() - 1)).isInThePast();
        // 校验:未来时间判断
        assertThat(new Date(new Date().getTime() + 1000000)).isInTheFuture();
        // 校验:年月日分别校验
        assertThat(testDate).isInSameDayAs("2002-12-18");
        assertThat(testDate).isInSameMonthAs("2002-12-22");
        assertThat(testDate).isInSameYearAs("2002-11-01");

8.异常断言

        // java8:异常类型、内容校验
        assertThatThrownBy(() -> {
            throw new Exception("boom!");
        }).isInstanceOf(Exception.class).hasMessageContaining("boom");

        Throwable thrown = catchThrowable(() -> {
            throw new Exception("boom!");
        });
        assertThat(thrown).isInstanceOf(Exception.class).hasMessageContaining("boom");

        // java7
        try {
            throw new IndexOutOfBoundsException("Index: 9, Size: 9");
        } catch (IndexOutOfBoundsException e) {
            assertThat(e).hasMessage("Index: 9, Size: 9");
        }

特点

  • 优点:从上面一些demo中不难看出AssertJ的以下优点。

    1. 流式断言,对一个对象可以根据需要在一行代码中使用api接连断言,代码量少且优雅;
    2. api可读性更好,更加贴近自然语义,AssertJ中封装了海量的api,基本都可以从名字中明确理解含义;
    3. api库更强大。除了以上基础类型和异常、日期、类属性、soft断言api,更突出的优势是扩展了对以下领域的支持:DB(据说适配myBatis, Hibernate, JOOQ等多种DB框架)、Guava、Swing;Uri、xml、file;jdb8如:Future,Stream, Optional, Java 8 Date等。
    4. 开源&免费,对新技术支持迅速。当前许多新技术都
  • 缺点:待考察。

JSONassert

依赖

     <dependency>
              <groupId>org.skyscreamer</groupId>
              <artifactId>jsonassert</artifactId>
              <version>1.5.0</version>
        </dependency>

先看示例


import static org.skyscreamer.jsonassert.JSONCompareMode.*;
import org.junit.Assert;
import org.junit.Test;
import org.skyscreamer.jsonassert.*;

public class JsonAssert {

    @Test
    public void testAssertEqualsString2JSONCompare() throws JSONException {
        // STRICT_ORDER(true, true); 可数据扩展,相同数据的顺序必须一致
        // LENIENT(true, false),可数据扩展,相同数据的顺序可以不一致
        // STRICT(false, true),不可数据扩展,相同数据的顺序必须一致
        // NON_EXTENSIBLE(false, false),不可数据扩展,相同数据的顺序可以不一致

        testPass("{id:1}", "{id:1,name:\"Joe\"}", STRICT_ORDER);
        testPass("{id:1}", "{id:1,name:\"Joe\"}", LENIENT);
        testFail("{id:1}", "{id:1,name:\"Joe\"}", STRICT);
        testFail("{id:1}", "{id:1,name:\"Joe\"}", NON_EXTENSIBLE);

        testPass("{name:\"Joe\",id:1}", "{id:1,name:\"Joe\"}", LENIENT);
        testPass("{name:\"Joe\",id:1}", "{id:1,name:\"Joe\"}", STRICT);
        testPass("{name:\"Joe\",id:1}", "{id:1,name:\"Joe\"}", NON_EXTENSIBLE);
        testPass("{name:\"Joe\",id:1}", "{id:1,name:\"Joe\"}", STRICT_ORDER);

        testPass("[1,2,3]", "[1,3,2]", LENIENT);
        testFail("[1,2,3]", "[1,3,2]", STRICT);
        testFail("[1,2,3]", "[1,5,6]", LENIENT);
        testFail("[1,2,3]", "[1,2,4]", NON_EXTENSIBLE);
        testFail("[1,2,3]", "[1,3,2]", STRICT_ORDER);

        testFail("{id:1,pets:[\"dog\",\"cat\",\"fish\"]}", // Out-of-order fails
            "{id:1,pets:[\"dog\",\"fish\",\"cat\"]}", STRICT);
        testPass("{id:1,pets:[\"dog\",\"cat\",\"fish\"]}", // Out-of-order ok
            "{id:1,pets:[\"dog\",\"fish\",\"cat\"]}", LENIENT);
        testPass("{id:1,pets:[\"dog\",\"cat\",\"fish\"]}", // Out-of-order ok
            "{id:1,pets:[\"dog\",\"fish\",\"cat\"]}", NON_EXTENSIBLE);
        testFail("{id:1,pets:[\"dog\",\"cat\",\"fish\"]}", // Out-of-order fails
            "{id:1,pets:[\"dog\",\"fish\",\"cat\"]}", STRICT_ORDER);
        testFail("{id:1,pets:[\"dog\",\"cat\",\"fish\"]}", // Mismatch
            "{id:1,pets:[\"dog\",\"cat\",\"bird\"]}", STRICT);
        testFail("{id:1,pets:[\"dog\",\"cat\",\"fish\"]}", // Mismatch
            "{id:1,pets:[\"dog\",\"cat\",\"bird\"]}", LENIENT);
        testFail("{id:1,pets:[\"dog\",\"cat\",\"fish\"]}", // Mismatch
            "{id:1,pets:[\"dog\",\"cat\",\"bird\"]}", STRICT_ORDER);
        testFail("{id:1,pets:[\"dog\",\"cat\",\"fish\"]}", // Mismatch
            "{id:1,pets:[\"dog\",\"cat\",\"bird\"]}", NON_EXTENSIBLE);
    }

    private void testPass(String expected, String actual, JSONCompareMode compareMode) throws JSONException {
        JSONCompareResult result = JSONCompare.compareJSON(expected, actual, compareMode);
        Assert.assertTrue(result.passed());
    }

    private void testFail(String expected, String actual, JSONCompareMode compareMode) throws JSONException {
        JSONCompareResult result = JSONCompare.compareJSON(expected, actual, compareMode);
        Assert.assertTrue(result.failed());
    }
}

特点

  • 定义:JSONassert是个很轻量的工具包,封装处理了JSONObject、JSON String、JSONArray的比较逻辑。旨在:让开发者对json的单测写更少的代码,并且适合做REST interfaces的测试。
  • 优点:JSONassert会将string转换为JSONObject,并且结合对象的逻辑结构和数据做比较。它提供了两个维度的比较选择:是否容忍数据顺序不一致(推荐),是否容忍数据扩展,即可以选择:被比较对象增加了部分属性(忽略比较),只比较相同的属性部分。(JSONassert converts your string into a JSON object and compares the logical structure and data with the actual JSON. When strict is set to false (recommended), it forgives reordering data and extending results (as long as all the expected elements are there), making tests less brittle.)
  • 缺点:个人觉得JSONCompareMode理解性上不是太好哈,而且compare返回的是个Result Pojo,需要自己用"_success"属性判断是否成功,封装性待考虑哈。跟AssertJ的api的设计还是有差距的。不过目前看json的断言比较好的工具就是JSONassert,大家可以自己体验下。

总结

从以上介绍的顺序也能看出assert工具的一个逐渐进化的过程:

  • api从计算机的表达方式逐渐转为自然语义;
  • 从单个断言到匹配器组合,再到任意扩展的流式断言;
  • 对领域模型的封装逐渐做到强大,以及对java新技术栈的支持。

总结一句:推荐使用强大的AssertJ作为你的断言工具。

3. Mocks

Mock原理

目标

这里以jmockit(1.31版本)为例作说明,目的是想让大家在使用mock之前了解一下mock的过程,以及Instrumentation基于JVM的动态代理技术 & ASM字节码技术。知其然亦知其所以然。
另外, 其他Mock工具的动态代理的思路是一致的,但是具体技术不同,例如EasyMock、jMock、Mockito等对于接口的mock是基于java.lang.reflect.Proxy技术,生成一个新的实现类并在运行时AOP替换;或者对于非接口的非final类使用CGLIB技术动态生成子类。不过这样的技术处理会导致final类、构造方法以及一些不能被覆盖的方法不能被mock,有兴趣的同学可以挑一个框架研究一下源码。

知识准备

  • Instrumentation。

    • 解决问题: Instrumentation实现了JVM运行时对类进行动态控制和解释的这样一个动态代理。
    • 具体说明:Instrumentation基于JAVA 5之前的JVMTI(Java Virtual Machine Tool Interface:JVM本地编程工具接口集合)技术,能够对虚拟机进行类定义的改变和操作,除此之外,能够处理虚拟机内存管理,线程控制等。在Jmockit中就通过调用Instrumentation#redefineClasses(ClassDefinition... definitions)方法结合MockUp新类的字节码实现类转换;其次,结合JAVA 6的Attach VirtualMachine工具,并通过编写agentmain指定方法,能够进行实时的动态操作;此外还有prefix-instrumentation方式,支持多种定制方法名替换原有native方法,并且在使用时,如果本地类库中找不到目标prefix方法,还可以尝试做原标准方法的解析。

Mock过程

  1. 框架初始化。Jmockit实现了自己的junit Runner,在Test框架初始化的时候,会一并初始化自己的动态代理,即Instrumentation的运行环境和初始数据。
  2. 期望录制。主要是对声明为MockUp对象的类和方法转化为字节码数组,通过Instrumentation对内存方法进行重新定义,并注册到本地的环境变量。
  3. 替换预期值。执行测试方法,通过Instrumentation的动态代理监听MockUp类调用,并替换录制结果,实现虚拟机级别的AOP功能。

关键代码片段

  • 接口(&dollar;Impl_)、抽象类(&dollar;Subclass_)、普通类三种mock方式

    
    @Nonnull
    private Class<?> redefineClassOrImplementInterface(@Nonnull Class<T> classToMock)
    {
      if (classToMock.isInterface()) {
         return createInstanceOfMockedImplementationClass(classToMock, targetType);
      }
    
      Class<T> realClass = classToMock;
    
      if (isAbstract(classToMock.getModifiers())) {
         classToMock = new ConcreteSubclass<T>(classToMock).generateClass();
      }
    
      classesToRestore = redefineMethods(realClass, classToMock, targetType);
      return classToMock;
    }
    
  • 以接口mock举例:根据mockup类创建mock的实现类;并且将mock类和实例注册到MockClasses的环境变量中;用当前mock类的字节码数组通过instrumentation改变被mock的类和方法。

    
    
    @Nonnull
    public Class<T> createImplementation(@Nonnull Class<T> interfaceToBeMocked, @Nullable Type typeToMock)
    {
       createImplementation(interfaceToBeMocked);
       byte[] generatedBytecode = implementationClass == null ? null : implementationClass.getGeneratedBytecode();
    
       MockClassSetup mockClassSetup = new MockClassSetup(generatedClass, typeToMock, mockUpInstance, generatedBytecode);
       mockClassSetup.redefineMethodsInGeneratedClass();
    
       return generatedClass;
    }
    
  • instrumentation的运行时jvm通讯接口

    /**
    * This method must only be called by the JVM, to provide the instrumentation object.
    * This occurs only when the JMockit Java agent gets loaded on demand, through the Attach API.
    * <p/>
    * For additional details, see the {@link #premain(String, Instrumentation)} method.
    *
    * @param agentArgs not used
    * @param inst      the instrumentation service provided by the JVM
    */
   @SuppressWarnings({"unused", "WeakerAccess"})
   public static void agentmain(@Nullable String agentArgs, @Nonnull Instrumentation inst)
   {
      if (!inst.isRedefineClassesSupported()) {
         throw new UnsupportedOperationException(
            "This JRE must be started in debug mode, or with -javaagent:<proper path>/jmockit.jar");
      }

      String hostJREClassName = InstrumentationHolder.hostJREClassName;

      if (hostJREClassName == null) {
         hostJREClassName = MockingBridgeFields.createSyntheticFieldsInJREClassToHoldMockingBridges(inst);
      }

      InstrumentationHolder.set(inst, hostJREClassName);
      activateCodeCoverageIfRequested(agentArgs, inst);
   }
    

Mock工具比较

网上的一个表格能够清楚的看到几类常用mock工具的能力差别:

screenshot.png
screenshot.png
screenshot.png

JMockit用法

基于上面的比较,能看出JMockit功能是强大的,因此做推荐。另外从源码里能看到JMockit对Junit4/5,spring-test,以及testNG等框架都分别实现装饰器,做了较好的适配;Instrumentation Attach的虚拟机支持:Bsd/HotSpot/Linut/Windows/Solaris等;此外Jmockit自带覆盖率(coverage)统计能力,也是其他mock框架不具有的。下面对Jmockit的api和常用的写法做一些示例说明。

依赖

<dependency>
            <groupId>org.jmockit</groupId>
            <artifactId>jmockit</artifactId>
            <version>1.31</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.jmockit</groupId>
            <artifactId>jmockit-coverage</artifactId>
            <version>1.23</version>
            <scope>test</scope>
        </dependency>

JMockit API

screenshot.png

JMockit有两种mock方式:

  1. Behavior-oriented(Expectations & Verifications) :对mock目标代码的行为进行模仿,更像黑盒测试。
  2. State-oriented(MockUp): 基于状态的mock。可以对传入的参数进行检查、匹配,才返回某些结果,类似白盒;基本上可以mock任何代码或逻辑。
  • @Mocked:被修饰的对象将会被Mock,对应的类和实例都会受影响
  • @Injectable:仅Mock被修饰的实例
  • @Capturing:可以mock接口以及其所有的实现类
  • @Mock:MockUp模式中,指定被Fake的方法
  • Expectations:期望,指定的方法必须被调用
  • StrictExpectations:严格的期望,指定方法必须按照顺序调用
  • Verifications:验证
  • VerificationsInOrder:有顺序的验证
  • Invocation:工具类,可以获取调用信息
  • Delegate:自己指定返回值,适合那种需要参数决定返回值的场景,只需指定匿名子类就可以。
  • MockUp:模拟函数实现
  • Deencapsulation:反射工具类

1.mock普通方法&构造方法

这里说明一下,不仅public方法, private、protected 或者包保护级别的方法,以及static、final、native本地方法都是可以像例子里一样mock处理的。

public class MockLoginContextTest {

    // mock普通方法和构造方法
    // 方式1:典型MockUp写法
    @Test(expected = LoginException.class)
    public void mockMethodAndConstructorWithMockUpClass() throws Exception {

        new MockUp<LoginContext>() {
            //$init即构造方法mock方法
            @Mock
            void $init(String name) {
                assertEquals("test", name);
            }

            @Mock
            void login() throws LoginException {
                throw new LoginException();
            }
        };

        new LoginContext("test").login();
    }

    // mock普通方法和构造方法
    // 方式2:使用MockUp的封装类,好处是可移植可重用
    @Test
    public void mockMethodAndConstructorUsingAnnotatedMockClass() throws Exception {

        new MockLoginContext();
        new LoginContext("test", (CallbackHandler)null).login();

    }

    public class MockLoginContext extends MockUp<LoginContext> {
        //$init即构造方法mock方法
        @Mock
        public void $init(String name, CallbackHandler callbackHandler) {
            assertEquals("test", name);
            assertNull(callbackHandler);
        }

        @Mock
        public void login() {}

        @Mock
        public Subject getSubject() {
            return null;
        }
    }

}

2.mock接口

    // Mock接口
    // MockUp#getMockInstance() 返回了一个目标接口的mock实例
    @Test
    public void mockInterface() throws Exception {
        CallbackHandler callbackHandler = new MockCallbackHandler().getMockInstance();

        callbackHandler.handle(new Callback[] {new NameCallback("Enter name:")});
    }

    public static class MockCallbackHandler extends MockUp<CallbackHandler> {
        @Mock
        public void handle(Callback[] callbacks) {
            assertEquals(1, callbacks.length);
            assertTrue(callbacks[0] instanceof NameCallback);
        }
    }

3.mock类静态初始化代码块

用途:有些诸如servicelocator等类在初始化时做一些上下文加载的事情,如果不想运行相关初始化逻辑,即可用$clinit()模拟掉。

    @Test
    public void fakeStaticInitializer() {
        new MockUp<AccessibleState>() {
            @Mock
            void $clinit() {}
        };

        assertNull(AccessibleState.ACTIVE);
    }

4.使用Invocation上下文 & Deencapsulation反射工具

  • 每个被mock的方法包括构造方法,都可以选择性的在第一个参数的位置添加Invocation参数。作用很多,例如获取当前mock实例,或者当前mock方法被执行的次数;
  • Deencapsulation工具能使用反射技术,操作类外部不可见属性。这为那些专门设计的,字段在外部不可操控的类的测试提供了方便。
@Test
    public void accessMockedInstance() throws Exception {
        final Subject testSubject = new Subject();

        new MockUp<LoginContext>() {
            @Mock
            void $init(Invocation inv, String name, Subject subject) {
                // getInvokedInstance()获取当前mock实例
                LoginContext it = inv.getInvokedInstance();
                assertNotNull(name);
                assertSame(testSubject, subject);
                assertNotNull(it);
                // Deencapsulation 工具类可以用反射手段设置外部不可见的属性
                // forces setting of private field, since no setter is available
                setField(it, subject);
                // getInvocationCount()返回当前mock实例方法执行的次数
                assertEquals(inv.getInvocationCount(), 1);
            }

            @Mock
            void login(Invocation inv) {
                LoginContext it = inv.getInvokedInstance();
                assertNotNull(it);
                // returns null until the subject is authenticated or
                // loginSucceeded
                assertNull(it.getSubject());
                // private field set to true when login succeeds
                setField(it, "loginSucceeded", true);
            }

            @Mock
            void logout(Invocation inv) {
                LoginContext it = inv.getInvokedInstance();
                assertNotNull(it);
                // getSubject() return real one since loginSucceeded is true
                assertSame(testSubject, it.getSubject());
            }
        };

        LoginContext theMockedInstance = new LoginContext("test", testSubject);
        theMockedInstance.login();
        theMockedInstance.logout();
    }

5.支持mock实例执行原来的方法

mock方法中可以通过调用mock实例的proceed()方法,来执行原来的真实的方法

// mock方法中可以通过调用mock实例的proceed()方法,来执行原来的真实的方法
    @Test
    public void proceedIntoRealImplementationsOfMockedMethods() throws Exception {
        // Create objects to be exercised by the code under test:
        Configuration configuration = new Configuration() {
            @Override
            public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
                Map<String, ?> options = Collections.emptyMap();
                return new AppConfigurationEntry[] {new AppConfigurationEntry(TestLoginModule.class.getName(),
                    AppConfigurationEntry.LoginModuleControlFlag.SUFFICIENT, options)};
            }
        };

        LoginContext loginContext = new LoginContext("test", null, null, configuration);

        // Set up mocks:
        ProceedingMockLoginContext mockInstance = new ProceedingMockLoginContext();

        // Exercise the code under test:
        assertNull(loginContext.getSubject());
        loginContext.login();
        assertNotNull(loginContext.getSubject());
        assertTrue(mockInstance.loggedIn);

        mockInstance.ignoreLogout = true;
        loginContext.logout();
        assertTrue(mockInstance.loggedIn);

        mockInstance.ignoreLogout = false;
        loginContext.logout();
        assertFalse(mockInstance.loggedIn);
    }

    static final class ProceedingMockLoginContext extends MockUp<LoginContext> {
        boolean ignoreLogout;
        boolean loggedIn;

        @Mock
        void login(Invocation inv) throws LoginException {
            LoginContext it = inv.getInvokedInstance();

            try {
                inv.proceed();
                loggedIn = true;
            } finally {
                it.getSubject();
            }
        }

        @Mock
        void logout(Invocation inv) throws LoginException {
            if (!ignoreLogout) {
                inv.proceed();
                loggedIn = false;
            }
        }

    }

代码参考

http://gitlab.alibaba-inc.com/afzet/UT/

附录列表

附录1:测试框架

SpringBoot测试框架说明:http://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-testing
JUnit4 GitHub:https://github.com/junit-team/junit4/wiki
IntlTest:http://docs.alibaba-inc.com/pages/viewpage.action?pageId=42938516

附录2:Assert

AssertJ开源网站:http://joel-costigliola.github.io/assertj
AssertJ示例代码:https://github.com/joel-costigliola/assertj-examples/tree/master/assertions-examples
hamcrest官网:http://hamcrest.org/
JSONassert官网:http://jsonassert.skyscreamer.org

附录3:Mocks

mockito+spring:https://www.atatech.org/articles/64357
JMockit官网教程:http://jmockit.org/tutorial.html
JMockit SourceCode & Samples:https://github.com/jmockit/jmockit1
EasyMock官网:http://easymock.org
EasyMock 使用方法与原理剖析:https://www.ibm.com/developerworks/cn/opensource/os-cn-easymock/
Instrumentation简介:https://www.ibm.com/developerworks/cn/java/j-lo-jse61/index.html

附录4:《集团开发规约》--单元测试规范:https://www.atatech.org/articles/50331#31

目录
相关文章
|
Java 测试技术
Spock单测利器,用了都说好
参考Spock单元测试框架介绍以及在美团优选的实践最近发现了一种写法简洁高效,一个单测方法可以测试多组测试数据,且测试结果一目了然的单测框架Spock。Spock国外的测试框架,其设计灵感来自JUnit、Mockito、Groovy,可以用于Java和Groovy应用的测试。尽管Spock写单测,需要使用groovy语言,但是groovy语言是一种弱类型,写法超级简单,我也是零基础的groovy新
803 0
Spock单测利器,用了都说好
|
11月前
|
存储 Java 测试技术
【C#编程最佳实践 一】单元测试实践
【C#编程最佳实践 一】单元测试实践
74 0
|
测试技术
测试思想 单元测试用例基础设计思想总结
测试思想 单元测试用例基础设计思想总结
72 0
|
前端开发 测试技术
如何使用 Vitest 在前端项目中做单元测试 TDD
如何使用 Vitest 在前端项目中做单元测试 TDD
如何使用 Vitest 在前端项目中做单元测试 TDD
|
测试技术
软件测试面试题:单元测试、集成测试、系统测试的侧重点是什么?
软件测试面试题:单元测试、集成测试、系统测试的侧重点是什么?
104 0
|
Java 测试技术 数据库
EnvironmentPostProcessor怎么做单元测试?阿里P7告诉你
从Spring Boot 1.3开始,我们可以在应用程序上下文刷新之前使用EnvironmentPostProcessor来自定义应用程序的Environment。Environment表示当前应用程序运行的环境,它可以统一访问各种属性源中的属性,如属性文件、JVM系统属性、系统环境变量和Servlet上下文参数。使用EnvironmentPostProcessor可以在bean初始化之前对Environment进行修改。
208 0
|
SQL JSON Java
谈一谈单元测试
写在前面对于我们开发人员来说,单元测试一定不会陌生,但在各种原因下会被忽视,尤其是在我接触到的项目中,提测阶段发现各种各样的问题,我觉得有必要聊一下单元测试。对于单元测试到底有没有存在的必要,这里不是我想要说的重点。有兴趣的可以去了解一下:【单元测试和TDD】【单元测试到底是什么】为了写而写的单元测试没什么价值,但一个好的单元测试带来的收益是非常客观的。问题是怎么去写好单元测试?怎么去驱动写好单元
616 0
谈一谈单元测试
|
JavaScript 前端开发 IDE
如何为开源技术项目做单元测试
如何高效、正确的做单元测试?
如何为开源技术项目做单元测试
|
测试技术 C#
【译】单元测试最佳实践
原文地址:Unit testing best practicesPS:本文未翻译原文的全部内容,以下为译文。 编写单元测试有如下好处: 利于回归测试 提供文档 改进代码设计 但是,难以阅读和维护的测试代码则会适得其反。
1685 0
|
Java 测试技术 API
单元测试实践
工欲善其事,必先利其器. 单元测试三剑客: - TestNg:单元测试框架 - AssertJ:断言工具 - Jmockit:mock工具
3427 0