一文详尽单元测试

前言

如果你认为单元测试会降低开发效率,那么它做的事就是让你的开发效率少降低一点;如果你认为单元测试可以提高开发效率,那么恭喜你,它会是一份宝藏。

这是一篇涵盖了大部分场景下需要用到的单元测试方法介绍,不管你是新手还是老鸟,都建议读读看。

本文并不会去传导单元测试的重要性之类的思想,这不是本文的重点,本文只说明如何写单元测试

案例

我们以SpringBoot构建一个简单的demo

引入依赖:

<!-- web环境,为后面的接口测试所准备-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 测试包 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>

命名规范

测试类的命名一般要以Test为后缀,例如:XxxTest

测试方法的命名一般要以test为前缀,例如:testXxx

注意:如果你的类名不是XxxTest,那么你在执行类似maven test命令时,是不会自动测试这个类的。

这个规范是在maven-surefire-plugin插件中约定的,你也可以自定义的设置你自己的命名规范,但是不建议这样做。

简单测试

简单测试只需在测试方法上加上Test注解即可

适用场景:测试一些工具类,验证心中所想(比如忘了正则怎么写了)

新建测试类: HelloTest, 测试方法:testHello

import org.junit.jupiter.api.Test;

public class HelloTest {
    
    @Test
    public void testHello(){
        System.out.println("Hello World!");
    }
}

接下来只需轻轻点击测试按钮

1、运行整个测试类,测试类中所有的测试方法(加了Test注解的)

2、运行这个测试方法(点开运行方式的界面)

3、直接运行这个测试方法

4、以debug的方式运行这个测试方法

5、以测试覆盖率的方式运行这个测试方法

一般是点3、4这两个

效果:

关于断言

断言的意思就是:... 断言!

有时候我们测试了某个方法,在当时我们知道结果是正确的,但是很可能过了几天:咦,这代码是我写的?

所以加个断言就很有必要了,它能让我们知道:只要测试结果通过了断言,那么就是这个被测试的方法就是正确的。如果没有通过,那就需要好好检查一下代码了!

那么断言应该怎么写呢?

import org.hamcrest.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.jupiter.api.Test;

public class HelloTest {

    @Test
    public void testAssert(){
        int a = 1, b =2 ;
        // 断言
        MatcherAssert.assertThat(a + b, CoreMatchers.is(3));
    }
}

第一个参数是实际测试的结果,第二个是match函数,里面放的是期望值

你也可以用junit的Assert方法,我比较喜欢上面的

业务测试

所谓业务测试就是测试你的业务代码,这种情况下,我们就需要用Spring环境了。

新建接口: FooService

public interface FooService {

    String hello();
}

实现类:FooServiceImpl

@Service
public class FooServiceImpl implements FooService {

    @Override
    public String hello() {
        System.out.println("foo hello");
        return "foo hello";
    }
}

测试类:FooTest

@SpringBootTest
public class FooServiceTest {

    @Autowired
    private FooService fooService;

    @Test
    public void testHello(){
        String hello = fooService.hello();
        MatcherAssert.assertThat(hello, CoreMatchers.is("foo hello"));
    }
}

注意:如果你的Test注解是junit4的: org.junit.Test,那么还需要在类上再加一个注解:@RunWith(SpringRunner.class)

数据测试

基本上每一个业务代码都离不开数据库,那么在做数据测试时,就离不开两个问题:

1、初始数据从哪里来(比如在做查询测试时)

2、测试产生的数据如何清除(比如在做新增测试时)

问题1:我们可以在测试方法上增加@Sql注解用于初始化数据

问题2:我们可以在测试方法上增加@Transactional@Rollback注解用于测试完毕自动回滚

案例:

假设我们要测试查询逻辑,首先我们在test/resourcs下新建sql目录,用于存放初始化数据sql

接着在sql目录中新建test_foo_select.sql文件

insert into user (`name`) values ('张三');

新建测试方法:

import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
public class FooServiceTest {

    @Autowired
    private FooService fooService;

    @Transactional
    @Rollback
    @Sql(value = "/sql/test_foo_select.sql")
    @Test
    public void testSelect(){
        // 假设该方法中调用了数据库
        User user = fooService.selectUser("张三");
        MatcherAssert.assertThat(user.getName(), CoreMatchers.is("张三"));
    }

}

@Transactional和@Rollback注解是为了回滚初始化的测试数据

假设要测试修改数据逻辑

@Rollback
@Transactional
@Test
public void testInsert(){
  fooService.insertUser(new User("李四"));
}

通常来说,不管测试任何业务都需加上Rollback和Transactional注解

Before与After注解

如果在你的单元测试类中,所有方法都依赖于一份初始化数据文件,那么你还可以这样写

@Sql(value = "/sql/test_foo_select.sql")
@BeforeEach
public void init(){
	// 这里可以写每个单元测试前需要做的事情
}

如果你用的是juint4, 那么使用的便是Before注解

同样,还有AfterEachAfter注解,使用方式相同,这里就不再赘述。

接口测试

以上测试是在测试业务层逻辑,有时候我们还需要测试接口层逻辑,比如说参数校验

