Spring Boot 单元测试实践

7,251 阅读7分钟

最近在琢磨并写了不少单元测试,参考了很多关于 Spring Boot 的单元测试的文章, 但是绝大部分的案例都是通过 @SpringBootTest 来跑单测,多次实践后,发现对于单元测试来说其实这不是一个最优解,此方式更适用于集成测试

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

    @Autowired
    private FooService service;

    @Test
    public void get() {
        FooVO foo = service.get("id");
        assertThat(foo).isNotNull();
    }

}

一、单元测试

原则

AIR 原则

  • Automatic(自动化的):自动通过一系列的断言给出执行结果,而不需要人为去判断,在几十上百的测试用例下很难人为的去判断
  • Independent(独立的):测试用例之间不能相互依赖影响,是独立的
  • Repeatable(可重复的):单元测试是可以重复执行的,不能受到外界环境的影响,如数据库、远程调用、中间件等外部依赖不能影响测试用例的执行

可测性

目前在实际开发中来说单元测试更多是针对方法(函数)进行一个测试,而对方法来说最重要的就是方法是要可测的,如果某个方法是不可测的,或者说很难测,那么就代表此方法的结构存在有问题的,需要进行调整,可见进行单元测试对代码结构设计是有好处的

益处

不进行单元测试,那么在将来对此方法进行一个逻辑的修改或者重构将会付出更多的成本,因为没有一种快速高效可靠的手段去保证修改后的结果是正确的且不会影响到其它业务逻辑

而当对此方法有了充分的测试用例后,在后续的逻辑修改或者重构调整都可以放心大胆的进行,因为可以通过单元测试来验证调整重构后的结果是否依然符合期望

不足

写单元测试的成本太高,特别是在开发任务紧急的情况下很难做到,这也是 TDD 为何难以推行的原因之一

二、相关概念

注解

@RunWith(SpringRunner.class)

表明在 Spring 测试环境下执行测试用例,SpringRunnerSpringJUnit4ClassRunner 的别名,此 Runner 提供了一个 Spring 容器环境

@RunWith:When a class is annotated with @RunWith or extends a class annotated with @RunWith, JUnit will invoke the class it references to run the tests in that class instead of the runner built into JUnit.

SpringJUnit4ClassRunner:SpringJUnit4ClassRunner is a custom extension of JUnit's BlockJUnit4ClassRunner which provides functionality of the Spring TestContext Framework to standard JUnit tests by means of the TestContextManager and associated support classes and annotations.

@SpringBootTest

此注解用于启动一个真实的 Spring 容器用于测试,具有加载 ApplicationContext 的能力,因此可以随心所欲的注入和使用 Spring 容器里的 Bean, 如下所示 image.png 然而实际上的服务会依赖数据库、Redis、MQ等等之类的各种外部服务,此注解也需要配置相关的信息才能正常启动后才能执行单元测试,这违反了单元测试的可重复的原则

而引入这些依赖,服务的体积会变大,导致完全启动起来需要较长的时间,特别是在机器性能内存不够的情况下需要的时间就更久了,可实际上需要的只是简单的调试一个测试用例而已,尤其是这个测试用例需要频繁调试的时候,都需要等待服务慢悠悠的连完数据库、Redis、MQ,再去执行测试用例,不得不说这是一个及其低效的方式(网上的参考文章里的例子都不会提到这个,毕竟 demo 不需要依赖外部服务和中间件,秒秒钟就启动完成)

单元测试的 R 原则是要遵守的,不应该依赖外部服务和中间件,毕竟大部分情况下在单元测试阶段是没有这些中间件的,尤其是在 CI/CD 流程中

@MockBean

spring-boot-test 包提供的注解,用于在 Spring 容器中 Mock 一些 Bean,当测试目标依赖了 下层的 Bean 时,可通过该注解 mock 注入,避免真正去调用 Bean,毕竟不一定会有真实数据库或者其它外部依赖

