《有效的单元测试》读书笔记

2021-05-30 fishedee 后端

0 概述

测试是开发中重要的一部分,单元测试更是测试中最重要的部分,虽然单元测试简单。但是正因为成本更低,才能更快地把代码测试起来不是吗,让bug在项目最开始的阶段就能发现出来,而不是在项目的尾端才发现出来。所以,单元测试其实是测试理论中,成本效率最高的一种方法。

本书也指出了我一直以来单元测试的一些误区。

1 理论

1.1 目标

  • 单元测试不是要追求100%的覆盖率,也是避免显而易见的bug产生。所以,对于一眼就能看穿的代码,就不需要单元测试。而那些分支多,场景复杂的例子更应该被仔细地测试。

1.2 测试前提

  • 良好的可维护代码,模块化代码。

1.3 原则

  • 测试应该是独立的,各个单元测试之间的用例不应该互相依赖,更不应该依赖测试的顺序。P19
  • 外部依赖应该被替身掉,以加强确定性和速度。P27
  • 一个测试应该只有一个失败的原因。P48。一旦违反以后,只要一个地方新增了字段就会触发多处的用例失败,导致难以维护单元测试代码。
  • 一个测试应该只包含一个场景。P56

2 替身

替身的意义在于:

  • 桩Stub,没有实现行为,纯碎的硬编码实现。P30
  • 伪造对象Fake,用简要实现来代替实际实现,并提供了行为。例如用内存数据库代替实际数据库,用控制台日志代替文件日志。P31
  • 间谍Spy,记录替身的输入参数与次数,而给后续验证时使用.P32
  • 模拟对象Mock,最为复杂的替身,完成控制替身的每一步行为.P33

3 可读性

  • 提高验证的抽象层次.P44
  • 将验证的准备,各个子动作,测试过程分开,以提高可读性。P54
  • 为魔法数提供一个名字。P65

4 可维护性

  • 尽可能减少条件执行结构,而使用类似contains的验证。P77
  • 避免脆弱的测试,时间,文件,外部接口,应该被mock掉。P80
  • 避免使用绝对路径,而要使用相对路径。P81
  • 临时文件,应该在setUp阶段就保证先删除后创建的方式,避免意外的错误。P85
  • 避免使用sleep方法,速度慢而且不确定。P87
  • 避免像素对比的验证方法,应该用业务意义上的验证方法。P93

5 可测设计

  • 模块化设计,这个说烂了,但是也是最难做到的。
  • 避免方法private
  • 避免方法final
  • 避免方法static
  • 避免用new或者单例的创建依赖对象,这样难以替换依赖。

注入替身的方法

  • 使用@Override覆盖方法,返回自定义替身。P136
  • 使用Setter注入自定义替身。P140
  • 使用IOC框架注入自定义替身,最重型的方法。P139

6 junit4

代码在这里

6.1 生命周期

package spring_test;

import org.junit.*;

import static org.junit.Assert.*;

/**
 * Created by fish on 2021/5/30.
 */
public class Test1 {
    @BeforeClass
    public static void a(){
        System.out.println("beforeClass");
    }


    @AfterClass
    public static void b(){
        System.out.println("afterClass");
    }


    @Before
    public void setUp(){
        System.out.println("setUp");
    }

    @Test
    public void test1(){
        System.out.println("test1 ref = "+this.toString());
        assertEquals(1,1);
    }

    @Test
    public void test2(){
        System.out.println("test2 ref = "+this.toString());
        assertEquals(2,2);
    }

    @After
    public void clean(){
        System.out.println("clean");
    }

}

从代码中我们可以看出:

  • 每个测试用例所用的Test1实例都是不同的,这保证了上一个测试的数据不会遗留在下一个测试用例去。
  • @Before@After是每个测试用例都会执行一次的,分别在测试前,和测试后触发
  • @BeforeClass@AfterClass是整体测试前跑一遍

这是设置测试用例的地方

6.2 断言

6.2.1 基本断言

import static org.junit.Assert.*;

@Test
public void test3(){
    assertEquals(2,2);
}

assertEquals是最常用的断言

6.2.2 类字段断言

package spring_test;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.beans.factory.annotation.Autowired;

import javax.annotation.PostConstruct;

/**
 * Created by fish on 2021/5/1.
 */
public class JsonAssertUtil {

    private static ObjectMapper objectMapper;

    private static ObjectMapper get(){
        if( objectMapper == null){
            objectMapper = new ObjectMapper();
        }
        return objectMapper;
    }

