Sprintboot程序的单元测试和集成测试建议

Springboot测试注解说明

@SpringBootTest

当需要启动完整spring容器的时候考虑使用。

  • 如果没有指定使用注解@ContextConfiguration(loader=...)指定加载,SpringBootContextLoader就是默认的加载器。当没有嵌套使用@Configuration时候,会自动搜索 @SpringBootConfiguration注解。 当我们用@SpringBootConfiguration标记一个类时,意味着该类提供了@Bean定义方法。Spring 容器为我们的应用程序实例化和配置 bean。 @SpringBootConfiguration与@Configuration区别在于@SpringBootConfiguration允许自动发现配置。

  • @SpringBootTest会首先在当前包中搜索@SpringBootConfiguration,如果没有搜索到则根据包结构向上搜索。

  • 测试类需要与@SpringbootApplication注解标记的类在同一个包内,或者在更下层的包内。(与上句话一致,表达方式不同)

  • 简单的说这个逻辑链条就是:@SpringbootTest去找@SpringbootApplication,@SpringbootApplication注解又包含了SpringBootConfiguration注解。

  • 如果你需要一个跟src/main下代码不一样的应用配置,考虑使用自定义springbootapplication启动类,放在src/test。

@SpringBootTest(classes = CustomApplication.class)
class CustomApplicationTest {

}
1
2
3
4
  • 允许properties自定义环境变量
  • 允许从启动参数中定义配置属性
  • 提供不同的webEnvironment模式,可以在指定或者随机端口启动运行web server。注册一个TestRestTemplate或者webTestClient用于完整测试webserver。

由于@SpringBootTest会完整的启动容器,建议在集成测试时考虑使用。

@TestPropertySource

指定测试用例要使用的配置,如果不同的测试用例有不同的测试配置,那么可以使用这个注解把配置加载到ApplicationContext中。 例如:

@TestPropertySource("classpath:application.properties")
1

如果没有指定location,那么默认搜索类名关联的配置进行加载。 如果使用注解测测试类是 is com.example.MyTest, 相应的配置文件就是 "classpath:com/example/MyTest.properties". 如果没有找到默认配置,则抛出IllegalStateException异常。

@ContextConfiguration

加载配置类。如果使用了SpringBootTest,这个注解是不需要的。当你需要Spring容器,又不希望加载全部的类时候,可以考虑用@ContextConfiguration指定加载。某种程度上来说@SpringBootTest(classes="")与@ContextConfiguration(classes="")是等价的。

@Import

@Import与@ContextConfiguration是完全不同的使用场景。不建议互换(有时也不能互换)。 @Import用于一个配置类中,导入其他配置类。例如:

@Configuration
@Import(PersistenceConfig.class)
public class MainConfig {}

1
2
3
4

比如你禁用了一个包的component scan,那么但是你需要那个包中的一个配置类的时候,你可以考虑用@Import。

@ContextConfiguration只能用于spring测试。

@RunWith(SpringRunner.class)

SpringRunner是SpringJUnit4ClassRunner的别名,所以@RunWith(SpringRunner.class) @RunWith(SpringJUnit4ClassRunner)是等价的。

Junit注解(Junit4)

Junit注解参考Junit说明即可。

Test

Before

After

BeforeClass

AfterClass

Junit5注解

Mockito

@Mock和@MockBean

  • @Mock用于不启动容器的单元测试
  • @MockBean用于启动部分或者全部容器功能的测试

测试建议

单元测试

重点说三遍:

  • 单元测试不要启动容器。
  • 单元测试不要启动容器。
  • 单元测试不要启动容器。
  • dao和service根据情况进行单元测试。如果service仅是返回dao的结果,那么可以仅对dao做单元测试。
  • 如果service有业务逻辑,那么建议做单元测试。
  • 对于DAO和Service的测试尽可能不要启动容器,freshal-test已经提供了不启动容器的测试mybatis dao测试支持
  • 对于service的测试,应该使用mock对象,mock dao层。 例如:
@RunWith(MockitoJUnitRunner.class)
public class TodoListServiceTest {
    @InjectMocks
    private ToDoService toDoService;
    @Mock
    private TodoListDao mockDao;

    @Test
    public void findAllTest() throws Exception {
        List<ToDoList> toDoList = new ArrayList<ToDoList>();
        toDoList.add(new ToDoList(1L,"jogging at 6:00",true));
        toDoList.add(new ToDoList(2L,"meeting at 10:00",true));
        when(mockDao.findAll()).thenReturn(toDoList);
        List<ToDoList> toDoList2 = toDoService.findAll();
        verify(mockDao).findAll();
        assertThat(toDoList2).hasSize(2);
    }

