Unit testing scheme of application based on spring-boot

Posted by VinzC on Tue, 14 May 2019 14:46:10 +0200

Summary

This paper mainly introduces how to write unit test and integration test code for web application based on spring-boot.

The architecture of such applications is generally as follows:

web-structure.png

The program of our project corresponds to the web application section in the figure above. This part is generally divided into Controller layer, service layer and persistence layer. In addition, there are some data encapsulation classes in the application, which we call domain. The responsibilities of the above components are as follows:

  • Controller Layer/Rest Interface Layer: Responsible for providing Rest services to the outside world, receiving Rest requests and returning processing results.
  • service layer: The business logic layer, which implements specific logic according to the needs of the Controller layer.
  • Persistence layer: access to the database, read and write data. The database access requirements of the service layer are supported upwards.

In Spring environments, we usually register these three layers into Spring containers, which is illustrated by the light blue background in the figure above.

In the follow-up part of this article, we will introduce how to integrate the application testing, including starting the request testing of the web container, using the simulation environment instead of starting the web container, and how to unit test the application, including testing the Controller layer, service layer and persistence layer separately.

The difference between integration testing and unit testing is that integration testing usually only needs to test the top layer, because the upper layer will automatically call the lower layer, so it will test the complete process chain, each link in the process chain is real and concrete. Unit testing is a separate test of a link in the process chain, which is directly dependent on the downstream links to provide support in the way of simulation. This technology is called Mock. In introducing unit testing, we will introduce how mock depends on objects, and briefly introduce the principle of mock.

Another topic of concern in this paper is how to eliminate the side effects of modifying the database when testing the persistence layer.

integration testing

Integration testing is the assembly testing after all components have been developed. There are two ways to test: start the web container for testing, and use the simulated environment for testing. There is no difference between the results of these two tests, but with the use of simulated environment testing, you can start the web container without any overhead. In addition, the test API s of the two will be different.

Start the web container for testing

We implement integration testing by testing the top-level Controller. Our testing objectives are as follows:

@RestController
public class CityController {

    @Autowired
    private CityService cityService;

    @GetMapping("/cities")
    public ResponseEntity<?> getAllCities() {
        List<City> cities = cityService.getAllCities();
        return ResponseEntity.ok(cities);
    }
}

This is a Controller, which provides a service / cities to the outside world and returns a list of all cities. This Controller fulfills its responsibilities by calling CityService at the next level.

The integrated test scheme for this Controller is as follows:

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class CityControllerWithRunningServer {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void getAllCitiesTest() {
        String response = restTemplate.getForObject("/cities", String.class);
        Assertions.assertThat(response).contains("San Francisco");
    }
}

First, we use @RunWith (Spring Runner. class) to declare unit testing in Spring environment so that Spring's annotations can be identified and validated. Then we use @SpringBootTest, which scans the spring configuration of the application and builds the complete Spring Context. We assign the parameter webEnvironment to SpringBootTest.WebEnvironment.RANDOM_PORT, which starts the web container and listens on a random port, and automatically assembles a TestRestTemplate type bean for us to assist us in sending requests.

Using simulated environment testing

The goal of the test remains unchanged, and the scheme of the test is as follows:

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class CityControllerWithMockEnvironment {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void getAllCities() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/cities"))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("San Francisco")));
    }
}

We still use @SpringBootTest, but we haven't set its webEnvironment property, which will still build the full Spring Context, but we won't start the web container again. In order to test, we need to send requests using MockMvc instances, and we use @AutoConfigureMockMvc because we can get automatically configured MockMvc instances.

There are many new APIs in the specific test code, and the study of API details is beyond the scope of this article.

unit testing

The same thing about the two integration testing scenarios described above is that the entire Spring Context is built. This means that all declared bean s, regardless of how they are declared, are built and can be relied on. The implication here is that the code on the entire dependency chain from top to bottom has been implemented.

Mock Technology