@Service
public class FooService {
    @Autowired
    private FooRepository fooRepository;
    @Autowired
    private BarRepository barRepository;
    
    // some method
}

@RunWith(SpringRunner.class)
public class FooServiceTest {
    @Autowired
    private FooService fooService;
    @MockBean
    private FooRepository fooRepository;
    @MockBean
    private BarRepository barRepository;
    
    // some test method
}

@Import 

提供一种快速的方式把 Bean 加入 Spring 的容器中,使得该 Bean 能够被注入(此注解貌似跟单元测试没得关系,实际上也没得关系,但是也可以在单元测试中使用)

// 快速导入
@Import({FooService.class})
@RunWith(SpringRunner.class)
public class BarServiceTest extends BaseTest {
    @Autowired
    private FooService fooService;

    @MockBean
    private FooRepository fooRepository;
    @MockBean
    private BarRepository barRepository;
 	// some test method
}

// 正常使用
@RunWith(SpringRunner.class)
public class OtherServiceTest extends BaseTest {
    @Autowired
    private FooService fooService;

    @MockBean
    private FooRepository fooRepository;
    @MockBean
    private BarRepository barRepository;

    // 提供一些测试相关的配置入口,也仅限于 test,ComponentScan 会跳过此类的
    @TestConfiguration
    static class TestContextConfiguration {
        @Bean
        public FooService fooService() {
            return new FooService();
        }
    }
 	// some test method
}

Mockito

单测很重要的一个思想就是 Mock,通过 Mock 能够做到不依赖任何外部的服务或中间件,只关注于方法本身的逻辑,任何外部依赖都应该通过 Mock 的手段完成,如果无法做到 Mock,那么代表方法和类是不可测的,代码结构存在问题,需要进行结构上的调整

Mockito 是 Java 中一个比较强大的 Mock 框架,关于此框架的用法网上文章很多,就此略过

手把手教你 Mockito 的使用

单元测试利器Mockito框架

Mockito API Docs

Mockito is a mocking framework that tastes really good. It lets you write beautiful tests with a clean & simple API. Mockito doesn't give you hangover because the tests are very readable and they produce clean verification errors.

Mock数据填充

构造真实的 Mock 数据,以下列出了两种,看个人喜好,选一种即可

jfairy

github.com/Codearte/jf…

@Test
public void name() {
    Fairy fairy = Fairy.create();
    fairy.person();
    fairy.company();
    fairy.creditCard();
    fairy.textProducer();
    fairy.baseProducer();	
    fairy.dateProducer();
    fairy.networkProducer();
}

java-faker

github.com/DiUS/java-f…

@Test
public void name() {
    Faker faker = new Faker();
    String name = faker.name().fullName(); // Miss Samanta Schmidt
    String firstName = faker.name().firstName(); // Emory
    String lastName = faker.name().lastName(); // Barton
    String streetAddress = faker.address().streetAddress(); // 60018 Sawayn Brooks Suite 449
}

断言

判断一个方法执行的结果是否符合期望

单元测试如果不进行断言那是没有意义的,每一次代码逻辑的改动,通过断言都能够及时的接收到反馈,所以断言是必须有的

三、示例

// FooService
public FooVO get(String id) {
    BarDO barDO = barRepository.getByFooId(id);
    FooDO fooDO = fooRepository.get(id);
    FooVO foo = new FooVO();
    foo.setStatus(barDO.getType());
    foo.setId(id);
    foo.setName(fooDO.getName() + "-suffix");
    switch (barDO.getType()) {
        case FAILED:
            foo.setContent("this is failed result");
            break;
        case SUCCESS:
            foo.setContent("this is success result");
            break;
        default:
            throw new DummyException("some exception happened!");
    }
    return foo;
}

正常流

