从 0 到 0.1 入门单元测试

单元测试

故事场景

工厂生产电视机

工厂首先会将各种电子元器件按照图纸组装在一起构成各个功能电路板,比如供电板、音视频解码板、射频接收板等,然后再将这些电路板组装起来构成一个完整的电视机。

如果一切顺利,接通电源后,你就可以开始观看电视节目了。但是很不幸,大多数情况下组装完成的电视机根本无法开机,这时你就需要把电视机拆开,然后逐个模块排查问题。

假设你发现是供电板的供电电压不足,那你就要继续逐级排查组成供电板的各个电子元器件,最终你可能发现罪魁祸首是一个电容的故障。这时,为了定位到这个问题,可能已经花费了大量的时间和精力。

如何避免?

如何才能避免类似的问题呢?

为什么不在组装前,就先测试每个要用到的电子元器件呢?这样就可以先排除有问题的元器件,最大程度地防止组装完成后逐级排查问题的事情发生。

单元测试 VS 工厂生产电视机

如果把电视机的生产、测试和软件的开发、测试进行类比,可以发现:

  • 电子元器件就像是软件中的单元,通常是函数或者类,对单个元器件的测试就像是软件测试中的单元测试;
  • 组装完成的功能电路板就像是软件中的模块,对电路板的测试就像是软件中的集成测试;
  • 电视机全部组装完成就像是软件完成了预发布版本,电视机全部组装完成后的开机测试就像是软件中的系统测试。

通过类比,可以发现单元测试的重要性。那么单元测试到底是什么呢?

单元测试-基本概念

单元测试是指,对软件中的最小可测试单元在与程序其他部分相隔离的情况下进行检查和验证的工作,最小可测试单元通常是指函数或者类。

维基百科中这样定义:

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

其实,对“单元”的定义取决于自己。如果正在使用函数式编程,一个单元最有可能指的是一个函数,单元测试将使用不同的参数调用这个函数,并断言它返回了期待的结果;在面向对象语言里,下至一个方法,上至一个类都可以是一个单元。

单元测试-金字塔模型

冰淇淋模型

在金字塔模型之前,流行的是冰淇淋模型。包含了大量的手工测试、端到端的自动化测试及少量的单元测试。造成的后果是,随着产品壮大,手工回归测试时间越来越长,质量很难把控;自动化 case 经常失败,每一个失败对应着一个很长的函数调用。

冰淇淋模型

哪里出了问题?

  • 单元测试太少,基本没起作用。

金字塔模型

”测试金字塔“ 比喻非常形象,让人一眼就知道测试是需要分层的,并且还告诉你每一层需要写多少测试。测试金字塔具备两点经验法则:

  • 编写不同粒度的测试
  • 层次越高,写的测试应该越少
金字塔模型

可以把金字塔模型理解为——冰激凌融化了。就是指,最顶部的“手工测试”理论上全部要自动化,向下融化,优先全部考虑融化成单元测试,单元测试覆盖不了的放在中间层(分层测试),再覆盖不了的才会放到 UI 层。 因此,不分单元测试还是分层测试,统一都叫自动化测试,把所有的自动化 case 看做一个整体,case不要冗余,单元测试能覆盖,就要把这个case从分层或ui中去掉。越是底层的测试,牵扯到相关内容越少,而高层测试则涉及面更广。

  • 单元测试:它的关注点只有一个单元,而没有其它任何东西。所以,只要一个单元写好了,测试就是可以通过的

  • 集成测试:要把好几个单元组装到一起才能测试,测试通过的前提条件是,所有这些单元都写好了,这个周期就明显比单元测试要长

  • 系统测试:要把整个系统的各个模块都连在一起,各种数据都准备好,才可能通过。另外,因为涉及到的模块过多,任何一个模块做了调整,都有可能破坏高层测试

单元测试-意义

  • 在开发早期以最小的成本保证局部代码的质量
  • 在单元测试代码里提供函数的使用示例
  • 实施过程中帮助开发工程师改善代码的设计与实现
  • 单元测试都是以自动化的方式执行,在大量回归测试的场景下更能带来高收益

如何做好单元测试

  • 明确单元测试的对象是代码,代码的基本特征和产生错误的原因
  • 对单元测试的用例设计有深入的理解
  • 掌握单元测试的基本方法和主要技术手段 - 驱动代码、桩代码和 Mock 代码等

代码的基本特征与产生错误的原因

抛开业务逻辑,从代码结构来看:

所有的代码无异于条件分支、循环处理和函数调用等最基本的逻辑控制,都是在对数据进行分类处理,每一次条件判定都是一次分类处理。

  • 如果有任何一个分类遗漏,都会产生缺陷
  • 如果有任何一个分类错误,也会产生缺陷
  • 如果分类正确也没有遗漏,但是分类时的处理逻辑错误,也同样会产生缺陷