In the process of development, testing can not meet the above conditions. Mock technology allows us to shield the lower dependencies, thus focusing on the current test objectives. The idea of Mock technology is that when the behavior of the underlying dependencies of the test target is predictable, the behavior of the test target itself is predictable. Testing is to compare the actual results with the expected results of the test target, and Mock is the behavior performance of the predefined underlying dependencies.

Mock process

  1. mock the dependent object of the test target and set its expected behavior.
  2. Test the test target.
  3. Testing results are checked to see if the results of the test target meet the expectations under the expected behavior of the dependent object.

Use scenarios for Mock

  1. When many people cooperate, they can test first through mock without waiting.
  2. When the dependent object of the test target needs to access the external service, and the external service is not easily available, the service can be simulated by mock.
  3. When the problem scenario is not easy to reproduce, the problem is simulated by mock.

Testing web level

The goal of the test remains unchanged, and the scheme of the test is as follows:

/**
 * Instead of building the entire Spring Context, build only the specified Controller for testing. Relevant dependencies need to be mocked. <br>
 * Created by lijinlong9 on 2018/8/22.
 */
@RunWith(SpringRunner.class)
@WebMvcTest(CityController.class)
public class CityControllerWebLayer {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private CityService service;

    @Test
    public void getAllCities() throws Exception {

        City city = new City();
        city.setId(1L);
        city.setName("Hangzhou");
        city.setState("Zhejiang");
        city.setCountry("China");

        Mockito.when(service.getAllCities()).thenReturn(Collections.singletonList(city));

        mvc.perform(MockMvcRequestBuilders.get("/cities"))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("Hangzhou")));
    }
}

Instead of using @SpringBootTest, @WebMvcTest, you can only build a web layer or specify one or more Controller beans. @ WebMvcTest can also automatically configure MockMvc-type beans for us, and we can use it to simulate sending requests.

@ MockBean is a new contact annotation that indicates that the corresponding bean is a simulated bean. Because we're testing CityController, the CityService on which it depends, we need mock's expected behavioral performance. In the specific test method, Mockito's API is used to mock the behavior of sercive, which indicates that when the service's getAllCities is called, a list of pre-defined City objects is returned.

Then the request is made and the result is predicted.

Mockito is the mock testing framework of Java language, and spring integrates it in its own way.

Testing persistence layer

The test scheme of persistence layer is related to the specific persistence layer technology. Here we introduce Mybatis-based persistence layer testing.

The test objectives are:

@Mapper
public interface CityMapper {

    City selectCityById(int id);

    List<City> selectAllCities();

    int insert(City city);

}

The test scheme is:

@RunWith(SpringRunner.class)
@MybatisTest
@FixMethodOrder(value = MethodSorters.NAME_ASCENDING)
// @Transactional(propagation = Propagation.NOT_SUPPORTED)
public class CityMapperTest {

    @Autowired
    private CityMapper cityMapper;

    @Test
    public void /*selectCityById*/ test1() throws Exception {
        City city = cityMapper.selectCityById(1);
        Assertions.assertThat(city.getId()).isEqualTo(Long.valueOf(1));
        Assertions.assertThat(city.getName()).isEqualTo("San Francisco");
        Assertions.assertThat(city.getState()).isEqualTo("CA");
        Assertions.assertThat(city.getCountry()).isEqualTo("US");
    }

    @Test
    public void /*insertCity*/ test2() throws Exception {
        City city = new City();
        city.setId(2L);
        city.setName("HangZhou");
        city.setState("ZheJiang");
        city.setCountry("CN");

        int result = cityMapper.insert(city);
        Assertions.assertThat(result).isEqualTo(1);
    }

    @Test
    public void /*selectNewInsertedCity*/ test3() throws Exception {
        City city = cityMapper.selectCityById(2);
        Assertions.assertThat(city).isNull();
    }
}

