新领导来了3天,我开始加班写单元测试!然后怒肝了- SpringBoot单元测试指南

” 最近公司来了新领导,所谓新官上任三把火。领导review了一遍公司的代码,发现大部分代码的测试覆盖率极低。之后每个部门都动员起来,填补原来单元测试的债“.小B对我吐糟道。 指北君见状立马连夜肝了这一篇,SpringBoot单元测试指南。

1 单元测试的优点与基本原则

单元测试,是指对程序中的最小可测试单元进行验证,在Java中的话,就是类。其有两个目的

  1. 验证程序实现的逻辑是否与设计的逻辑正确
  2. 在涉及到代码修改时,用单元测试去保证原有功能不被破坏,

而一个好的单元测试应该具备以下FIRST 原则和AIR原则中的任何一条:

单元测试的FIRST 规则

Fast 快速原则,测试的速度要比较快,

Independent 独立原则,每个测试用例应该互不影响,不依赖于外部资源。

Repeatable 可重复原则,同一个测试用例多次运行的结果应该是相同的

Self-validating 自我验证原则,单元测试可以自动验证,并不需要手工干预

Thorough 及时原则 单元测试必须即使进行编写,更新,维护。保证测试用例随着业务动态变化

AIR原则

Automatic 自动化原则 单元测试应该是自动运行,自动校验,自动给出结果。

Independent 独立原则 单元测试应该独立运行,吧相互之间无依赖,对外无依赖,多次运行之间无依赖。

Repeatable 可重复原则 单元测试是可重复运动的,每次的结果都稳定可靠。

一个整套完善的单元测试可以保障后续的增添功能时,程序迭代过程中,代码的逻辑正确性。验证程序的输入和输出与最初设计一致。这对后续的集成测试等会提供巨大的帮助。同时也会有利于集成测试的顺利进行。

2 单元测试的粒度应该如何选择

一个好的单元测试基本上应该满足上面讲到的FIRST 规则和AIR原则,然而关于单元测试需要覆盖到那些层级,覆盖到那些点,这个直接和写单元测试所花费的时间相关。所以一个团队应该要考虑单元测试的粒度,以及单元测试的代码覆盖率。

有些厂追求100%的单元测试覆盖率,本人觉得完全没有必要。stack overflow上也有一些讨论,有些点赞比较高的回答,大概意思就是:老板付钱是让我们写代码,而不是做测试。测试目的是保障代码逻辑符合预期的结果。然而有一些基本代码,没有必要专门去写单元测试中。而写单元测试应该要针对一些有意义的错误做测试,对比较复杂的逻辑需要囊括的单元测试也会比较多。把单元测试的90%的精力放到那些复杂且有意义的代码片段上。

目前一些代码扫描工具基本都给出了最低30%的单元测试代码覆盖率。这是一个最低限度,然而一个项目的覆盖率,要综合去考虑项目成本,人员安排等等因素。

关于单元测试的粒度,可以总结以下几点:

  1. DAO层的单元测试: 对于基本CRUD,可以考虑跳过这一部分单元测试,而一些比较复杂的动态更新、查询等操作,建议用使用H2去做模拟单元测试。
  2. Service层的单元测试:基本上一个Service里面肯定会依赖很多其他的service(此处也建议将成员变量通过构造方法进行注入,以便于单元测试去Mock),此时建议我们将依赖其他service的方法用Mock替代,Service里面的一些数据库的操作也进行Mock。这样可以保证service测试的独立性,不过对于逻辑复杂的方法可能要花很多时间在Mock上面。 如果发现需要Mock的方法过多,那么可能就需要考虑将要测试的方法是不是需要重构。
  3. Controller(API)层的单元测试:主要着重测试HTTP status在 200,400,500 等情况下的异常处理,request及response的转换等。由于其余部分的代码测试都已经在其对应的单元测试覆盖,那么此时可以Mock绝大部分Serivce层中的方法。
  4. 一般工具类的单元测试:一些工具类里面包含了比较多的逻辑,所以需要尽可能考虑多种情况下测试用例。

3 单元测试简单示例

单元测试可使用的第三方工具非常多,然而我们不可能精通每一个,只有在不断 的使用提高熟练程度。 对于SpringBoot而言可以直接引入spring-boot-starter-test , 它将会引入JUnit、Spring Test、AssertJ、Hamcrest(匹配对象)、Mockito、JSONassert、JsonPath等工具库。

IDEA编译器可以快速生成单元测试类,其步骤如下:打开当前类 -> Navigate -> Test -> Create New Test .一般情况下建议勾选自动创建Before/After 的方法。IDEA自动创建的Test类如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
class UserServiceTest {
    @BeforeEach
    void setUp() {   }