对单元测试的用例设计有深入的理解

单元测试的用例是一个“输入数据”和“预计输出”的集合。就是在明确了代码需要实现的逻辑功能的基础上,什么输入,应该产生什么输出。但是会存在下面的误区:

误区

  1. 输入:只有被测试函数的输入参数是“输入数据”
  2. 输出:只有函数返回值是”输出数据“
完整的单元测试“输入数据”
  • 被测试函数的输入参数~~~~
  • 被测试函数内部需要读取的全局静态变量
  • 被测试函数内部需要读取的成员变量
  • 函数内部调用子函数获得的数据
  • 函数内部调用子函数改写的数据
  • 嵌入式系统中,在中断调用时改写的数据
  • ...
完整的单元测试“输出数据”
  • 被测试函数的返回值
  • 被测试函数的输出参数
  • 被测试函数所改写的成员变量
  • 被测试函数所改写的全局变量
  • 被测试函数中进行的文件更新
  • 被测试函数中进行的数据库更新
  • 被测试函数中进行的消息队列更新
  • ...

掌握单元测试的基本方法和主要技术手段 - 驱动代码、桩代码和 Mock 代码等

  • 驱动代码(Driver): 指调用被测函数的代码,通常包括了被测函数前的数据准备(如 @Before 修饰的代码)、调用被测函数以及验证相关结果,结构通常由单元测试框架决定
  • 桩代码(Stub): 是用来代替真实代码的临时代码
  • Mock 代码: 和桩代码非常类似,都是用来代替真实代码的临时代码,起到隔离和补齐的作用,和桩代码的本质区别是:测试期待结果的验证(Assert and Expectiation)。
驱动代码
桩代码-被测函数

示例:函数A是被测函数,内部调用了函数B

void funcA(){
    boolean funcB_retVal = funcB();
    if (true == funcB_retV){
        do Operation 1;
    }else{
        do Operation 2;
    }
}
桩代码-桩函数

在单元测试阶段,由于函数B尚未实现,但是为了不影响对函数A的测试,可以用一个假的函数B来代替真实的函数B,那么这个假的函数B就是桩函数。

并且,为实现函数A的全路径覆盖,需要控制不同的测试用例中函数B的返回值,代码如下:

boolean funcB(){
    if(testCaseID == 'TC0001'){
        return true;
    }else if(testCaseID == 'TC0002'){
        return false;
    }
}

当执行第一个测试用例的时候,桩函数B应该返回true,而当执行第二个测试用例的时候,桩函数B应该返回false。

桩代码原则
  1. 具有与原函数完全相同的原形,仅仅是内部实现不同
  2. 用于隔离和补齐的桩函数,只需保持原函数声明,加一个空实现,目的是通过编译链接
  3. 控制功能的桩函数要根据测试用例的需要,输出合适的数据作为被测函数的输入

同时,桩代码关注点是利用 Stub 来控制被测函数的执行路径,不会去关注 Stub 是否被调用以及怎样被调用。

Mock 代码

关注点:

  • Mock 方法有没有被调用
  • 以什么样的参数被调用
  • 被调用的次数
  • 多个 Mock 函数的先后调用顺序
  • ...

所以,在使用Mock代码的测试中,对于结果的验证(也就是assert),通常出现在 Mock 函数中

Mock 测试

背景

对于持续交付中的测试来说,自动化回归测试不可或缺,但存在如下三个难点:

  1. 测试数据的准备和清理
  2. 分布式系统的依赖
  3. 测试用例高度仿真

解决方案:

  1. Mock
  2. “回放”技术(记录实际用户在生产环境的操作,然后在测试环境中回放)
    1. 拦截:
      1. SLB 统一做拦截和复制转发处理;主路径影响路由,容易故障
      2. 集群扩容一台软交换服务器,负责复制和转发用户请求;
    2. 回放

Mock 背景 - 分布式系统依赖

微服务项目中会出现相互依赖的关系,比如由于服务 B 依赖服务 C,而服务 C 还没有开发完成,导致即使服务 A 和服务 B 都没问题,但也没有办法完成服务 A 的接口测试。

分布式系统依赖

还有的会依赖数据库、消息中间件等:

测试过程中,被测对象的外部依赖情况展示

Mock 基本概念介绍

Mock 测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试的测试方法。