    public static  void checkEqualNotStrict(String target,Object result){
        try{
            ObjectMapper objectMapper = get();
            String resultJson =  objectMapper.writeValueAsString(result);
            System.out.println(resultJson);
            JSONAssert.assertEquals(target, resultJson,false);
        }catch(Exception e){
            throw  new RuntimeException(e);
        }

    }

    public static  void checkEqualStrict(String target,Object result){
        try{
            ObjectMapper objectMapper = get();
            String resultJson =  objectMapper.writeValueAsString(result);
            System.out.println(resultJson);
            JSONAssert.assertEquals(target, resultJson,true);
        }catch(Exception e){
            throw  new RuntimeException(e);
        }

    }
}

JSONAssert是一个相当重要的工具,我们用来检查类的部分字段是否是我们所期望的。而且,它的NoStrict模式避免了过度断言,具体看它的文档。

package spring_test;

import org.junit.Test;

/**
 * Created by fish on 2021/5/30.
 */
public class Test2 {

    @Test
    public void test1(){
        OrderDO orderDO = new OrderDO();

        orderDO.setSize(1);
        orderDO.setName("fish");

        JsonAssertUtil.checkEqualNotStrict(
                "{size:1}",
                orderDO
        );

        JsonAssertUtil.checkEqualNotStrict(
                "{name:\"fish\"}",
                orderDO
        );
    }
}

这是测试代码。可以看到编写测试代码的时候,避免对字段名进行双引号操作。

6.2.3 异常断言

package spring_test;

import org.junit.Test;

/**
 * Created by fish on 2021/5/30.
 */
public class Test3 {

    @Test(expected = RuntimeException.class)
    public void test3(){
        throw new RuntimeException("123");
    }
}

异常断言的方式,只检查了断言的异常类型,没有检查异常的消息。FIXME,这里还需要优化一下。

6.3 套件

package spring_test;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;

/**
 * Created by fish on 2021/5/30.
 */
@RunWith(Suite.class)
@Suite.SuiteClasses({
        Test1.class,
    Test2.class,
    Test3.class})
public class TestAll {
}

套件的好处是,可以同时测试多个单元测试。

6.4 SpringBoot集成

package spring_test;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import javax.naming.ldap.Control;

import static org.junit.Assert.*;
/**
 * Created by fish on 2021/5/30.
 */
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class Test4 {

    @Autowired
    private Controller controller;

    @Test
    public void test(){
        assertEquals(controller.getDisplay(),"mm");
    }
}

对于SpringBoot,如果我们需要注入bean,那么就要添加@RunWith和@SpringBootTest注解

7 mockito

mockito是重要的mock工具,当然mock也不是越多越好,最好的单元测试应该是无mock的。代码在这里

7.1 有返回值mock

package spring_test;

import org.junit.*;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

/**
 * Created by fish on 2021/5/30.
 */
//有返回值mock
public class Test1 {
    //mock控制返回值
    @Test
    public void test1(){
        Service a = mock(Service.class);
        //无参数mock
        when(a.display()).thenReturn("123");
        assertEquals(a.display(),"123");

        //含参数的mock
        when(a.display2(anyInt())).thenReturn("456");
        assertEquals(a.display2(1),"456");
    }


    //mock控制行为
    @Test
    public void test2(){
        Service b = mock(Service.class);
        when(b.display()).thenAnswer(new Answer<String>() {
            private boolean isFirst = true;

            @Override
            public String answer(InvocationOnMock var1) throws Throwable{
                if( isFirst){
                    isFirst = false;
                    return "123";
                }else{
                    return "456";
                }
            }
        });


        assertEquals(b.display(),"123");
        assertEquals(b.display(),"456");
    }

    //mock抛出异常
    @Test(expected = RuntimeException.class)
    public void test3(){
        Service c = mock(Service.class);
        when(c.display()).thenThrow(new RuntimeException());

        c.display();
    }

}

当对象方法是有返回值的时候,mock是很简单的,直接when,然后thenXXX就可以了。

7.2 无返回值mock

package spring_test;

import org.junit.Test;

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

/**
 * Created by fish on 2021/5/31.
 */
//无返回值mock
public class Test2 {

    //mock,啥事不做
    @Test
    public void test1(){
        Service a = mock(Service.class);
        doNothing().when(a).nothing();

        a.nothing();
    }

    //mock,抛出异常
    @Test(expected = RuntimeException.class)
    public void test2(){
        Service a = mock(Service.class);
        doThrow(new RuntimeException()).when(a).nothing2(anyInt());

        a.nothing2(1);
    }
}

当对象方法是没返回值的时候,mock要麻烦一点,先写doXXX,然后再when加方法。