// BarServiceTest
@Test
public void getSuccess() {
    String name = getName();
    when(barRepository.getByFooId(ID)).thenReturn(mockBar(Status.SUCCESS));
    when(fooRepository.get(ID)).thenReturn(mockFoo(name));
    FooVO fooVO = fooService.get(ID);
    assertThat(fooVO).isNotNull();
    assertThat(fooVO.getContent()).isEqualTo("this is success result");
    assertThat(fooVO.getName()).isEqualTo(name + "-suffix");
}

image.png 当逻辑稍微调整,运行单元测试,断言就会失败

foo.setContent("this is success result...");

image.png

异常流

// BarServiceTest
@Rule
public ExpectedException expected = ExpectedException.none();
@Test
public void getException() {
    expected.expect(DummyException.class);
    expected.expectMessage("exception happened"); // 包含
    when(barRepository.getByFooId(ID)).thenReturn(mockBar(Status.EXCEPTION));
    when(fooRepository.get(ID)).thenReturn(mockFoo(getName()));
    fooService.get(ID);
}

完整案例

@Import({FooService.class})
@RunWith(SpringRunner.class)
public class BarServiceTest extends BaseTest {
    @Autowired
    private FooService fooService;

    @MockBean
    private FooRepository fooRepository;
    @MockBean
    private BarRepository barRepository;
    
    @Rule
    public ExpectedException expected = ExpectedException.none();

    // ignore setUp/tearDown
    
    @Test
    public void getSuccess() {
        String name = getName();
        when(barRepository.getByFooId(ID)).thenReturn(mockBar(Status.SUCCESS));
        when(fooRepository.get(ID)).thenReturn(mockFoo(name));
        FooVO fooVO = fooService.get(ID);
        assertThat(fooVO).isNotNull();
        assertThat(fooVO.getContent()).isEqualTo("this is success result");
        assertThat(fooVO.getName()).isEqualTo(name + "-suffix");
    }

    @Test
    public void getFailed() {
        String name = getName();
        when(barRepository.getByFooId(ID)).thenReturn(mockBar(Status.FAILED));
        when(fooRepository.get(ID)).thenReturn(mockFoo(name));
        FooVO fooVO = fooService.get(ID);
        assertThat(fooVO).isNotNull();
        assertThat(fooVO.getContent()).isEqualTo("this is failed result");
        assertThat(fooVO.getName()).isEqualTo(name + "-suffix");
    }

    @Test
    public void getException() {
        expected.expect(DummyException.class);
        expected.expectMessage("exception happened");
        when(barRepository.getByFooId(ID)).thenReturn(mockBar(Status.EXCEPTION));
        when(fooRepository.get(ID)).thenReturn(mockFoo(getName()));
        fooService.get(ID);
    }

}

@Service
public class FooService {
    @Autowired
    private FooRepository fooRepository;
    @Autowired
    private BarRepository barRepository;

    // 当此方法的逻辑有任何的调整,测试用例都有可能执行失败
    public FooVO get(String id) {
        BarDO barDO = barRepository.getByFooId(id);
        FooDO fooDO = fooRepository.get(id);
        FooVO foo = new FooVO();
        foo.setStatus(barDO.getType());
        foo.setId(id);
        foo.setName(fooDO.getName() + "-suffix");
        switch (barDO.getType()) {
            case FAILED:
                foo.setContent("this is failed result");
                break;
            case SUCCESS:
                foo.setContent("this is success result");
                break;
            default:
                throw new DummyException("some exception happened!");
        }
        return foo;
    }
}

详细代码,传送门

示例代码基于 Junit4,Junit5在4的基础上优化增强了很多,但是这不是最重要的,工具再好用,如果方法不可测、用例断言没设计好依旧是徒然的

@Rule 和 @TestConfiguration 等其它概念后续有机会实践后再聊聊

四、结语

敏捷宣言有一句就是要响应变化,而对于开发人员来说,单元测试就是一种高效、可靠的方式去响应需求或是其它层面的变化,对代码逻辑进行的任何改动都能够快速的在测试用例中得到反馈