新增测试接口:

@RequestMapping("/foo")
@RestController
public class FooController {

    @GetMapping
    public User getUser(String name){
        return new User(name);
    }
}

新增测试类:

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@AutoConfigureMockMvc
@SpringBootTest
public class FooControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testGet() throws Exception {
        // 构建请求
        MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get("/foo?name=张三");
        // 发起请求
        ResultActions resultActions = mockMvc.perform(builder);
        // 获取结果
        MockHttpServletResponse response = resultActions.andReturn().getResponse();
        response.setCharacterEncoding("UTF-8");
        // 断言http响应状态码是否为2xx
        resultActions.andExpect(MockMvcResultMatchers.status().is2xxSuccessful());
        // 获取响应数据
        String result = response.getContentAsString();
        User user = JSON.parseObject(result, User.class);
        MatcherAssert.assertThat(user.getName(), CoreMatchers.is("张三"));
    }

}

测试接口虽然看起来很复杂,但是里面大多是样板代码,在实际开发中,可以将这些样板代码封装到工具中

比如测试post请求时,代码同样如此

@Test
public void testPost() throws Exception {
  // 构建请求, 这里是唯一的变化,将get改为了post
  MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.post("/foo");
  // 发起请求
  ResultActions resultActions = mockMvc.perform(builder);
  // 获取结果
  MockHttpServletResponse response = resultActions.andReturn().getResponse();
  response.setCharacterEncoding("UTF-8");
  // 断言http响应状态码是否为2xx
  resultActions.andExpect(MockMvcResultMatchers.status().is2xxSuccessful());
  // 获取响应数据
  String result = response.getContentAsString();
  MatcherAssert.assertThat(result, CoreMatchers.is("true"));
}

MockMvcRequestBuilders里面有很多方法,这里给出常用的几个

// post请求
MockMvcRequestBuilders.post("/foo")
  						  // 请求参数
                .queryParam("key", "value")
                // header
                .header("token", "123456")
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                // 请求body
                .content(JSON.toJSONString(new User("张三")))

Mock测试

在如今分布式、微服务越来越火的情况下,一个系统总是不可避免的会与其他系统交互,但是在测试时,我们是不希望发生这种情况的,因为这样就需要依赖外部环境了。

单元测试的准则便是:能够独立运行。

此时,学会mock测试就是一件非常有必要的事情。

关于Mockito

spring-boot-test中,自带一个叫Mockito的工具,它能够帮助我们对不想调用的方法进行拦截,并且返回我们期望的结果

比如有一个FooService调用BarService的场景

当我们在测试时不想要真正调用barService,那么我们就可以使用Mockito进行拦截

基本Mock

新增BarService

public interface BarService {

    String mock();
}
@Service
public class BarServiceImpl implements BarService {

    @Override
    public String mock() {
        System.out.println("bar mock");
        return "bar mock";
    }
}

在FooService中添加mock方法

@Override
public String mock() {
  System.out.println("foo mock");
  return barService.mock();
}

使用mocktio测试

import org.mockito.Mockito;
import org.springframework.boot.test.mock.mockito.MockBean;

@SpringBootTest
public class FooServiceTest {

    @Autowired
    private FooService fooService;
    // 使用MockBean注解注入barService
    @MockBean
    private BarService barService;
  
    @Test
    public void testMock(){
        // 当调用barService.mock方法是返回it's mock
        Mockito.doReturn("it's mock").when(barService).mock();
        String mock = fooService.mock();
        MatcherAssert.assertThat(mock, CoreMatchers.is("it's mock"));
    }

}

使用参数控制mock

可能有时候会有这种奇怪的需求,当参数为1时使用mock,当参数为其他调用真实方法

@Test
public void testMockHasParam(){
  // 当参数为1时生效
  Mockito.doReturn("it's mock").when(barService).mock(Mockito.eq(1));
  String mock = fooService.mock(1);
  MatcherAssert.assertThat(mock, CoreMatchers.is("it's mock"));
}

如果你觉得任何参数都应该使用mock,那你可以在参数上写:Mockito.any()

Mockito中还有很多类似的方法,如果你觉得还不满足,mockito允许你自定义规则

@Test
public void testMockHasParam2() {
  // 当参数为1时生效
  Mockito.doReturn("it's mock")
    .when(barService)
    .mock(Mockito.intThat(arg -> {
      // 这里写你的逻辑
      return arg.equals(1);
    }));
  String mock = fooService.mock(1);
  MatcherAssert.assertThat(mock, CoreMatchers.is("it's mock"));
}

**注意:**虽然以上案例看起来像:当参数不为1时就调用真实方法,但实际上并不是的,因为barService实际上是Mockito生成的代理类,仅仅是个代理类,它并未持有真正的barService, 所以当不满足mock逻辑时,它永远都是返回null

那么该如何解决这个问题呢?

部分方法Mock

参数控制mock与部分方法mock的场景是共通的:在特定的情况下需要调用真实方法

改动方式特别简单:将原来的@MockBean注解替换为@SpyBean