7.3 mock检查

package spring_test;

import org.junit.Test;
import org.mockito.ArgumentCaptor;

import java.util.Arrays;

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

/**
 * Created by fish on 2021/5/31.
 */
//捕获参数检查
public class Test3 {

    //逐次检查参数
    @Test
    public void test1(){
        Service a = mock(Service.class);


        a.display2(1);
        verify(a).display2(1);


        a.display2(2);
        verify(a).display2(1);
    }

    //逐次检查调用次数
    @Test
    public void test2(){
        Service b = mock(Service.class);

        b.nothing();
        b.nothing();

        verify(b,times(2)).nothing();
    }

    //使用参数捕获器,检查参数
    @Test
    public void test3(){
        Service a = mock(Service.class);

        a.display2(12);
        a.display2(23);

        ArgumentCaptor<Integer> argument = ArgumentCaptor.forClass(Integer.class);
        verify(a,times(2)).display2(argument.capture());

        assertEquals(
                Arrays.asList(12,23),
                argument.getAllValues()
        );
    }
}

mock检查主要分两种,检查mock对象方法被调用的次数,以及检查mock对象方法的参数是否正确。

7.4 注入mock

package spring_test;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

/**
 * Created by fish on 2021/5/31.
 */
public class Test4 {
    @Mock
    private Service service;

    @InjectMocks
    private Controller controller;

    @Before
    public void setUp(){
        MockitoAnnotations.initMocks(this);
    }
    
    @Test
    public void test1(){
        when(service.display()).thenReturn("789");

        assertEquals(controller.getDisplay(),"789");
    }
}

Mock框架提供了相当轻松的方式注入mock,@InjectMocks是要测试的对象,它的依赖用mock代替了。@Mock就是我们手动mock的对象。记住,在使用前要用MockitoAnnotations进行mock的注入。

8 junit5

代码在这里

8.1 生命周期

package spring_test;

import org.junit.jupiter.api.*;

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

/**
 * Created by fish on 2021/5/30.
 */
public class Test1 {
    @BeforeAll
    public static void a(){
        System.out.println("beforeClass");
    }


    @AfterAll
    public static void b(){
        System.out.println("afterClass");
    }


    @BeforeEach
    public void setUp(){
        System.out.println("setUp");
    }

    @Test
    public void test1(){
        System.out.println("test1 ref = "+this.toString());
        assertEquals(1,1);
    }

    @Test
    public void test2(){
        System.out.println("test2 ref = "+this.toString());
        assertEquals(2,2);
    }

    @AfterEach
    public void clean(){
        System.out.println("clean");
    }

}

生命周期的命名比原来的更加直观

8.2 断言

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

 @Test
public void test2(){
    assertEquals(2,2);
}

基础断言没变,就是改了一下包引用而已

package spring_test;

import org.junit.jupiter.api.Test;

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

/**
 * Created by fish on 2021/5/30.
 */
public class Test3 {

    @Test
    public void test3(){
        RuntimeException exception = assertThrows(RuntimeException.class,()->{
            throw new RuntimeException("123");
        });

        assertEquals(exception.getMessage(),"123");
    }
}

异常断言不再允许在@Test的标记上面写了,用assertThrows断言,比原来的更加强大了。

8.3 套件

在JUnit5里面就不再支持测试套件了,不能使用@TestSuit注解

取而代之是更现代的方法,在IDE里面选择测试整个包。

8.4 SpringBoot集成

package spring_test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;

import javax.naming.ldap.Control;

import static org.junit.jupiter.api.Assertions.*;
/**
 * Created by fish on 2021/5/30.
 */
@SpringBootTest
public class Test4 {

    @Autowired
    private Controller controller;

    @Test
    public void test(){
        assertEquals(controller.getDisplay(),"mm");
    }
}

直接用@SpringBootTest就能引入SpringBoot集成了

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.boot.test.context;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.core.annotation.AliasFor;
import org.springframework.test.context.BootstrapWith;
import org.springframework.test.context.junit.jupiter.SpringExtension;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ExtendWith({SpringExtension.class})
public @interface SpringBootTest {
    @AliasFor("properties")
    String[] value() default {};

    @AliasFor("value")
    String[] properties() default {};

    String[] args() default {};

    Class<?>[] classes() default {};

    SpringBootTest.WebEnvironment webEnvironment() default SpringBootTest.WebEnvironment.MOCK;

    public static enum WebEnvironment {
        MOCK(false),
        RANDOM_PORT(true),
        DEFINED_PORT(true),
        NONE(false);