好处

  1. 团队并行工作
    团队间不需互相等待对方进度,只需约定好相互之间的数据规范(接口文档),即可使用 mock 构建出可用接口,然后尽快进行开发和自测,提前发现缺陷

  2. 测试驱动开发 TDD (Test-Driven Development)
    单元测试是 TDD 实现的基石,而 TDD 经常会碰到协同模块尚未开发完成的情况,但有了 mock,当接口定义好后,测试人员就可以创建一个 Mock,把接口添加到自动化测试环境,提前创建测试。

  3. 测试覆盖率
    若一个接口在不同的状态下要返回不同的值,常见做法是复现这种状态然后再去请求接口,而这种方法很可能因操作时机或方式不当导致失败,甚至污染后端存储如数据库等, 但用 mock 则不用担心

  4. 隔离系统
    使用某些接口时,为避免系统数据库被污染,可以将接口调整为 Mock 模式,以保证数据库纯净。

  5. 方便演示

Mock 框架介绍

Mock 技术主要的应用场景可以分为两类:

  1. 基于对象和类的 Mock

    1. Mockito & PowerMock
  2. 基于微服务的 Mock

    1. Moco、MockMVC、WireMock、Mock Server

因为项目主要基于 Java 开发, 因此下面主要介绍 Java 相关的 Mock 框架, 其他语言思想类似

基于对象和类的 Mock

  • 原理:
    • 在运行时,为每一个被 Mock 的对象或类动态生成一个代理对象,由这个代理对象返回预先设计的结果
  • 场景:
    • 适合模拟 DAO 层的数据操作和复杂逻辑,常用于用于单元测试阶段

基于微服务的 Mock

从代码编写的角度来看,实现方式如下:

  • 声明被代理的服务
  • 通过 Mock 框架定制代理的行为
  • 调用代理,从而获得预期的结果

Mockito & PowerMock ★★

Mockito 是 GitHub 上使用非常广泛的 Java Mock 框架, star 数 11k, 在包括 openstack4jkubernetes-client/java 等都有用到。Mockito 与 JUnit 结合使用, 能隔离外部依赖以便对自己的业务逻辑代码进行单元测试在编写单元测试需要调用某一个接口时,可以模拟一个假方法,并任意指定方法的返回值。

但缺点是 Mockito 2 版本对静态方法、final 方法、private 方法和构造函数的功能支持并不完善, 因此 PowerMock 则在 Mockito 原有的基础上做了扩展,通过修改类字节码并使用自定义 ClassLoader 加载运行的方式来实现 mock 静态方法、final 方法、private 方法和构造函数等功能。

Mockito & PowerMock 一般测试步骤
1. mock: 模拟对象

用 mock()/@Mock 或 spy()/@Spy 创建模拟对象, 两者创建出来的模拟对象区别是: 使用 mock 生成的对象,所有方法都是被 mock 的,除非某个方法被 stub 了,否则返回值都是默认值; 使用 spy 生产的 spy 对象,所有方法都是调用的 spy 对象的真实方法,直到某个方法被 stub 后

2. stub: 定义桩函数

可以通过 when()/given()/thenReturn()/doReturn()/thenAnswer() 等来定义 mock 对象如何执行, 如果提供的接口不符合需求, 还可以通过实现 Answer 接口来自定义实现

3. run: 执行调用

执行实际方法的调用,此时被 mock 的对象将返回自定义的桩函数的返回值

4. verify: 可选, 对调用进行验证, 如是否被调用, 调用次数等

这一步可以对 mock 对象的方法是否被调用以及被调用次数进行验证,同时还可以对参数捕获进行参数校验

下面以操作 Redis 和 RabbitMQ 来进行简单举例。

首先引入依赖:

<powermock.version>2.0.2</powermock.version>

...

<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>
<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>
Redis 示例
// redis 操作类
class RedisDemo {

    private Jedis jedis;

    public void setUp() {
        jedis = new Jedis("127.0.0.1", 6379);
        jedis.connect();
    }

    public boolean isAdmin(String user) {
        String ret = jedis.get("name");
        if (user.equals(ret)) {
            return true;
        }
        return false;
    }

    public void set(String key, String val) {
        jedis.set(key, val);
    }

    public String get(String key) {
        String s = jedis.get(key);
        return s;
    }

    void out(){
        System.out.println("ss");
    }
}

// 单元测试类
@RunWith(PowerMockRunner.class) //让测试运行于PowerMock环境
public class RedisMockitoTest {

    @Mock //此注解会自动创建1个mock对象并注入到@InjectMocks对象中
    private Jedis jedis;

    @InjectMocks
    private RedisDemo demo;

    @Mock
    StringOperator stringOperator;

    //第1种方式
    @Test
    public void redisTest1() throws Exception {
        Mockito.when(jedis.get("name")).thenReturn("admin");
        boolean admin = demo.isAdmin("admin");
        assertTrue(admin);
    }

    //第2种方式
    @Test
    public void redisTest2() {
        RedisDemo demo = mock(RedisDemo.class);
        ReflectionTestUtils.setField(demo, "jedis", jedis);
        when(demo.isAdmin("admin")).thenReturn(true);
        boolean admin = demo.isAdmin("admin");
        assertTrue(admin);
    }