    @Test
    public void countTest(){
        when(mockDao.count()).thenReturn(2);
        long count = toDoService.count();
        verify(mockDao).count();
        assertThat(count).isEqualTo(2L);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

使用mokcito时,测试用例中verify和assert应该都有。verify确保mock的方法被调用。

  • 对于controller层,可以不写单元测试用例,因为集成测试用例也要覆盖到controller层。
  • 如果controller层进行单元测试,请使用MockMvc,例如:
@RunWith(MockitoJUnitRunner.class)
public class TodoListControllerStandaloneTest {
    @Autowired
    MockMvc mockMvc;

    @Mock
    private ToDoService toDoService;

    @InjectMocks
    ToDoListController toDoListController;

    @Before
    public void setup() {
        JacksonTester.initFields(this, new ObjectMapper());
        // MockMvc standalone approach
        mockMvc = MockMvcBuilders.standaloneSetup(toDoListController)
                .build();
    }
    @Test
    public void getAllToDos() throws Exception {
        List<ToDoList> toDoList = new ArrayList<ToDoList>();
        toDoList.add(new ToDoList(1L,"jogging at 6:00",true));
        toDoList.add(new ToDoList(2L,"meeting at 10:00",true));
        when(toDoService.findAll()).thenReturn(toDoList);

        mockMvc.perform(get("/todos")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$",hasSize(2)))
                .andDo(print());
        verify(toDoService).findAll();

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

关于controller测试更多的内容可以参考:https://github.com/mechero/spring-boot-testing-strategies

mybatis单元测试

在写测试用例中,mybatis的测试每次都要启动spring容器,这导致非常耗时。

@MybatisTest注解需要SqlSession和SqlFactory,在使用spring自动配置机制时候,这个由mybatis-spring提供。

在这种情形下,你必须使用spring容器加载mybatis,才可能获得session。为了不启动容器,加速测试用例的运行,建议提供freshal-cloud测试支持

public class DaoWithoutSpringTest {
    protected static SqlSessionFactory sqlSessionFactory;
    protected static SqlSession session;
    public static List<Class<?>> mapperfile = new ArrayList<Class<?>>();

    /**
     * 返回默认的配置文件,如果文件名称不一样,则在测试用例中复写本方法。
     * 仍然使用spring的配置文件,但是不启动spring容器。
     * @return
     */
    protected static String getPropertyFile(){
        return "application.properties";
    }


    public static void setUpDatabse() throws IOException {
        Properties properties = PropertiesLoaderUtils.loadProperties(new ClassPathResource(getPropertyFile()));
        String user = properties.getProperty("spring.datasource.username");
        String password = properties.getProperty("spring.datasource.password");
        String url = properties.getProperty("spring.datasource.url");
        String driver = properties.getProperty("spring.datasource.driverClassName");
        DataSource dataSource = new org.apache.ibatis.datasource.pooled.PooledDataSource(
                driver, url, user, password);
        TransactionFactory transactionFactory = new JdbcTransactionFactory();
        Environment environment = new Environment("development",
                transactionFactory, dataSource);
        Configuration configuration = new Configuration(environment);
        for(Class clazz: mapperfile){
            configuration.addMapper(clazz);
        }
        sqlSessionFactory = new SqlSessionFactoryBuilder()
                .build(configuration);
        session = sqlSessionFactory.openSession();
    }

    @AfterClass
    public static void close(){
        session.close();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

可以写dao的测试用例如下:

public class TodoDaoWithoutSpringTest extends DaoWithoutSpringTest{


    @Test
    public void findAllTest() {
        //使用session获得dao
        TodoListDao todoListDao = session.getMapper(TodoListDao.class);
        List<ToDoList> list = todoListDao.findAll();
        assertThat(list).hasSize(2);
    }

    @BeforeClass
    public static void setUp() throws IOException {
        //添加mapper配置类
        mapperfile.add(TodoListDao.class);
        //初始化session
        setUpDatabse();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

无需mybatis注解,就可以测试dao。

控制台日志显示非常简短

16:16:09.960 [main] DEBUG org.apache.ibatis.logging.LogFactory - Logging initialized using 'class org.apache.ibatis.logging.slf4j.Slf4jImpl' adapter.
16:16:10.172 [main] DEBUG org.apache.ibatis.transaction.jdbc.JdbcTransaction - Opening JDBC Connection
16:16:10.451 [main] DEBUG org.apache.ibatis.datasource.pooled.PooledDataSource - Created connection 132577100.
16:16:10.452 [main] DEBUG org.apache.ibatis.transaction.jdbc.JdbcTransaction - Setting autocommit to false on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@7e6f74c]
16:16:10.457 [main] DEBUG com.freshal.sample.tdd.TodoListDao.findAll - ==>  Preparing: select * from todos
16:16:10.511 [main] DEBUG com.freshal.sample.tdd.TodoListDao.findAll - ==> Parameters: 
16:16:10.547 [main] DEBUG com.freshal.sample.tdd.TodoListDao.findAll - <==      Total: 2
16:16:10.629 [main] DEBUG org.apache.ibatis.transaction.jdbc.JdbcTransaction - Resetting autocommit to true on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@7e6f74c]
16:16:10.630 [main] DEBUG org.apache.ibatis.transaction.jdbc.JdbcTransaction - Closing JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@7e6f74c]
16:16:10.630 [main] DEBUG org.apache.ibatis.datasource.pooled.PooledDataSource - Returned connection 132577100 to pool.
1
2
3
4
5
6
7
8
9
10

上面是用注解方式配置mybatis,xml需要对应修改。

集成测试

  • 把@SpringBootTest注解用于集成测试。
  • 如果要进行web测试,使用@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)

编写测试用例的原则(F.I.R.S.T. principles:)

https://www.appsdeveloperblog.com/the-first-principle-in-unit-testing/

  • F - Fast
  • I - Independent
  • R - Repeatable
  • S - Self-Validating
  • T - Timely

实践建议:

我们总结一下几个关于springboot测试的实践

  • 除了集成测试,避免加载所有的组件。测试什么,加载什么。最好单元测试用例仅有@Test注解。
  • 使用Mockito 模拟对象,隔离要被测试的功能。
  • 仅加载功能相关的部分。例如不可避免要使用@SpringBootTest测试,那么考虑使用@SpringBootTest(classes = ABC.class)这种方式
  • RestApi一定要进行集成测试
  • 如果使用了JPA,那么用@DataJpaTest注解测试DAO.
  • 分层测试,测试用例要小且聚焦功能
  • 避免void方法
  • 使用@Suite.SuiteClasses组织集成测试套件。比如入库组织一个,出库组织一个属于不同的测试套件。