        private final boolean embedded;

        private WebEnvironment(boolean embedded) {
            this.embedded = embedded;
        }

        public boolean isEmbedded() {
            return this.embedded;
        }
    }
}

@SpringBootTest注解有效的关键在于@ExtendWith注解和@BootstrapWith注解

9 DBUnit与H2

在单元测试里面,如果模块涉及到数据库交互是测试中最麻烦的,因为数据库是一个副作用的系统,这次写入数据库的测试用例会影响到下一个测试用例的输入。另外数据库测试的麻烦点在于,它的输入和输出不能通过简单的内存参数传递来实现。

因此,绝大部分的情况下,我们提倡让代码模块化,让代码技术与业务分离的方式,使得代码更容易被测试,可以脱离数据库进行单独的单元测试。(这点并不容易,那确实可以做到。)

而在很少一部分的情况下,我们会使用Mock来替代数据库真实实现来测试。那么,最终在一些极少的场景下,一个模块依赖的项很多,用Mock麻烦,所以这时候我们才会考虑使用连接真实的数据库来进行单元测试。

数据库测试,我们需要两个工具:

  • H2,类似sqlite的嵌入式数据库,但是它相比sqlite的好处在于,它对mysql协议的支持很好,大部分的sql语句不需要更改就能直接在H2数据库运行。在单元测试中使用H2这类的嵌入式数据库的意义在于,避免网络延迟加快测试速度和不稳定导致的测试失败,而且H2支持数据放在内存中,可以暴力地支持每个测试用例指向一个单独的内存数据库,从而支持并发的单元测试。
  • DBUnit,DBUnit的意义在于,简化数据库集的测试用例的导入和测试结果的校验。

代码在这里

9.1 依赖

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

H2的依赖

<dependency>
    <groupId>org.dbunit</groupId>
    <artifactId>dbunit</artifactId>
    <version>2.7.0</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>com.github.springtestdbunit</groupId>
    <artifactId>spring-test-dbunit</artifactId>
    <version>1.3.0</version>
</dependency>

DBUnit的依赖,我们另外导入springtestdbunit的包,它能提供注解方便配置DBUnit

9.2 H2

spring.datasource.url  = jdbc:h2:mem:testdb
spring.datasource.driver-class-name = org.h2.Driver
spring.jpa.hibernate.ddl-auto=create

在application-test.properties中加入以上的配置,指定h2作为datasource。同时,由于使用Hibernate作为ORM,它能提供自动生成DDL语句的功能。

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Before;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;

import javax.transaction.Transactional;
import java.util.List;

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

/**
 * Created by fish on 2021/5/30.
 */
//
@SpringBootTest
@ActiveProfiles("test")
@Slf4j
//每个方法之后都会重置ApplicationContext,速度会变慢,但是环境每次都是全新的,每个用例都会自动执行一次DDL语句,删除数据库然后重建表
@DirtiesContext(classMode= DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class Test1 {
    @Autowired
    private UserRepository userRepository;

    private Test1 current;

    @Autowired
    private Service service;

    @BeforeEach
    public void init(){
        //Test1不纳入Spring的bean,所以不能使用AopContext.currentProxy();
        //current = (Test1) AopContext.currentProxy();
    }

    @Transactional
    public void add(User user){
        this.userRepository.add(user);
    }

    @Test
    public void go(){
        List<User> users = userRepository.getAll();
        assertEquals(users.size(),0);

        service.add(new User("fish"));
        assertEquals(userRepository.getAll().size(),1);
    }

    @Test
    public void go2(){
        List<User> users = userRepository.getAll();
        assertEquals(users.size(),0);

        service.add(new User("cat"));
        service.add(new User("dog"));
        assertEquals(userRepository.getAll().size(),2);
    }
}

这是示范的测试用例,注意:

  • @ActiveProfiles注解来配置我们的profile
  • @DirtiesContext来配置每次测试用例都要重建ApplicationContext

9.3 DBUnit

package spring_test;

import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseOperation;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import com.github.springtestdbunit.assertion.DatabaseAssertionMode;
import lombok.Data;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;

import java.util.List;

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

@SpringBootTest
@ActiveProfiles("test")
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        MockitoTestExecutionListener.class,
        DbUnitTestExecutionListener.class
})
public class Test2 {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private Service service;