    //第3种方式
    @Test
    public void redisTest3() {
        RedisDemo demo = mock(RedisDemo.class);
        doReturn(true).when(demo).isAdmin("admin");
        System.out.println(demo.isAdmin("admin"));
    }
}
RabbitMQ 示例
@Component
public class DirectReceiver {
    @Autowired
    RabbitTemplate rabbitTemplate;

    public Object getMsg() {
        return rabbitTemplate.receiveAndConvert("queue_demo");
    }
}

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = Main.class)
public class RecvMessage {
    @Spy
    RabbitTemplate rabbitTemplate;

    @InjectMocks
    @Autowired
    DirectReceiver receiver;

    @Test
    public void recvTest() {
        doReturn("Mock answer").when(rabbitTemplate).receiveAndConvert("queue_demo");
        System.out.println(rabbitTemplate.receiveAndConvert("queue_demo"));
    }
}
更多示例
public class Node {
    private int num;
    private String name;

    public static Node getStaticNode() {
        return new Node(1, "static node");
    }

    public Node() {
    }

    public Node(String name) {
        this.name = name;
    }

    public Node(int num) {
        this.num = num;
    }

    public Node(int num, String name) {
        this.num = num;
        this.name = name;
    }
}

public class LocalServiceImpl implements ILocalService {

    @Autowired
    private IRemoteService remoteService;

    @Override
    public Node getLocalNode(int num, String name) {
        return new Node(num, name);
    }

    @Override
    public Node getRemoteNode(int num) {
        return remoteService.getRemoteNode(num);
    }

    @Override
    public Node getRemoteNode(String name) throws MockException {
        try {
            return remoteService.getRemoteNode(name);
        } catch (IllegalArgumentException e) {
            throw e;
        }
    }

    @Override
    public void remoteDoSomething() {
        remoteService.doSometing();
    }

}

public class RemoteServiceImpl implements IRemoteService {

    @Override
    public Node getRemoteNode(int num) {
        return new Node(num, "Node from remote service");
    }

    @Override
    public final Node getFinalNode() {
        return new Node(1, "final node");
    }

    @Override
    public Node getRemoteNode(String name) throws MockException {
        if (StringUtils.isEmpty(name)) {
            throw new MockException("name不能为空", name);
        }
        return new Node(name);
    }

    @Override
    public void doSometing() {
        System.out.println("remote service do something!");
    }

    @Override
    public Node getPrivateNode() {
        return privateMethod();
    }

    private Node privateMethod() {
        return new Node(1, "private node");
    }

    @Override
    public Node getSystemPropertyNode() {
        return new Node(System.getProperty("abc"));
    }
}

// 单元测试类
@RunWith(MockitoJUnitRunner.class) //让测试运行于Mockito环境
public class LocalServiceImplMockTest {

    @InjectMocks //此注解表示这个对象需要被注入mock对象
    private LocalServiceImpl localService;
    @Mock //此注解会自动创建1个mock对象并注入到@InjectMocks对象中
    private RemoteServiceImpl remoteService;
    @Captor
    private ArgumentCaptor<String> localCaptor;

    //如果不使用上述注解,可以使用@Before方法来手动进行mock对象的创建和注入,但会多几行代码
    /*@Before
    public void setUp() throws Exception {
        localService = new LocalServiceImpl();
        remoteService = mock(RemoteServiceImpl.class);
        Whitebox.setInternalState(localService, "remoteService", remoteService);
    }*/

    /**
     * any系列方法指定多参数情况
     */
    @Test
    public void testAny() {
        Node target = new Node(1, "target");
        when(remoteService.getRemoteNode(anyInt())).thenReturn(target); //静态导入Mockito.when和ArgumentMatchers.anyInt后可以简化代码提升可读性

        Node result = localService.getRemoteNode(20); //上面指定了调用remoteService.getRemoteNode(int)时,不管传入什么参数都会返回target对象
        assertEquals(target, result);   //可以断言我们得到的返回值其实就是target对象
        assertEquals(1, result.getNum());   //具体属性和我们指定的返回值相同
        assertEquals("target", result.getName());   //具体属性和我们指定的返回值相同
    }

    /**
     * 指定mock多次调用返回值
     */
    @Test
    public void testMultipleReturn() {
        Node target1 = new Node(1, "target");
        Node target2 = new Node(1, "target");
        Node target3 = new Node(1, "target");
        when(remoteService.getRemoteNode(anyInt())).thenReturn(target1).thenReturn(target2).thenReturn(target3);
        //第一次调用返回target1、第二次返回target2、第三次返回target3

        Node result1 = localService.getRemoteNode(1); //第1次调用
        assertEquals(target1, result1);
        Node result2 = localService.getRemoteNode(2); //第2次调用
        assertEquals(target2, result2);
        Node result3 = localService.getRemoteNode(3); //第3次调用
        assertEquals(target3, result3);
    }