    @AfterEach
    void tearDown() {    }

    @Test
    void saveUser() {    }

    @Test
    void retrieveUserById() {    }
}

我们加上一些测试必要的注解(此时需要引入junit依赖),并写简单的单元测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RunWith(MockitoJUnitRunner.class)
@ExtendWith({MockitoExtension.class})
class UserServiceTest {
    UserService userService;
    @Mock
    UserRepository userRepository;
    @BeforeEach
    void setUp() {
        userService = new UserServiceImpl(userRepository);
    }
    @AfterEach
    void tearDown() {   }
    @Test
    void saveUser() {   }

    @Test
    void retrieveUserById() {
        User user1 = new User("javaNorth",100l,"指北君");
        when(userRepository.findById(anyLong())).thenReturn(Optional.of(user1));
        User user = userService.retrieveUserById(101L);
        assertThat(user1).isEqualTo(user);
    }
}

上面的示例我们Mock了userRepository.findById()这个方法在UserServiceImp.retrieveUserById 中的调用。这就可以测试到retrieveUserById方法中其余的代码逻辑,从而与userRepository隔离开来。

4 SpringBoot中的单元测试

SpringBoot中的单元测试可以启动服务,可以不启动服务。而在非必要的情况尽量避免启动整个SpringBoot服务。如果想要测试跑的更快,那么就要尽量少的实例化不需要的类。

下面我们看一下写单元测试时常用的注解。

@SpringBootTest

SpringBoot的单元测试可以使用@SpringBootTest 注解。其中可以在此注解参数中指定需要加载的类,这样有助于Spring快速启动并完成测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {MapRepository.class, CarService.class})
public class CarServiceWithRepoTest {

	@Autowired
	private CarService carService;

	@Test
	public void shouldReturnValidDateInTheFuture() {
    	Date date = carService.schedulePickup(new Date(), new Route());
    	assertTrue(date.getTime() > new Date().getTime());
	}
}
@DataJpaTest

对于JPA,Repository进行测试的时候可以使用@DataJpaTest 注解,有了这个注解,Spring在启动的时候就只会加载@Repository 相关的class。同样可以提高测试的效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RunWith(SpringRunner.class)
@DataJpaTest
class UserRepositoryTest {

    @Autowired
    UserRepository userRepository;

    @Test
    public void findByUserIdReturnUser() {
        User user1 = new User("javaNorth",100l,"指北君");
        Optional<User> userOptional = userRepository.findById(100l);
        assertThat(user1).isEqualTo(userOptional.get());
    }
}
@WebMvcTest web层的测试

测试WEB层可以使用@WebMvcTest 注解,使用此注解可以测试controller部分并且不用把整个服务都跑起来。也是一种提高测试速度的方法

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
@RunWith(SpringRunner.class)
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private UserService userService;

    @InjectMocks
    UserController userController;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        mvc = MockMvcBuilders.standaloneSetup(userController).build();
    }

    @AfterEach
    void tearDown() {
    }

    @Test
    void retrieveUserByUserId() throws Exception {
        User user1 = new User("javaNorth","100","指北君");
        given(this.userService.retrieveUserById("100"))
           .willReturn(user1);
         this.mvc.perform(MockMvcRequestBuilders.get("/javanorth/user/100")
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(MockMvcResultHandlers.print());
    }
}

5 单元测试的其他

@Test注解可以添加返回期望的异常,异常处理的测试也是需要考虑的地方。

1
@Test(expected = IndexOutOfBoundsException.class)

Karate + Cucumber 可以做基于WEB-API的自动化测试框架,也可以作为Controller部分的测试补充。

另外PowerMock也是一个比较好用的单元测试三方库,功能强大,可以对静态方法,构造方法,私有方法及Final方法进行模拟。后面指北君也会对PowerMock进行单独的介绍,尽情期待哦。

PowerMock

总结

本篇介绍了单元测试的一些概念以及依赖关系,并且给出了不同测试点的代码测试案例。实际项目中需要Mock的方法会比较多,这也就需要花费比较大的精力,虽然使用直接启动SpringBoot的方式可以避免许多Mock方法,但是这样做的话测试速度会明显降低,而且测试用例的独立性也会收到影响。至此指北君还是推荐大家多Mock相关依赖类的方法,以保证单元测试的独立性。PowerMock也是一个比较好用的单元测试

都看到这里了,还不动手撸一套单元测试。如果还有什么想法,不妨在评论区留言。

Java Geek Tech wechat
欢迎订阅 Java 技术指北,这里分享关于 Java 的一切。