    /*
    NONE:不执行任何操作,是getTearDownOperation的默认返回值。
    UPDATE:将数据集中的内容更新到数据库中。它假设数据库中已经有对应的记录,否则将失败。
    INSERT:将数据集中的内容插入到数据库中。它假设数据库中没有对应的记录,否则将失败。
    REFRESH:将数据集中的内容刷新到数据库中。如果数据库有对应的记录,则更新,没有则插入。
    DELETE:删除数据库中与数据集对应的记录。
    DELETE_ALL:删除表中所有的记录,如果没有对应的表,则不受影响。
    TRUNCATE_TABLE:与DELETE_ALL类似,更轻量级,不能rollback。
    CLEAN_INSERT:是一个组合操作,是DELETE_ALL和INSERT的组合。是getSetUpOeration的默认返回值。
     */
    @DatabaseSetup( value = {"classpath:/test2.xml"},type = DatabaseOperation.CLEAN_INSERT)
    @Test
    public void go() {
        List<User> users = userRepository.getAll();
        assertEquals(users.size(), 2);

        service.add(new User("kk"));
        users = userRepository.getAll();
        assertEquals(users.size(), 3);
    }

    //使用CLEAN_INSERT能保证每次数据库都是在干净的情况下再运行的
    @DatabaseSetup( value = {"classpath:/test3.xml"},type = DatabaseOperation.CLEAN_INSERT)
    @Test
    public void go2() {
        List<User> users = userRepository.getAll();
        assertEquals(users.size(), 1);
    }

    /*
    DEFAULT。数量一致,顺序一致,字段全部一致
    NON_STRICT。数量一致,顺序一致,字段只验证数据集的部分
    NON_STRICT_UNORDERED。数量一致,顺序可以不一致,字段只验证数据集的部分
     */
    @DatabaseSetup( value = {"classpath:/test4.xml"},type = DatabaseOperation.CLEAN_INSERT)
    @ExpectedDatabase(value="classpath:/test4_validate.xml",assertionMode= DatabaseAssertionMode.DEFAULT)
    @Test
    public void go3(){
        this.service.mod(1L,"KK");
    }
}

这是DBUnit的测试代码,注意:

  • @ActiveProfiles,依然需要,确保指向到自己的H2数据库
  • @TestExecutionListeners,确保需要,DBUnit需要知道测试用例的生命周期,以在测试前清理和注入数据,在测试后进行数据对比校验工作
  • @DatabaseSetup,测试用例的数据集注入工作,注意type的选择,一般我们选择CLEAN_INSERT,以保证最干净的测试环境。这个注解也可以放在类级别,使得在类级别的测试用例只执行一次。
  • @ExpectedDatabase,对数据进行校验的工作。注意有多个选项。

DBUnit的问题在于,@DatabaseSetup注入数据都是需要填写多个字段。一旦表结构新增一个无默认值的新字段,那么所有测试用例的xml文件,都需要加入这个字段,这样会破坏测试用例的可维护性。其实,对于所有无Mock的真实数据库测试都会面对这个问题。

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user
            id="1"
            name="fish"
    />
    <user
            id="2"
            name="cat"
    />
</dataset>

test2.xml文件,代表注入user表的2条数据。

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user
            id="1"
            name="KK"
    />
</dataset>

test4_validate.xml文件,代表要校验的数据

10 SpringBoot

代码在这里

SpringBoot的单元测试为替换自定义的Bean提供了很多功能方法和注解,能帮助我们在SpringBoot框架下的测试。它提供的功能很强大,但相对运行性能就比较差了,要谨慎使用。

10.1 MockBean

package spring_test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class ServiceA {

    @Autowired
    private ServiceB serviceB;

    public String get(){
        return "A"+serviceB.get();
    }
}

定义ServiceA的bean,它依赖着ServiceB

package spring_test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 * Created by fish on 2021/5/30.
 */
@Component
public class ServiceB {
    @Autowired
    private ServiceC serviceC;

    public String get(){
        return "B"+serviceC.get();
    }
}

定义ServiceB的bean,它依赖着ServiceC

package spring_test;

import org.springframework.stereotype.Component;

@Component
public class ServiceC {

    public String get(){
        return "HelloC";
    }

    public String get2(){
        return "MU";
    }
}

这是ServiceC的bean

package spring_test;

import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import spring_test.ServiceA;
import spring_test.ServiceB;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

/**
 * Created by fish on 2021/5/30.
 */
@SpringBootTest
public class Test1 {
    @MockBean
    private ServiceB serviceB;

    @Autowired
    private ServiceA serviceA;

    @Test
    public void test1(){
        when(serviceB.get()).thenReturn("MM");
        assertEquals(serviceA.get(),"AMM");
    }

}

这是单元测试1,我们将ServiceB的bean替换成一个mock实现,只需要加一个@MockBean注解就可以了。这一点Mockito框架下的@Mock和@InjectMocks注解也能做到