    /**
     * 指定mock对象已声明异常抛出的方法抛出受检查异常
     */
    @Test
    public void testCheckedException() {
        try {
            Node target = new Node(1, "target");
            when(remoteService.getRemoteNode("name")).thenReturn(target).thenThrow(new MockException("message", "exception")); //第一次调用正常返回,第二次则抛出一个Exception

            Node result1 = localService.getRemoteNode("name");
            assertEquals(target, result1); //第一次调用正常返回

            Node result2 = localService.getRemoteNode("name"); //第二次调用不会正常返回,会抛出异常
            assertEquals(target, result2);
        } catch (MockException e) {
            assertEquals("exception", e.getName()); //验证是否返回指定异常内容
            assertEquals("message", e.getMessage()); //验证是否返回指定异常内容
        }
    }

    /**
     * 校验mock对象和方法的调用情况
     */
    public void testVerify() {
        Node target = new Node(1, "target");
        when(remoteService.getRemoteNode(anyInt())).thenReturn(target);

        verify(remoteService, Mockito.never()).getRemoteNode(1); //mock方法未调用过

        localService.getRemoteNode(1);
        verify(remoteService, times(1)).getRemoteNode(anyInt()); //目前mock方法调用过1次

        localService.getRemoteNode(2);
        verify(remoteService, times(2)).getRemoteNode(anyInt()); //目前mock方法调用过2次
        verify(remoteService, times(1)).getRemoteNode(2); //目前mock方法参数为2只调用过1次
    }

    /**
     * mock对象调用真实方法
     */
    @Test
    public void testCallRealMethod() {
        when(remoteService.getRemoteNode(anyInt())).thenCallRealMethod(); //设置调用真实方法
        Node result = localService.getRemoteNode(1);

        assertEquals(1, result.getNum());
        assertEquals("Node from remote service", result.getName());
    }

    /**
     * 利用ArgumentCaptor捕获方法参数进行mock方法参数校验
     */
    @Test
    public void testCaptor() throws Exception {
        Node target = new Node(1, "target");
        when(remoteService.getRemoteNode(anyString())).thenReturn(target);

        localService.getRemoteNode("name1");
        localService.getRemoteNode("name2");
        verify(remoteService, atLeastOnce()).getRemoteNode(localCaptor.capture()); //设置captor

        assertEquals("name2", localCaptor.getValue()); //获取最后一次调用的参数
        List<String> list = localCaptor.getAllValues(); //按顺序获取所有传入的参数
        assertEquals("name1", list.get(0));
        assertEquals("name2", list.get(1));
    }

    /**
     * 校验mock对象0调用和未被验证的调用
     */
    @Test(expected = NoInteractionsWanted.class)
    public void testInteraction() {

        verifyZeroInteractions(remoteService); //目前还未被调用过,执行不报错

        Node target = new Node(1, "target");
        when(remoteService.getRemoteNode(anyInt())).thenReturn(target);

        localService.getRemoteNode(1);
        localService.getRemoteNode(2);
        verify(remoteService, times(2)).getRemoteNode(anyInt());
        // 参数1和2的两次调用都会被上面的anyInt()校验到,所以没有未被校验的调用了
        verifyNoMoreInteractions(remoteService);

        reset(remoteService);
        localService.getRemoteNode(1);
        localService.getRemoteNode(2);
        verify(remoteService, times(1)).getRemoteNode(1);
        // 参数2的调用不会被上面的校验到,所以执行会抛异常
        verifyNoMoreInteractions(remoteService);
    }
}

Moco

Moco 框架在开发 Mock 服务的时候提供了一种不需任何编程语言的方式, 可以通过撰写它约束的 json 建立服务, 并通过命令独立启动对应的服务, 这可以快速开发和启动运行所需的 Mock 服务. 除此之外, 也可以编写服务代码来进行测试. 下面进行简单举例:

  1. 使用 json 配置文件启动 mock 服务
# foo.json
[
  {
    "response" :
      {
        "text" : "Hello, Moco"
      }
  }
]
java -jar moco-runner-1.1.0-standalone.jar  http -p 12306 -c foo.json

这时访问 http://localhost:12306/ 将会返回 Hello, Moco

  1. 在项目中使用 Moco Java API
    除了使用 json 配置文件作为独立服务启动外, 还可以使用 Java API 来启动 mock 服务, 下面是代码片段:

首先引入依赖:

<dependency>
    <groupId>com.github.dreamhead</groupId>
    <artifactId>moco-core</artifactId>
    <version>1.1.0</version>