SpyBean注解是真正的将Spring容器中的BarService进行代理,而不是简单的仅仅生成代理类,所以它具备了真正调用方法的能力

比如我们在FooService中新增方法:partMock

public String partMock() {
  barService.hello();
  return barService.mock();
}

现在我们期望在barService.hello()调用实际方法,调用barService.mock()时被mockito拦截

import org.springframework.boot.test.mock.mockito.SpyBean;

@SpringBootTest
public class FooServiceTest {

    @Autowired
    private FooService fooService;

    @SpyBean
    private BarService barService;

    @Test
    public void testPartMock() {
        Mockito.doReturn("it's mock").when(barService).mock();
        final String mock = fooService.partMock();
        MatcherAssert.assertThat(mock, CoreMatchers.is("it's mock"));
    }
}

你会发现使用方式没有任何的变化

静态方法Mock

你可能想问,为什么Mock还要区分是不是静态方法?这是因为静态方法mock是Mockito所不具备的能力,我们需要另外一个组件来完成:powermock

但很可惜的是,powermock只支持junit4,而且最近的release是在2020年11月2日

不管怎样,我们还是应该学习它,让我们在未来能够遇到这种问题时有解决办法

引入依赖:

<dependency>
  <groupId>org.powermock</groupId>
  <artifactId>powermock-module-junit4</artifactId>
  <version>2.0.2</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.powermock</groupId>
  <artifactId>powermock-api-mockito2</artifactId>
  <version>2.0.2</version>
  <scope>test</scope>
</dependency>

新增方法:

@Override
public String powermock() {
  return JSON.toJSONString(new User("张三"));
}

现在,我们想要拦截JSON.toJSONString方法,并且期望它返回xxx

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PowerMockIgnore;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.powermock.modules.junit4.PowerMockRunnerDelegate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

// 代理SpringRunner
@PowerMockRunnerDelegate(SpringRunner.class)
// 使用PowerMockRunner
@RunWith(PowerMockRunner.class)
// 延迟加载以下包中的所有类
@PowerMockIgnore(value = { "javax.management.*", "javax.net.ssl.*", "javax.net.SocketFactory", "oracle.*"})
@SpringBootTest
// 想要mock的类
@PrepareForTest(JSON.class)
public class PowermockTest {

    @Autowired
    private FooService fooService;

    @Test
    public void testPowermock(){
        // 固定写法
        PowerMockito.mockStatic(JSON.class);
        // 以下写法与mockito相同
        Mockito.when(JSON.toJSONString(Mockito.any())).thenReturn("xxx");
        String s = fooService.powermock();
        MatcherAssert.assertThat(s, CoreMatchers.is("xxx"));

    }
}

对于PowerMockIgnore注解笔者也不是太懂其中的原理,如果你在测试时发现哪个包报错,并且是你看不懂的,那么你就把这个包加到这里面就好了。

在Mockito 4.x版本,在Mockito-inline子项目中对静态方法mock有所支持。

由于该案例的springboot版本自带的mockito为3.x版本,所以需要对依赖进行如下更改

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
  <exclusions>
    <!-- 排除低版本的mockito -->
    <exclusion>
      <artifactId>mockito-core</artifactId>
      <groupId>org.mockito</groupId>
    </exclusion>
  </exclusions>
</dependency>
<!-- 引入更高的版本 -->
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <version>4.2.0</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-inline</artifactId>
  <version>4.2.0</version>
  <scope>test</scope>
</dependency>

使用方法极其简单:

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class MockitoInlineTest {

    @Autowired
    private FooService fooService;

    @Test
    public void testMockitoInline(){
        Mockito.mockStatic(JSON.class);
        Mockito.when(JSON.toJSONString(Mockito.any())).thenReturn("xxx");
        String s = fooService.powermock();
        MatcherAssert.assertThat(s, CoreMatchers.is("xxx"));
    }

}

配置文件的划分

大部分情况下,单元测试时所使用的配置与实际在服务器上运行时所用的配置是相同的,那么我们就可以单独在test/resources包下放入测试所用配置。

注意:测试包下的配置文件与main/resources下的配置文件是替换的关系

比如测试包下有一个application.yaml文件,里面的配置为:

abc: xxx

main/resources下也有一个application.yaml文件,里面的配置为:

def: xxx

实际运行时并非像往常一样是合并所有配置,而是只存在

abc: xxx

利用这样的方法,我们可以在单元测试时指定我们需要的环境,比如在微服务系统中单元测试时不需要连接注册中心,那么我们就可以在配置文件中将它关掉。

小结

编写单元测试是一件开头较难的事,对于未接触过单元测试的开发人员来说,可能编写一个接口需要1个小时,但是在编写单元测试的功夫上需要花费2个小时。本文的目的就在于能够让这样的同学快速的学习编写单元测试,让写单元测试也能快乐起来。

希望小伙伴们最终都能达到:单元测试可以提高开发效率

案例地址:https://gitee.com/lzj960515/junit-demo


如果我的文章对你有所帮助,还请帮忙点赞、关注、转发一下,你的支持就是我更新的动力,非常感谢!

个人博客空间:https://zijiancode.cn