参考资料:

  1. 文章讲解TDD
  2. 视频讲解TDD
  3. 测试驱动开发在项目中的实践
  4. TDD的概述

    代码示例说明

    这里我根据github的代码,进行简要的说明和讲解,最后我们会自己来写一遍测试。
    从接口层开始编写测试,从上至下,从接口到数据访问的顺序编写测试

    Controller层测试

    @WebMvcTest(controllers = PokemonController.class)
    @AutoConfigureMockMvc(addFilters = false)
    @ExtendWith(MockitoExtension.class)
    public class PokemonControllerTests {
        @Autowired
        private MockMvc mockMvc;
    
        @MockBean
        private PokemonService pokemonService;
    
        @Autowired
        private ObjectMapper objectMapper;
    }
  • mockMvc:用来模拟HTTP请求
  • objectMapper:将对象转为JSON字符串
  • pokemonService:PokemonController所依赖的Service对象,这里我们为了隔离外界的影响,将依赖的Service进行mock化,所以加上注解:MockBean,那这个对象的所有方法调用,就可以我们来控制执行。

    @Mock 和 @MockBean 的区别?

@Mock

  • @Mock 是 Mockito 库提供的注解,它允许你在测试类中声明一个mock对象。
  • 使用 @Mock 注解的字段将在初始化测试类时被自动替换为mock对象,但这需要配合特定的运行器(如MockitoJUnitRunner.class)或手动调用 MockitoAnnotations.openMocks(this) 来初始化这些mock对象。
  • @Mock 创建的mock对象并不会自动添加到Spring应用上下文(ApplicationContext)中。

@MockBean

  • @MockBean 是 Spring Boot Test 框架提供的注解,它同样可以创建mock对象,但它是在Spring Boot集成测试环境下使用的。
  • 当你在一个使用了 @SpringBootTest 的测试类中使用 @MockBean 时,它不仅会创建指定类型的mock对象,而且会将这个mock对象作为bean注入到当前的Spring应用上下文中。
  • 这意味着所有通过Spring容器依赖注入的方式来获取这个bean的地方,在测试期间都会得到这个mock对象,而不是实际的实现类。

从测试的角度就能看到,如果一个Controller依赖大量的Service对象,就需要进行大量的MockBean。测试的复杂性也就不断上升

单元测试这里遵守一种默认的规则(AAA模式):

  1. Arrange:做一些准备工作,比如新建对象,模拟对象行为
  2. Act:进行我们要执行的操作
  3. Assert:断言,看返回结果是否符合我们的预期

我们看一个示例:

@Test
public void PokemonController_CreatePokemon_ReturnCreated() throws Exception {
			given(pokemonService.createPokemon(ArgumentMatchers.any())).willAnswer((invocation -> invocation.getArgument(0)));

			ResultActions response = mockMvc.perform(post("/api/pokemon/create")
                                         .contentType(MediaType.APPLICATION_JSON)
                                         .content(objectMapper.writeValueAsString(pokemonDto)));

			response.andExpect(MockMvcResultMatchers.status().isCreated())
				.andExpect(MockMvcResultMatchers.jsonPath("$.name", CoreMatchers.is(pokemonDto.getName())))
				.andExpect(MockMvcResultMatchers.jsonPath("$.type", CoreMatchers.is(pokemonDto.getType())));
}

上面的代码通过插入空白行,来区分不同的行为,严格遵守:Arrange,Act,Assert的格式。并且可以发现,通过Mockito将Service对象的行为进行预设,这样就能单纯的测试Controller对象的功能了。

Service层测试

Service层一般依赖于Mapper层(Repository),所以我们mock Mapper对象,就不需要真的进行数据库交互,并模拟真实的返回结果,便于单纯测试Service对象中的业务逻辑。

@ExtendWith(MockitoExtension.class)
public class PokemonServiceTests {
    @Mock
    private PokemonRepository pokemonRepository;

    @InjectMocks
    private PokemonServiceImpl pokemonService;
}

可以看到这里的Service中没有引入:SpringBootTest之类的,因为这部分是纯业务逻辑。数据库访问,或者第三方接口访问我们都可以通过Mock来控制,让测试速度更快,测试更精确。
@InjectMocks表示Service对象内需要注入依赖,我们将Mock的对象注入进去,方便控制。
我们看一个例子:

public void PokemonService_CreatePokemon_ReturnsPokemonDto() {
    Pokemon pokemon = Pokemon.builder()
    .name("pikachu")
    .type("electric").build();
    PokemonDto pokemonDto = PokemonDto.builder().name("pickachu").type("electric").build();

    when(pokemonRepository.save(Mockito.any(Pokemon.class))).thenReturn(pokemon);

    PokemonDto savedPokemon = pokemonService.createPokemon(pokemonDto);

    Assertions.assertThat(savedPokemon).isNotNull();
}