</dependency>
@RunWith(SpringRunner.class)
@SpringBootTest(classes = MockServletContext.class)
public class MockAPITest {
    @Test
    public void should_response_as_expected() throws Exception {
        HttpServer server = httpServer(12307);
        server.response("foo");
        running(server, new Runnable() {
            @Override
            public void run() throws IOException {
                CloseableHttpResponse response = HttpClients.createDefault().execute(new HttpGet("http://localhost:12307"));
                String content = EntityUtils.toString(response.getEntity(), "UTF-8");
                assertThat(content, is("foo"));
            }
        });
    }
}

Moco 还支持 HTTPS 和 Socket, 支持与 JUnit 集成等, 详细内容见文档使用说明

MockMVC ★

MockMVC 是 spring-boot-starter-test 包自带的 Mock API,MockMvc 实现了对 Http 请求的模拟,可以方便对 Controller 进行测试,测试速度快、不依赖网络环境,且提供了验证的工具。下面是具体示例:

  • HelloController
//HelloController
@RestController
public class HelloController {
    @RequestMapping("/hello")
    public String index() {
        return "Hello World";
    }
}
  • UserController
//UserController
@Slf4j
@RestController
@RequestMapping(value = "/users")     // 通过这里配置使下面的映射都在/users下
public class UserController {
    // 创建线程安全的Map
    static Map<Long, User> users = Collections.synchronizedMap(new HashMap<Long, User>());

    @RequestMapping(value = "/", method = RequestMethod.GET)
    public List<User> getUserList() {
        // 处理"/users/"的GET请求,用来获取用户列表
        // 还可以通过@RequestParam从页面中传递参数来进行查询条件或者翻页信息的传递
        List<User> r = new ArrayList<User>(users.values());
        return r;
    }

    @RequestMapping(value = "/", method = RequestMethod.POST)
    public String postUser(@ModelAttribute User user) {
        // 处理"/users/"的POST请求,用来创建User
        // 除了@ModelAttribute绑定参数之外,还可以通过@RequestParam从页面中传递参数
        users.put(user.getId(), user);
        return "success";
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.GET)
    public User getUser(@PathVariable Long id) {
        // 处理"/users/{id}"的GET请求,用来获取url中id值的User信息
        // url中的id可通过@PathVariable绑定到函数的参数中
        return users.get(id);
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.PUT)
    public String putUser(@PathVariable Long id, @ModelAttribute User user) {
        // 处理"/users/{id}"的PUT请求,用来更新User信息
        User u = users.get(id);
        u.setName(user.getName());
        u.setAge(user.getAge());
        users.put(id, u);
        return "success";
    }

    @RequestMapping(value = "/{id}", method = RequestMethod.DELETE)
    public String deleteUser(@PathVariable Long id) {
        // 处理"/users/{id}"的DELETE请求,用来删除User
        users.remove(id);
        return "success";
    }
    // 测试 
    @RequestMapping(value = "/postByJson", method = RequestMethod.POST)
    public String postByJson(@RequestBody User user, String method) {
        log.info("user: {};   method: {}", user, method);
        return "success";
    }
}

  • 单元测试类 HttpMockTest
public class HttpMockTest {

    private MockMvc mvc;
    private final static ObjectMapper objectMapper = new ObjectMapper();

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

    @Test
    public void getHello() throws Exception {
        mvc.perform(MockMvcRequestBuilders.get("/hello").accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("Hello World")));
    }

    @Test
    public void testUserController() throws Exception {
        // 测试UserController
        RequestBuilder request = null;

        // 1、get查一下user列表,应该为空
        request = get("/users/");
        mvc.perform(request)
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("[]")));

        // 2、post提交一个user
        request = post("/users/")
                .param("id", "1")
                .param("name", "测试大师")
                .param("age", "20");
        mvc.perform(request)
                .andDo(MockMvcResultHandlers.print())
                .andExpect(content().string(equalTo("success")));

        // 3、get获取user列表,应该有刚才插入的数据
        request = get("/users/");
        mvc.perform(request)
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("[{\"id\":1,\"name\":\"测试大师\",\"age\":20}]")));

        // 4、put修改id为1的user
        request = put("/users/1")
                .param("name", "测试终极大师")
                .param("age", "30");
        mvc.perform(request)
                .andExpect(content().string(equalTo("success")));

        // 5、get一个id为1的user
        request = get("/users/1");
        mvc.perform(request)
                .andExpect(content().string(equalTo("{\"id\":1,\"name\":\"测试终极大师\",\"age\":30}")));

        // 6、del删除id为1的user
        request = delete("/users/1");
        mvc.perform(request)
                .andExpect(content().string(equalTo("success")));

        // 7、get查一下user列表,应该为空
        request = get("/users/");
        mvc.perform(request)
                .andExpect(status().isOk())
                .andExpect(content().string(equalTo("[]")));

        // 8、json作为参数
        request = post("/users/postByJson")
                .param("method", "postByJson")
                .content(objectMapper.writeValueAsString(new User(1L, "USER", 23)))
                .contentType(MediaType.APPLICATION_JSON);
        mvc.perform(request).andExpect(status().is(200))
                .andExpect(content().string("success"));
    }
}