package spring_test;

import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.stereotype.Component;
import org.springframework.test.annotation.DirtiesContext;
import spring_test.ServiceA;
import spring_test.ServiceC;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@SpringBootTest
public class Test2 {
    @MockBean
    private ServiceC serviceC;

    @Autowired
    private ServiceA serviceA;

    @Test
    public void test1(){
        when(serviceC.get()).thenReturn("KK");
        assertEquals(serviceA.get(),"ABKK");
    }
}

这是单元测试2,我们将ServiceC的bean替换成一个mock实现,同时ServiceA依赖下的ServiceB依然能工作,只是ServiceB下的依赖ServiceC的bean被替换了。这相当于嵌套bean依赖的替换,这一点Mockito框架下的@Mock和@InjectMocks注解就做不了

另外,如果我们要测试的Bean,我们想只替换它依赖的一部分bean,另外一部分沿用正常的bean,也只能@SpringBootTest注解能做到。Mockito只能一次替换它所有依赖的bean。

10.2 SpyBean

package spring_test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.context.annotation.Import;
import spring_test.util.MyConfig;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@SpringBootTest
public class Test4 {

    @SpyBean
    private ServiceC serviceC;

    @Test
    public void test1(){
        when(serviceC.get2()).thenReturn("UK");

        assertEquals(serviceC.get(),"HelloC");
        assertEquals(serviceC.get2(),"UK");
    }
}

这是单元测试3,我们希望对ServiceC进行替换操作,但是只替换它的get2方法,它的get方法则继续保留原来的实现,那这个功能就需要使用@SpyBean注解,也只有@SpringBootTest能实现。

10.3 TestConfiguration

package spring_test;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import spring_test.ServiceA;
import spring_test.util.MyConfig;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@SpringBootTest
@Import(MyConfig.class)
public class Test3 {

    @Autowired
    private MyConfig.MyServiceC myServiceC;

    @Autowired
    private ServiceA serviceA;

    @Test
    public void test1(){
        myServiceC.set("--MM--");
        assertEquals(serviceA.get(),"AB--MM--");
    }
}

我们希望ServiceA依赖的ServiceC实现替换为一个具体的额外实现,而不是mock实现,那么我们就要用到@Import注解。

package spring_test.util;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import spring_test.ServiceC;

@TestConfiguration
public class MyConfig {

    public static class MyServiceC extends ServiceC{
        private String name;
        public void set(String name){
            this.name = name;
        }
        public String get(){
            return this.name;
        }
    }

    @Bean
    @Primary
    public ServiceC getServiceC(){
        return new MyServiceC();
    }
}

我们定义一个新的配置文件,MyConfig文件,同时它是使用@TestConfiguration注解,避免被默认导入。最后我们定义一个新的@Bean方法来定义bean就可以了,记得要加上@Primary注解,来覆盖原有的bean实现。我们还可以在测试里面用@Autowired来使用我们自定义的ServiceC的bean实现。

10.4 BeanFactory变化

@DataJpaTest(includeFilters = @ComponentScan.Filter(
        type= FilterType.ASSIGNABLE_TYPE,
        classes = {CurdRepository.class, QueryRepository.class}
))
@ExtendWith({SnapshotExtension.class})
@Import({MyConfig.class})
@Slf4j
public class CreateOrderPayTest {

    private Expect expect;


    @Autowired
    private BeanFactory beanFactory;

    
    @BeforeEach
    public void setUp() {
        IocHelper.setBeanFactory(new DefaultTestIocHelper(beanFactory));
    }
}

因为有SpyBean,MockBean和TestConfiguration的存在,每个单元测试中同一个类对应的实例化bean可能是不同的。所以,在Spring的单元测试中,不要企图将BeanFactory缓存起来,留待下一个使用。正确的做法是,在需要BeanFactory的地方,都必须保证是由Spring的Autowired注入的,如果要拿一个全局的BeanFactory,那么在每次@BeforeEach里面都要重新取新的BeanFactory。

11 JpaTest

代码在这里

@SpringBoot注解太重了,单元测试太慢,而大部分的单元测试都是围绕着数据库进行的,能不能只加载jpa依赖来进行单元测试,这样测试就快多了。有,这就是@DataJpaTest注解

11.1 Sql

package spring_test;

import lombok.Getter;
import lombok.ToString;

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

@Entity
@ToString
@Getter
public class User{

    private static Long globalId = 10001L;

    @Id
    private Long id;

    private String name;

    protected User(){

    }