Here we use @MybatisTest, which is responsible for building mybatis-mapper layer beans, just as @WebMvcTest used above is responsible for building web layer beans. It is worth mentioning that @MybatisTest comes from the mybatis-spring-boot-starter-test project, which is implemented by the mybatis team according to spring habits. Spring natively supports two persistence layer testing schemes @DataJpaTest and @JdbcTest, which correspond to JPA persistence scheme and JDBC persistence scheme respectively.

@ FixMethodOrder comes from junit to allow multiple test scenarios in a test class to execute in the set order. Normally, this is not necessary. I want to confirm whether the data inserted in the test2 method still exists in test3, so I need to ensure the execution order of both methods.

We injected CityMapper, because it has no lower-level dependencies, so we don't need to mock.

@ In addition to instantiating mapper-related bean s, MybatisTest also detects the built-in database in dependencies, and then uses the built-in database when testing. If there is no built-in database in the dependency, it will fail. Of course, using embedded databases is the default behavior and can be modified with configuration.

@ MybatisTest also ensures that every test method is transaction rollback, so in the above test case, after the data is inserted in test2, the data inserted in test3 is still not available. Of course, this is also the default behavior, which can be changed.

Testing arbitrary bean s

The service layer is not a special layer, so there are no annotations to express the concept of "bean s that build only the service layer".

Here's another general test scenario. I'm testing a common bean with no special roles, such as not a controller for special processing or a dao component for persistence. We're testing just a common bean.

In the previous article, we used the default mechanism of @SpringBootTest, which looks for the configuration of @SpringBootApplication and builds Spring context accordingly. Look at the doc of @SpringBootTest, which has one sentence:

Automatically searches for a @SpringBootConfiguration when nested @Configuration is not used, and no explicit classes are specified.

This means that we can specify the Configuration class through the classes attribute, or define the embedded Configuration class to change the default configuration.

Here we use the built-in Configuration class to achieve, first look at the test target - CityService:

@Service
public class CityService {

    @Autowired
    private CityMapper cityMapper;

    public List<City> getAllCities() {
        return cityMapper.selectAllCities();
    }
}

Test scheme:

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

    @Configuration
    static class CityServiceConfig {
        @Bean
        public CityService cityService() {
            return new CityService();
        }
    }

    @Autowired
    private CityService cityService;

    @MockBean
    private CityMapper cityMapper;

    @Test
    public void getAllCities() {
        City city = new City();
        city.setId(1L);
        city.setName("Hangzhou");
        city.setState("Zhejiang");
        city.setCountry("CN");

        Mockito.when(cityMapper.selectAllCities())
                .thenReturn(Collections.singletonList(city));

        List<City> result = cityService.getAllCities();
        Assertions.assertThat(result.size()).isEqualTo(1);
        Assertions.assertThat(result.get(0).getName()).isEqualTo("Hangzhou");
    }
}

Similarly, we need to mock for the dependency on the test target.

Mock operation

In unit testing, it is necessary to mock the dependencies on the test target. It is necessary to introduce mock in detail. The logic, process and usage scenarios of Mock are introduced in the unit test section above, which focuses on the practical level.

Setting Expected Behavior Based on Method Parameters

The general mock is a method-level mock. When the method has parameters, the behavior of the method may be related to the specific parameter values of the method. For example, in a division method, the input parameters 4 and 2 get result 2, the input parameters 8 and 2 get result 4, and the input parameters 2 and 0 get abnormal.

mock can set different expectations for different parameter values, as follows:

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

    @Configuration
    static class ConfigTest {}

    @MockBean
    private MathService mathService;

    @Test
    public void testDivide() {
        Mockito.when(mathService.divide(4, 2))
                .thenReturn(2);

        Mockito.when(mathService.divide(8, 2))
                .thenReturn(4);

        Mockito.when(mathService.divide(ArgumentMatchers.anyInt(), ArgumentMatchers.eq(0))) // You must use matchers grammar at the same time
                .thenThrow(new RuntimeException("error"));

        Assertions.assertThat(mathService.divide(4, 2))
                .isEqualTo(2);

        Assertions.assertThat(mathService.divide(8, 2))
                .isEqualTo(4);

        Assertions.assertThatExceptionOfType(RuntimeException.class)
                .isThrownBy(() -> {
                    mathService.divide(3, 0);
                })
                .withMessageContaining("error");
    }
}