WireMock ★★

WireMock 是在阅读 kubernetes-client/java 代码时发现的, 在其中有大量使用,它是基于 HTTP API 的 mock 服务框架,和前面提到的 moco 一样,它可以通过文件配置以独立服务启动, 也可以通过代码控制,同时 Spring Cloud Contract WireMock 模块也使得可以在 Spring Boot 应用中使用 WireMock,具体介绍见 Spring Cloud Contract WireMock 。除此之外, WireMock 还提供了在线 mock 服务 MockLab 。下面是 WireMock 在 K8S API 上的示例:

public class K8SApiTest {
    @Rule
    public WireMockRule wireMockRule = new WireMockRule(8000);

    private GenericKubernetesApi<V1Job, V1JobList> jobClient;

    ApiClient apiClient;


    @Before
    public void setup() {
        apiClient = new ClientBuilder().setBasePath("http://localhost:" + 8000).build();
        jobClient =
                new GenericKubernetesApi<>(V1Job.class, V1JobList.class, "batch", "v1", "jobs", apiClient);
    }

    // test delete
    @Test
    public void delJob() {
        V1Status status = new V1Status().kind("Status").code(200).message("good!");
        stubFor(
                delete(urlEqualTo("/apis/batch/v1/namespaces/default/jobs/foo1"))
                        .willReturn(aResponse().withStatus(200).withBody(new Gson().toJson(status))));

        KubernetesApiResponse<V1Job> deleteJobResp = jobClient.delete("default", "foo1", null);
        assertTrue(deleteJobResp.isSuccess());
        assertEquals(status, deleteJobResp.getStatus());
        assertNull(deleteJobResp.getObject());
        verify(1, deleteRequestedFor(urlPathEqualTo("/apis/batch/v1/namespaces/default/jobs/foo1")));
    }

    @Test
    public void getNs() throws ApiException {
        Configuration.setDefaultApiClient(apiClient);

        V1Namespace ns1 = new V1Namespace().metadata(new V1ObjectMeta().name("name"));

        stubFor(
                get(urlEqualTo("/api/v1/namespaces/name"))
                        .willReturn(
                                aResponse()
                                        .withHeader("Content-Type", "application/json")
                                        .withBody(apiClient.getJSON().serialize(ns1))));

        CoreV1Api api = new CoreV1Api();
        V1Namespace ns2 = api.readNamespace("name", null, null, null);
        assertEquals(ns1, ns2);
    }
}

DOClever Mock Server ★★

DOClever 集成了 Mock.js,因此自身就是一个 Mock Server,当把接口的开发状态设置成已完成,本地 Mock 便会自动请求真实接口数据,否则返回事先定义好的 Mock 数据,适合 Web 前后端同学进行开发自测。

Mock Server

Mock 总结

以上,就是关于 Mock 技术以及框架及使用的简单介绍, 更多详细用法还需要参考相应的文档或源码。

关于 Mock 服务框架的选择:

  1. 首先要基于团队的技术栈来选择,这决定了完成服务"替身"的速度
  2. 其次,Mock 要方便快速修改和维护,并能马上发挥作用

关于 Mock 服务的设计:

  1. 首先要简单
  2. 其次,处理速度比完美的 Mock 服务更重要
  3. 最后,Mock 服务要能轻量化启动,并能容易销毁。

代码覆盖率

代码覆盖率是指,至少被执行了一次的条目数占整个条目数的百分比,常被用来衡量测试的充分性和完整性。

常用的三种代码覆盖率指标:

  1. 行覆盖率: 又称语句覆盖率,指已经被执行到的语句占总可执行语句的百分比。
  2. 判定覆盖: 又称分支覆盖,度量程序中每一个判定的分支是否都被测试到了
  3. 条件覆盖: 判定中的每个条件的可能取值至少满足一次,度量判定中的每个条件的结果 TRUE 和 FALSE 是否都被测试到了。

代码覆盖率的价值

  • 根本目的: 找出潜在的遗漏测试用例,并有针对性的进行补充
  • 识别出由于需求变更等原因造成的不可达的废弃代码

代码覆盖率的局限性

  1. 高的代码覆盖率不一定能保证软件的质量,但是低的代码覆盖率一定不能能保证软件的质量。如“未考虑某些输入”以及“未处理某些情况”形成的缺陷。
  2. 从技术实现上讲,单元测试可以最大化地利用打桩技术来提高覆盖率。
  3. 但在后期,需要付出越来越大的代价,因为需要大量的桩代码、Mock 代码和全局变量的配合来控制执行路径。

代码覆盖率工具

IDEA 覆盖率工具