    public User(String name){
        this.id = globalId++;
        this.name = name;
    }

    public void mod(String name){
        this.name = name;
    }
}

定义一个User实体

package spring_test;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Component;

public interface UserRepository extends CrudRepository<User,Long>{
}

定义一个User的仓库

package spring_test;

import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.stereotype.Component;
import org.springframework.test.context.jdbc.Sql;

import java.util.Arrays;
import java.util.stream.Collectors;

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

//可以打开H2数据库进行测试
@DataJpaTest
@Slf4j
public class Test1 {

    @Autowired
    private UserRepository userRepository;

    private List<User> getAll(){
        Iterable<User> users = userRepository.findAll();
        List result = new ArrayList();
        for( User user:users){
            result.add(user);
        }
        return result;
    }

    //可以用@Sql来指定加载哪个数据库脚本
    @Test
    @Sql("classpath:/init.sql")
    public void go(){
        //直接读取,数据量为2个
        List<User> users = this.getAll();
        assertEquals(users.size(),2);
    }

    //每个测试都是一个事务中进行,测试完毕后会自动回滚数据。
    @Test
    @Sql("classpath:/init.sql")
    public void go2(){
        //删除了其中一条数据,数据量为1条
        List<User> users = this.getAll();

        userRepository.delete(users.get(0));

        List<User> users2 = this.getAll();
        assertEquals(users2.size(),1);
    }

    @Test
    @Sql("classpath:/init.sql")
    public void go3(){
        //重新读取后,数据量为2条。
        List<User> users = this.getAll();
        assertEquals(users.size(),2);

        List<String> names = users.stream().map((user)->{
            return user.getName();
        }).sorted().collect(Collectors.toList());
        assertIterableEquals(names, Arrays.asList("cat","fish"));
    }
}

然后我们就能使用@DataJpaTest和@Sql进行测试了,测试的速度要比@SpringBootTest快得多,init.sql是为了初始化测试代码

insert into user(id,name) values(1,'fish'),(2,'cat');

普通的初始化语句

11.2 默认不加载其他依赖

package spring_test;

import org.springframework.stereotype.Component;

@Component
public class UserService {
    public String getDefaultName(){
        return "mm";
    }
}

我们定义一个UserService的bean,它是有@Component注解的

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.junit.jupiter.api.Assertions.*;

//DataJpaTest的特点是:
//只加载入口类App.java,以及加载实现了CrudRepository接口的bean
//不会加载SpringMVC,入口类所在包的其他@Component类,不会加载Tomcat,所以启动速度明显更快
@DataJpaTest
@Slf4j
public class Test2{

    //这个UserService虽然有@Component注解,但是并不进行加载
    @Autowired(required = false)
    private UserService service;

    @Test
    public void go(){
        //因此这个service是空
        assertNull(service);
    }
}

但是,单元测试发现这个bean总是为null。因为DataJpaTest的特点是:

  • 只加载入口类App.java,以及加载实现了CrudRepository接口的bean
  • 不会加载SpringMVC,入口类所在包的其他@Component类,不会加载Tomcat,所以启动速度明显更快

11.3 额外指定其他依赖

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;

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

@DataJpaTest(includeFilters = @ComponentScan.Filter(
        type= FilterType.ASSIGNABLE_TYPE,
        classes = {UserService.class}
))
@Slf4j
public class Test3{

    @Autowired
    private UserService service;

    @Test
    public void go(){
        //因此这个service是空
        assertEquals(service.getDefaultName(),"mm");
    }
}

要在@DataJpaTest中指定加入其他依赖,要么是使用includeFilters

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.Import;

import static org.junit.jupiter.api.Assertions.*;
import spring_test.util.MainConfig;

@DataJpaTest
//可以使用导入@TestConfiguration的方式,加入其他的bean
@Import(MainConfig.class)
@Slf4j
public class Test4{

    @Autowired
    private UserService service;

    @Test
    public void go(){
        //因此这个service是空
        assertEquals(service.getDefaultName(),"mm");
    }
}

要么使用@Import来加载配置文件,在配置文件中加入其他的bean

package spring_test.util;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import spring_test.User;

@TestConfiguration
//通过ComponentScan的方式加入bean
@ComponentScan(basePackageClasses= User.class)
public class MainConfig {
}

这个配置文件使用@ComponentScan来加载其他的bean

12 IDEA配置

12.1 全局Profile

对于单元测试项目,我们都希望触发使用SpringBoot里面test的Profile。一种方法是在类上面使用@ActiveProfiles注解,但是要在所有类上面都添加,比较麻烦。