可以看到这里也遵守了:Arrange,Act,Assert 层级关系。将数据库的交互全部控制住。

Mapper层测试

Mapper层就是专门和数据库打交道的,我们必须了解执行的SQL能否正常运行,并符合预期,所以这里我们就需要利用真实的Mapper对象去执行操作了。但是需要注意两点:

  1. 让数据库操作尽可能快
  2. 测试的独立性,每次测试都不能影响其他测试,比如第一条测试中插入的数据,必须清除,以防止影响第二个测试的进行

代码示例:

@DataJpaTest
@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2)
public class PokemonRepositoryTests {

    @Autowired
    private PokemonRepository pokemonRepository;

}
  • @DataJpaTest:这个注解主要用于简化和优化与数据库相关的集成测试,特别是针对 Spring Data JPA 存储库。
  • @AutoConfigureTestDatabase:它会配置一个嵌入式的内存数据库(如H2),而不是连接到实际生产环境中的数据库。这使得测试更快、更独立且不会影响到生产数据。
  • 在使用 @DataJpaTest 进行测试时,每个测试方法都在一个新的、与外界隔离的事务中执行。当测试方法执行完毕后,无论该方法内部对数据库进行了何种操作,Spring都会自动回滚这个事务,这样就保证了每次测试结束后数据库状态都能恢复到初始状态,不会影响后续测试。

这也就解决了我们上面提到的注意事项。我们代码示例一下:

public void PokemonRepository_SaveAll_ReturnSavedPokemon() {

    //Arrange
    Pokemon pokemon = Pokemon.builder()
    .name("pikachu")
    .type("electric").build();

    //Act
    Pokemon savedPokemon = pokemonRepository.save(pokemon);

    //Assert
    Assertions.assertThat(savedPokemon).isNotNull();
    Assertions.assertThat(savedPokemon.getId()).isGreaterThan(0);
}

思考

  1. 在对Service层做测试时,一定要Mock数据访问对象吗?

如果测试的Service对象,方法对数据进行了多种操作,我希望能看到是否能正确的配合,那这里就可以使用真实的数据访问对象。优点就是能测出真实情况,缺点就是不灵活,无法更纯粹的测试Service中的代码。

  1. 在对Controller层做测试时,如果进行大量操作,是否应该除了断言返回内容,还需要断言其他的内容呢?

比如调用接口:/printOrder,这个接口进行了:打印订单,增加积分,更改订单状态之类的操作,需要对这些操作进行断言判断吗?不需要,如果说Controller中依赖的外部对象都被mock掉了,那这些断言无意义,因为根本没有进行真实的操作。如果说Controller中依赖的外部对象都是真实的,也没有必要,因为只需要管好接口返回是否正常,其他操作应有各自的组件进行充分的测试。但如果说是一些老项目,本身并没有完整的测试用例,单纯对接口层写测试,也会有断言过于繁琐的问题。

自己编写的测试Demo

使用:SpringBoot + Mybatis + Mysql(开发环境) + H2(测试环境)

Controller

import com.fasterxml.jackson.databind.ObjectMapper;
import com.qinsicheng.blog.entity.Post;
import com.qinsicheng.blog.service.PostService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

import java.util.Collections;
import java.util.List;

import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(PostController.class)
class PostControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    PostService postService;

    @Autowired
    ObjectMapper objectMapper;

    Post post;

    @BeforeEach
    void setup() {
        post = Post.builder().id(1L).title("first").content("Hello World").build();
    }

    @Test
    void testCreatePost_ReturnIsCreated() throws Exception {
        // Arrange
        when(postService.create(any(Post.class))).thenReturn(post);

        // Act
        ResultActions response = mockMvc.perform(post("/api/post/create")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(post)));

        // Assert
        response.andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(post.getId()))
                .andExpect(jsonPath("$.title").value(post.getTitle()))
                .andExpect(jsonPath("$.content").value(post.getContent()));
    }

    @Test
    void testGetAllPost_ReturnListOfPosts() throws Exception {
        // Arrange
        List<Post> list = Collections.singletonList(post);
        when(postService.getAllPost()).thenReturn(list);

        // Act
        ResultActions response = mockMvc.perform(get("/api/post/list"));

        // Assert
        response.andExpect(status().isOk())
                .andExpect((jsonPath("$", hasSize(list.size())))).andDo(print());
    }

    @Test
    void testDeletePost_ReturnOk() throws Exception {
        // Arrange
        doNothing().when(postService).deleteById(anyLong());

        // Act
        ResultActions response = mockMvc.perform(delete("/api/post/1"));

        // Assert
        response.andExpect(status().isOk());
    }

    @Test
    void testUpdatePost_ReturnPostUpdated() throws Exception {
        when(postService.update(any(Post.class))).thenReturn(post);

        ResultActions perform = mockMvc.perform(put("/api/post/" + post.getId())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(post)));

        perform.andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(post.getId()));
    }
}