执行 xxxTest with Coverage 会进行覆盖率统计

IDEA 覆盖率工具
IDEA 覆盖率结果
IDEA 覆盖率配置

JaCoCo

JaCoCo 是一款 Java 代码的主流开源覆盖率工具,可以很方便地嵌入到 Maven 中,并且和很多主流的持续集成工具如 Jekins 等以及代码静态检查工具,都有很好的集成。在上面 IDEA 覆盖率配置中选择 Coverage Runner 为 JaCoCo,并导入 pom 依赖后,再运行测试即可得到如下测试覆盖率报告:

<dependency>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.2</version>
</dependency>
JaCoCo 覆盖率统计

其它

性能测试和分析

在前面的筋斗云开发过程中,遇到过几次反馈接口响应较慢的问题,因此,针对这种问题,在最近筋斗云开发中,开始尝试学习借助一些性能测试、分析工具来分析代码具体的执行性能,下面给出一些自己的探索:

性能测试 - JMH

JMH(Java Microbenchmark Harness) 是用于代码微基准测试的工具套件,由 Oracle 内部实现 JIT 的大牛们编写,主要是基于方法层面的基准测试,精度可以达到纳秒级。当定位到热点方法,希望进一步优化方法性能的时候,可以使用 JMH 对优化的结果进行量化的分析。

下面以 Java 中常见的字符串拼接来进行对比 +StringBuilder 的性能测试对比:

package jvm;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 3, time = 1)
@Threads(2)
@Fork(1)
@State(value = Scope.Benchmark)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class StringConnectTest {

    @Param(value = {"10", "20"})
    private int length;

    @Benchmark
    public void testStringAdd(Blackhole blackhole) {
        String a = "";
        for (int i = 0; i < length; i++) {
            a += i;
        }
        blackhole.consume(a);
    }

    @Benchmark
    public void testStringBuilderAdd(Blackhole blackhole) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < length; i++) {
            sb.append(i);
        }
        blackhole.consume(sb.toString());
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(StringConnectTest.class.getSimpleName())
                .result("result.json")
                .resultFormat(ResultFormatType.JSON).build();
        new Runner(opt).run();
    }
}

测试结果:

  • 运行中:
JMH运行中
  • 运行结束:
JMH运行结束

更多性能测试例子可见 OpenJDK 官方示例

性能分析 - JProfiler

类似于 Go 中 pprof,可以分析 CPU、内存等性能。

使用步骤:

  1. 安装 IDEA JProfiler Plugin
jprofile-plugin
  1. 官网下载安装相应平台的可执行程序
jprofile-exe
  1. 检查 IDEA JProfiler 配置
idea-jprofile
  1. 以 JProfiler 启动程序
jprofile-launch
  1. 执行请求,选择 CPU View 观察代码执行时间
cpu-view

系统性能分析

目前这只在应用层面代码上进行了一些性能分析和调优的探索,随着项目的深入和学习,未来要继续深入系统层面的性能优化:

  • CPU 性能
    • CPU 使用率、僵尸进程、CPU 瓶颈...
  • 内存性能
    • 内存分配、内存泄露、Buffer、Cache、Swap...
  • 网络性能
    • TCP、HTTP、RPC、网络延迟分析...
  • I/O 性能
    • 磁盘IO、SQL 查询、Redis、数据库...

Code Review

广义的单元测试,是这三部分的有机组合:

  • Code Review
  • 静态代码扫描
  • 单元测试用例编写

Code Review 在单元测试中也起到了很重要的作用。自从参与项目以来,自己的代码也被成哥和爽哥 review 过几次,不仅避免了一些小问题导致的 bug,而且从中也学习到了一些内容。

同时,也看到组内同事棒哥之前也进行过 Code Review 的经验分享,包括 MR 的规范以及部分代码示例,其中也提到了 Code Review 来作为单元测试的前提。

另外,Google 也分享了 Code Review 实践 Google's Engineering Practices documentation - How to do a code review,或许可以借鉴学习。

总之,Code Review 不仅能规避一些问题,同时还可以互相学习,形成代码规范,共同努力提高代码质量。

总结:实际项目中如何开展单元测试

  1. 不是所有的代码都要进行单元测试,通常只有底层模块或者核心模块的测试中才会采用单元测试
  2. 确定单元测试框架的选型,这和开发语言直接相关
  3. 引入计算代码覆盖率的工具,衡量单元测试的代码覆盖率
  4. 单元测试执行、代码覆盖率统计和持续集成流水线做集成,确保每次代码递交,都会自动触发单元测试,并在单元测试执行过程中自动统计代码覆盖率,最后 以“单元测试通过率”和“代码覆盖率”为标准 来决定本次代码递交是否能够被接受。

参考:

单元测试
Mock
Moco
MockMVC
Mockito & PowerMock
Wiremock
Code Review