另外一种方法,在IDEA的Run Configurations里面配置Environment Variables。配置环境变量为SPRING_PROFILES_ACTIVE=test就可以了。

12.2 测试资源文件夹

在项目文件夹选择Open Module Settings。

在文件夹中选择test文件夹下的某个文件夹,标记为Test Resources。那么test文件夹下的xml文件就会标记为资源文件,自动放在classpath文件夹里面了

就像这样的用法

12.2 覆盖率

在IDEA的Run Configurations里面配置要统计的代码覆盖率的类和包

启动的时候,选择with Coverage就可以了

这是测试覆盖率报告

13 快照测试

13.1 依赖

代码在这里

<dependency>
    <groupId>io.github.origin-energy</groupId>
    <artifactId>java-snapshot-testing-junit5</artifactId>
    <version>3.2.5</version>
</dependency>
<dependency>
    <groupId>io.github.origin-energy</groupId>
    <artifactId>java-snapshot-testing-plugin-jackson</artifactId>
    <version>3.2.5</version>
</dependency>

加入依赖

13.2 配置与测试代码

serializer=au.com.origin.snapshots.serializers.ToStringSnapshotSerializer
serializer.base64=au.com.origin.snapshots.serializers.Base64SnapshotSerializer
serializer.json=au.com.origin.snapshots.serializers.JacksonSnapshotSerializer
serializer.orderedJson=au.com.origin.snapshots.serializers.DeterministicJacksonSnapshotSerializer
comparator=au.com.origin.snapshots.comparators.PlainTextEqualsComparator
reporters=au.com.origin.snapshots.reporters.PlainTextSnapshotReporter
snapshot-dir=__snapshots__
output-dir=src/test/java
ci-env-var=CI

在resources文件夹加入snapshot.properties文件夹即可

package spring_test;

import au.com.origin.snapshots.junit5.SnapshotExtension;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;

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

import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import au.com.origin.snapshots.Expect;

/**
 * Created by fish on 2021/5/30.
 */
@SpringBootTest
@Import({JacksonAutoConfiguration.class})
@ExtendWith({SnapshotExtension.class})
public class Test1 {
    private Expect expect;


    @Autowired
    private ObjectMapper objectMapper;

    public String toString(Object data)throws Exception{
        return objectMapper.writeValueAsString(data);
    }

    //字符串测试,单用例下的多测试
    @Test
    public void test1() throws Exception{

        OrderDO orderDO = new OrderDO();

        orderDO.setSize(1);
        orderDO.setName("fish");
        expect.scenario("1").toMatchSnapshot(toString(orderDO));


        OrderDO orderDO2 = new OrderDO();

        orderDO2.setSize(2);
        orderDO2.setName("dog");

        expect.scenario("2").toMatchSnapshot(toString(orderDO2));

    }

    //json测试,就是指定一个序列化器而已,没有改变diff的方式
    @Test
    public void test2() throws Exception{

        OrderDO orderDO = new OrderDO();

        orderDO.setSize(1);
        orderDO.setName("fish");
        expect.scenario("1").serializer("json").toMatchSnapshot(orderDO);


        OrderDO orderDO2 = new OrderDO();

        orderDO2.setSize(2);
        orderDO2.setName("dog");

        expect.scenario("2").serializer("json").toMatchSnapshot(orderDO2);

    }

}

单元测试,相当的简单暴力

单元测试运行以后,会生成.snap文件,如果下次测试不通过的话,会产生.debug文件进行对照

13.3 回归测试与结果对照

在下次新增代码以后,重新运行单元测试,必然会产生测试失败的报告,因为测试数据新增或者修改了。

这个时候如果查看.debug文件或者console控制台查看错误的地方,有点麻烦

解决办法是,先将原来的测试.snap文件删除,然后重新运行单元测试,就会生成新的.snap文件,最后我们将其加入到git中

打开VSCode,点击第三个图标,查看当前文件与上次提交时候的改动。我们就能轻松看到新旧输出的差异了。

14 总结

收获最大的地方是:

  • 好的代码应该是同一抽象层上的描述,而不是一个函数将高抽象,到低抽象全部捅到底。P89,这个收获很棒,提醒了写代码要放在同一层次的抽象的编排。
  • 测试不是追求按位断言,而是用断言你想检查的部分字段,以测试是否满足你的原来的期望。P50。这个也不错,过度断言会导致,对某个结构体添加一个字段时,一大堆用例就会失败,那感觉太糟糕了。
  • 一个测试应该只有一个失败的原因,也应该只包含一个场景。P48,过度断言就违反了这个原则

相关文章