The above test may be a bit strange, and the mock object is also the target of the test. This simplifies the test process because our purpose is to introduce mocks.

From the above test cases, we can see that we can specify not only the behavior when the specific parameters are specified, but also the behavior when the parameters satisfy certain matching rules.

Method with return

For returned methods, the behavior that can be set for mock is:

Returns the set result, such as:

when(taskService.findResourcePool(any()))
        .thenReturn(resourcePool);

Throw an exception directly, such as:

when(taskService.createTask(any(), any(), any()))
        .thenThrow(new RuntimeException("zz"));

Actual calls to real methods, such as:

when(taskService.createTask(any(), any(), any()))
        .thenCallRealMethod();

Note that calling real methods violates the meaning of mock and should be avoided as much as possible. If other dependencies are invoked in the method to be invoked, you need to inject other dependencies by yourself, otherwise the null pointer will occur.

Method without return

For methods that do not return, the behavior that can be set for mock is:

Throw an exception directly, such as:

doThrow(new RuntimeException("test"))
        .when(taskService).saveToDBAndSubmitToQueue(any());

Actual calls (the following is an example given in doc of the Mockito class, which I did not encounter), such as:

doAnswer(new Answer() {
    public Object answer(InvocationOnMock invocation) {
        Object[] args = invocation.getArguments();
        Mock mock = invocation.getMock();
        return null;
    }})
.when(mock).someMethod();

appendix

Summary of Relevant Annotations

annotations.png

  • @RunWith:
    The junit annotation, which uses SpringRunner.class, enables the integration of junit and spring. Subsequent spring-related annotations will take effect.
  • @SpringBootTest:
    Spring annotations build the Spring context for testing by scanning the configuration in the application.
  • @AutoConfigureMockMvc:
    spring annotations can automatically configure MockMvc object instances to send http requests in a simulated test environment.
  • @WebMvcTest:
    spring's annotation, a slice test. Replacing @SpringBootTest can limit the scope of building beans to the web layer, but the lower layer of the web layer relies on beans and needs to be simulated by mock. You can also specify parameters to instantiate only one or more controller s in the web layer.
  • @RestClientTest:
    spring's annotation, a slice test. If the application accesses other Rest services as a client, the function of the client can be tested with this annotation.
  • @MybatisTest:
    Mybatis develops annotations according to spring's habits, one of the slice tests. By replacing @SpringBootTest, the return of the build bean can be limited to the mybatis-mapper layer.
  • @JdbcTest:
    Spring's annotation, a slice test. If you use Jdbc as a persistence layer (spring's Jdbc Template) in your application, you can use this annotation instead of @SpringBootTest to limit the scope of bean construction. Official reference materials are limited and can be searched online by oneself.
  • @DataJpaTest:
    spring's annotation, a slice test. If you use Jpa as a persistence layer technology, you can use this annotation.
  • @DataRedisTest:
    spring's annotation, a slice test.

Setting up test database

Add the annotation @AutoConfigureTestDatabase(replace = Replace.NONE) to the persistence layer test class and use the configured database as the test database. At the same time, you need to configure the data source in the configuration file as follows:

spring:
  datasource:
      url: jdbc:mysql://127.0.0.1/test
      username: root
      password: root
      driver-class-name: com.mysql.jdbc.Driver

Transaction does not roll back

You can add @Rollback(false) to the test method to set up no rollback, or you can add the annotation at the level of the test class to indicate that all test methods in this class will not roll back.

Topics: Spring Database Mybatis JDBC