Service

package com.qinsicheng.blog.service;

import com.qinsicheng.blog.entity.Post;
import com.qinsicheng.blog.mapper.PostMapper;
import com.qinsicheng.blog.service.impl.PostServiceImpl;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;

import java.util.Collections;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.notNull;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class PostServiceTest {

    @Mock
    PostMapper postMapper;

    @InjectMocks
    PostServiceImpl postService;

    Post post;

    @BeforeEach
    void setup() {
        post = Post.builder().id(1L).title("first").content("Hello World").build();
    }

    @Test
    void testCreatePost_ReturnOneRowAffect() {
        when(postMapper.create(post)).thenReturn(1);

        Post postRs = postService.create(post);

        Assertions.assertNotNull(postRs);
    }

    @Test
    void testDeletePostById_IfIdIsNullOrNegative_ThrowIllegalArgumentException() {
        Assertions.assertThrows(IllegalArgumentException.class, () -> {
            postService.deleteById(null);
        });
        Assertions.assertThrows(IllegalArgumentException.class, () -> {
            postService.deleteById(-1L);
        });
    }

    @Test
    void testDeletePostById_ReturnVoid() {
        doNothing().when(postMapper).deleteById(notNull());

        assertAll(() -> {
            postService.deleteById(1L);
        });
    }

    @Test
    void testListPost_ReturnList() {
        when(postMapper.list()).thenReturn(Collections.singletonList(post));

        assertNotNull(postService.getAllPost());
    }

    @Test
    void testUpdatePost_IfIdIsNullOrNegative_ThrowIllegalArgumentException() {
        post.setId(null);
        Assertions.assertThrows(IllegalArgumentException.class, () -> {
            postService.update(post);
        });

        post.setId(-1L);
        Assertions.assertThrows(IllegalArgumentException.class, () -> {
            postService.update(post);
        });
    }

    @Test
    void testUpdatePost_IfSuccess_ReturnPost() {
        when(postMapper.update(post)).thenReturn(1);

        Post postRs = postService.update(post);

        assertNotNull(postRs);
    }
}

Mapper

package com.qinsicheng.blog.mapper;

import com.qinsicheng.blog.entity.Post;
import org.apache.ibatis.jdbc.SqlRunner;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.EmbeddedDatabaseConnection;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.event.annotation.BeforeTestClass;
import org.springframework.test.context.event.annotation.BeforeTestMethod;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.transaction.annotation.Transactional;

import java.io.FileNotFoundException;
import java.io.FileReader;
import java.util.List;

import static org.springframework.test.util.AssertionErrors.assertEquals;

@MybatisTest
@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2)
@Sql(scripts = "classpath:schema.sql")
class PostMapperTest {
    @Autowired
    PostMapper postMapper;

    Post post;

    @BeforeEach
    void setup() {
        post = Post.builder().id(1L).title("first").content("Hello World").build();
    }

    @Test
    void testCreate_ReturnAffectOneRow() {
        int i = postMapper.create(post);
        assertEquals("create post", 1, i);
    }

    @Test
    void testList_ReturnPostList() {
        postMapper.create(post);
        List<Post> posts = postMapper.list();
        assertEquals("PostMapper#list error", 1, posts.size());
    }

    @Test
    void testDelete_ReturnEmptyList() {
        postMapper.create(post);
        postMapper.deleteById(1L);
        List<Post> posts = postMapper.list();
        assertEquals("PostMapper#deleteById error", 0, posts.size());
    }

    @Test
    void testUpdate_ReturnUpdated() {
        postMapper.create(post);
        post.setTitle("second");
        post.setContent("Hello World Again");
        postMapper.update(post);
        List<Post> posts = postMapper.list();
        assertEquals("PostMapper#update error", "second", posts.get(0).getTitle());
    }
}

schema.sql

create table IF NOT EXISTS POSTS
(
    id      int comment '主键',
    title   varchar(100) not null comment '标题',
    content text         not null comment '内容',
    constraint posts_pk
        primary key (id)
);

总结

编写这些测试用例第一时间感觉最多的是繁琐,而且这些测试用例都很简单,实际中的项目要比这个复杂的多,当补测试用例,会发现完全懵逼。但从测试的角度的来看,容易测试代码必然是高度解耦的代码,不容易测试代码必定是高度耦合的代码。这也就进一步要求我们编写解耦的代码。TDD不是一下子能掌握的,需要长时间的学习和练习。

推荐文章:测试驱动开发在项目中的实践