0 概述
测试是开发中重要的一部分,单元测试更是测试中最重要的部分,虽然单元测试简单。但是正因为成本更低,才能更快地把代码测试起来不是吗,让bug在项目最开始的阶段就能发现出来,而不是在项目的尾端才发现出来。所以,单元测试其实是测试理论中,成本效率最高的一种方法。
本书也指出了我一直以来单元测试的一些误区。
1 理论
1.1 目标
- 单元测试不是要追求100%的覆盖率,也是避免显而易见的bug产生。所以,对于一眼就能看穿的代码,就不需要单元测试。而那些分支多,场景复杂的例子更应该被仔细地测试。
1.2 测试前提
- 良好的可维护代码,模块化代码。
1.3 原则
- 测试应该是独立的,各个单元测试之间的用例不应该互相依赖,更不应该依赖测试的顺序。P19
- 外部依赖应该被替身掉,以加强确定性和速度。P27
- 一个测试应该只有一个失败的原因。P48。一旦违反以后,只要一个地方新增了字段就会触发多处的用例失败,导致难以维护单元测试代码。
- 一个测试应该只包含一个场景。P56
2 替身
替身的意义在于:
- 桩Stub,没有实现行为,纯碎的硬编码实现。P30
- 伪造对象Fake,用简要实现来代替实际实现,并提供了行为。例如用内存数据库代替实际数据库,用控制台日志代替文件日志。P31
- 间谍Spy,记录替身的输入参数与次数,而给后续验证时使用.P32
- 模拟对象Mock,最为复杂的替身,完成控制替身的每一步行为.P33
3 可读性
- 提高验证的抽象层次.P44
- 将验证的准备,各个子动作,测试过程分开,以提高可读性。P54
- 为魔法数提供一个名字。P65
4 可维护性
- 尽可能减少条件执行结构,而使用类似contains的验证。P77
- 避免脆弱的测试,时间,文件,外部接口,应该被mock掉。P80
- 避免使用绝对路径,而要使用相对路径。P81
- 临时文件,应该在setUp阶段就保证先删除后创建的方式,避免意外的错误。P85
- 避免使用sleep方法,速度慢而且不确定。P87
- 避免像素对比的验证方法,应该用业务意义上的验证方法。P93
5 可测设计
- 模块化设计,这个说烂了,但是也是最难做到的。
- 避免方法private
- 避免方法final
- 避免方法static
- 避免用new或者单例的创建依赖对象,这样难以替换依赖。
注入替身的方法
- 使用@Override覆盖方法,返回自定义替身。P136
- 使用Setter注入自定义替身。P140
- 使用IOC框架注入自定义替身,最重型的方法。P139
6 junit4
代码在这里
6.1 生命周期
package spring_test;
import org.junit.*;
import static org.junit.Assert.*;
/**
* Created by fish on 2021/5/30.
*/
public class Test1 {
@BeforeClass
public static void a(){
System.out.println("beforeClass");
}
@AfterClass
public static void b(){
System.out.println("afterClass");
}
@Before
public void setUp(){
System.out.println("setUp");
}
@Test
public void test1(){
System.out.println("test1 ref = "+this.toString());
assertEquals(1,1);
}
@Test
public void test2(){
System.out.println("test2 ref = "+this.toString());
assertEquals(2,2);
}
@After
public void clean(){
System.out.println("clean");
}
}
从代码中我们可以看出:
- 每个测试用例所用的Test1实例都是不同的,这保证了上一个测试的数据不会遗留在下一个测试用例去。
- @Before,@After是每个测试用例都会执行一次的,分别在测试前,和测试后触发
- @BeforeClass,@AfterClass是整体测试前跑一遍。
这是设置测试用例的地方
6.2 断言
6.2.1 基本断言
import static org.junit.Assert.*;
@Test
public void test3(){
assertEquals(2,2);
}
assertEquals是最常用的断言
6.2.2 类字段断言
package spring_test;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.beans.factory.annotation.Autowired;
import javax.annotation.PostConstruct;
/**
* Created by fish on 2021/5/1.
*/
public class JsonAssertUtil {
private static ObjectMapper objectMapper;
private static ObjectMapper get(){
if( objectMapper == null){
= new ObjectMapper();
objectMapper }
return objectMapper;
}
public static void checkEqualNotStrict(String target,Object result){
try{
= get();
ObjectMapper objectMapper String resultJson = objectMapper.writeValueAsString(result);
System.out.println(resultJson);
.assertEquals(target, resultJson,false);
JSONAssert}catch(Exception e){
throw new RuntimeException(e);
}
}
public static void checkEqualStrict(String target,Object result){
try{
= get();
ObjectMapper objectMapper String resultJson = objectMapper.writeValueAsString(result);
System.out.println(resultJson);
.assertEquals(target, resultJson,true);
JSONAssert}catch(Exception e){
throw new RuntimeException(e);
}
}
}
JSONAssert是一个相当重要的工具,我们用来检查类的部分字段是否是我们所期望的。而且,它的NoStrict模式避免了过度断言,具体看它的文档。
package spring_test;
import org.junit.Test;
/**
* Created by fish on 2021/5/30.
*/
public class Test2 {
@Test
public void test1(){
= new OrderDO();
OrderDO orderDO
.setSize(1);
orderDO.setName("fish");
orderDO
.checkEqualNotStrict(
JsonAssertUtil"{size:1}",
orderDO);
.checkEqualNotStrict(
JsonAssertUtil"{name:\"fish\"}",
orderDO);
}
}
这是测试代码。可以看到编写测试代码的时候,避免对字段名进行双引号操作。
6.2.3 异常断言
package spring_test;
import org.junit.Test;
/**
* Created by fish on 2021/5/30.
*/
public class Test3 {
@Test(expected = RuntimeException.class)
public void test3(){
throw new RuntimeException("123");
}
}
异常断言的方式,只检查了断言的异常类型,没有检查异常的消息。FIXME,这里还需要优化一下。
6.3 套件
package spring_test;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
/**
* Created by fish on 2021/5/30.
*/
@RunWith(Suite.class)
@Suite.SuiteClasses({
.class,
Test1.class,
Test2.class})
Test3public class TestAll {
}
套件的好处是,可以同时测试多个单元测试。
6.4 SpringBoot集成
package spring_test;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import javax.naming.ldap.Control;
import static org.junit.Assert.*;
/**
* Created by fish on 2021/5/30.
*/
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class Test4 {
@Autowired
private Controller controller;
@Test
public void test(){
assertEquals(controller.getDisplay(),"mm");
}
}
对于SpringBoot,如果我们需要注入bean,那么就要添加@RunWith和@SpringBootTest注解。
7 mockito
mockito是重要的mock工具,当然mock也不是越多越好,最好的单元测试应该是无mock的。代码在这里
7.1 有返回值mock
package spring_test;
import org.junit.*;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
/**
* Created by fish on 2021/5/30.
*/
//有返回值mock
public class Test1 {
//mock控制返回值
@Test
public void test1(){
Service a = mock(Service.class);
//无参数mock
when(a.display()).thenReturn("123");
assertEquals(a.display(),"123");
//含参数的mock
when(a.display2(anyInt())).thenReturn("456");
assertEquals(a.display2(1),"456");
}
//mock控制行为
@Test
public void test2(){
Service b = mock(Service.class);
when(b.display()).thenAnswer(new Answer<String>() {
private boolean isFirst = true;
@Override
public String answer(InvocationOnMock var1) throws Throwable{
if( isFirst){
= false;
isFirst return "123";
}else{
return "456";
}
}
});
assertEquals(b.display(),"123");
assertEquals(b.display(),"456");
}
//mock抛出异常
@Test(expected = RuntimeException.class)
public void test3(){
Service c = mock(Service.class);
when(c.display()).thenThrow(new RuntimeException());
.display();
c}
}
当对象方法是有返回值的时候,mock是很简单的,直接when,然后thenXXX就可以了。
7.2 无返回值mock
package spring_test;
import org.junit.Test;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
/**
* Created by fish on 2021/5/31.
*/
//无返回值mock
public class Test2 {
//mock,啥事不做
@Test
public void test1(){
Service a = mock(Service.class);
doNothing().when(a).nothing();
.nothing();
a}
//mock,抛出异常
@Test(expected = RuntimeException.class)
public void test2(){
Service a = mock(Service.class);
doThrow(new RuntimeException()).when(a).nothing2(anyInt());
.nothing2(1);
a}
}
当对象方法是没返回值的时候,mock要麻烦一点,先写doXXX,然后再when加方法。
7.3 mock检查
package spring_test;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.util.Arrays;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
/**
* Created by fish on 2021/5/31.
*/
//捕获参数检查
public class Test3 {
//逐次检查参数
@Test
public void test1(){
Service a = mock(Service.class);
.display2(1);
averify(a).display2(1);
.display2(2);
averify(a).display2(1);
}
//逐次检查调用次数
@Test
public void test2(){
Service b = mock(Service.class);
.nothing();
b.nothing();
b
verify(b,times(2)).nothing();
}
//使用参数捕获器,检查参数
@Test
public void test3(){
Service a = mock(Service.class);
.display2(12);
a.display2(23);
a
<Integer> argument = ArgumentCaptor.forClass(Integer.class);
ArgumentCaptorverify(a,times(2)).display2(argument.capture());
assertEquals(
Arrays.asList(12,23),
.getAllValues()
argument);
}
}
mock检查主要分两种,检查mock对象方法被调用的次数,以及检查mock对象方法的参数是否正确。
7.4 注入mock
package spring_test;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
/**
* Created by fish on 2021/5/31.
*/
public class Test4 {
@Mock
private Service service;
@InjectMocks
private Controller controller;
@Before
public void setUp(){
.initMocks(this);
MockitoAnnotations}
@Test
public void test1(){
when(service.display()).thenReturn("789");
assertEquals(controller.getDisplay(),"789");
}
}
Mock框架提供了相当轻松的方式注入mock,@InjectMocks是要测试的对象,它的依赖用mock代替了。@Mock就是我们手动mock的对象。记住,在使用前要用MockitoAnnotations进行mock的注入。
8 junit5
代码在这里
8.1 生命周期
package spring_test;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* Created by fish on 2021/5/30.
*/
public class Test1 {
@BeforeAll
public static void a(){
System.out.println("beforeClass");
}
@AfterAll
public static void b(){
System.out.println("afterClass");
}
@BeforeEach
public void setUp(){
System.out.println("setUp");
}
@Test
public void test1(){
System.out.println("test1 ref = "+this.toString());
assertEquals(1,1);
}
@Test
public void test2(){
System.out.println("test2 ref = "+this.toString());
assertEquals(2,2);
}
@AfterEach
public void clean(){
System.out.println("clean");
}
}
生命周期的命名比原来的更加直观
8.2 断言
import static org.junit.jupiter.api.Assertions.*;
@Test
public void test2(){
assertEquals(2,2);
}
基础断言没变,就是改了一下包引用而已
package spring_test;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Created by fish on 2021/5/30.
*/
public class Test3 {
@Test
public void test3(){
RuntimeException exception = assertThrows(RuntimeException.class,()->{
throw new RuntimeException("123");
});
assertEquals(exception.getMessage(),"123");
}
}
异常断言不再允许在@Test的标记上面写了,用assertThrows断言,比原来的更加强大了。
8.3 套件
在JUnit5里面就不再支持测试套件了,不能使用@TestSuit注解
取而代之是更现代的方法,在IDE里面选择测试整个包。
8.4 SpringBoot集成
package spring_test;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import javax.naming.ldap.Control;
import static org.junit.jupiter.api.Assertions.*;
/**
* Created by fish on 2021/5/30.
*/
@SpringBootTest
public class Test4 {
@Autowired
private Controller controller;
@Test
public void test(){
assertEquals(controller.getDisplay(),"mm");
}
}
直接用@SpringBootTest就能引入SpringBoot集成了
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package org.springframework.boot.test.context;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.core.annotation.AliasFor;
import org.springframework.test.context.BootstrapWith;
import org.springframework.test.context.junit.jupiter.SpringExtension;
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ExtendWith({SpringExtension.class})
public @interface SpringBootTest {
@AliasFor("properties")
String[] value() default {};
@AliasFor("value")
String[] properties() default {};
String[] args() default {};
Class<?>[] classes() default {};
.WebEnvironment webEnvironment() default SpringBootTest.WebEnvironment.MOCK;
SpringBootTest
public static enum WebEnvironment {
MOCK(false),
RANDOM_PORT(true),
DEFINED_PORT(true),
NONE(false);
private final boolean embedded;
private WebEnvironment(boolean embedded) {
this.embedded = embedded;
}
public boolean isEmbedded() {
return this.embedded;
}
}
}
@SpringBootTest注解有效的关键在于@ExtendWith注解和@BootstrapWith注解
9 DBUnit与H2
在单元测试里面,如果模块涉及到数据库交互是测试中最麻烦的,因为数据库是一个副作用的系统,这次写入数据库的测试用例会影响到下一个测试用例的输入。另外数据库测试的麻烦点在于,它的输入和输出不能通过简单的内存参数传递来实现。
因此,绝大部分的情况下,我们提倡让代码模块化,让代码技术与业务分离的方式,使得代码更容易被测试,可以脱离数据库进行单独的单元测试。(这点并不容易,那确实可以做到。)
而在很少一部分的情况下,我们会使用Mock来替代数据库真实实现来测试。那么,最终在一些极少的场景下,一个模块依赖的项很多,用Mock麻烦,所以这时候我们才会考虑使用连接真实的数据库来进行单元测试。
数据库测试,我们需要两个工具:
- H2,类似sqlite的嵌入式数据库,但是它相比sqlite的好处在于,它对mysql协议的支持很好,大部分的sql语句不需要更改就能直接在H2数据库运行。在单元测试中使用H2这类的嵌入式数据库的意义在于,避免网络延迟加快测试速度和不稳定导致的测试失败,而且H2支持数据放在内存中,可以暴力地支持每个测试用例指向一个单独的内存数据库,从而支持并发的单元测试。
- DBUnit,DBUnit的意义在于,简化数据库集的测试用例的导入和测试结果的校验。
代码在这里
9.1 依赖
dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
<dependency> </
H2的依赖
dependency>
<groupId>org.dbunit</groupId>
<artifactId>dbunit</artifactId>
<version>2.7.0</version>
<scope>test</scope>
<dependency>
</
dependency>
<groupId>com.github.springtestdbunit</groupId>
<artifactId>spring-test-dbunit</artifactId>
<version>1.3.0</version>
<dependency> </
DBUnit的依赖,我们另外导入springtestdbunit的包,它能提供注解方便配置DBUnit
9.2 H2
spring.datasource.url = jdbc:h2:mem:testdb
spring.datasource.driver-class-name = org.h2.Driver
spring.jpa.hibernate.ddl-auto=create
在application-test.properties中加入以上的配置,指定h2作为datasource。同时,由于使用Hibernate作为ORM,它能提供自动生成DDL语句的功能。
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Before;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.AopContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import javax.transaction.Transactional;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Created by fish on 2021/5/30.
*/
//
@SpringBootTest
@ActiveProfiles("test")
@Slf4j
//每个方法之后都会重置ApplicationContext,速度会变慢,但是环境每次都是全新的,每个用例都会自动执行一次DDL语句,删除数据库然后重建表
@DirtiesContext(classMode= DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class Test1 {
@Autowired
private UserRepository userRepository;
private Test1 current;
@Autowired
private Service service;
@BeforeEach
public void init(){
//Test1不纳入Spring的bean,所以不能使用AopContext.currentProxy();
//current = (Test1) AopContext.currentProxy();
}
@Transactional
public void add(User user){
this.userRepository.add(user);
}
@Test
public void go(){
List<User> users = userRepository.getAll();
assertEquals(users.size(),0);
.add(new User("fish"));
serviceassertEquals(userRepository.getAll().size(),1);
}
@Test
public void go2(){
List<User> users = userRepository.getAll();
assertEquals(users.size(),0);
.add(new User("cat"));
service.add(new User("dog"));
serviceassertEquals(userRepository.getAll().size(),2);
}
}
这是示范的测试用例,注意:
- @ActiveProfiles注解来配置我们的profile。
- @DirtiesContext来配置每次测试用例都要重建ApplicationContext
9.3 DBUnit
package spring_test;
import com.github.springtestdbunit.DbUnitTestExecutionListener;
import com.github.springtestdbunit.annotation.DatabaseOperation;
import com.github.springtestdbunit.annotation.DatabaseSetup;
import com.github.springtestdbunit.annotation.ExpectedDatabase;
import com.github.springtestdbunit.assertion.DatabaseAssertionMode;
import lombok.Data;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockitoTestExecutionListener;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
@ActiveProfiles("test")
@TestExecutionListeners({
.class,
DependencyInjectionTestExecutionListener.class,
DirtiesContextTestExecutionListener.class,
TransactionalTestExecutionListener.class,
MockitoTestExecutionListener.class
DbUnitTestExecutionListener})
public class Test2 {
@Autowired
private UserRepository userRepository;
@Autowired
private Service service;
/*
NONE:不执行任何操作,是getTearDownOperation的默认返回值。
UPDATE:将数据集中的内容更新到数据库中。它假设数据库中已经有对应的记录,否则将失败。
INSERT:将数据集中的内容插入到数据库中。它假设数据库中没有对应的记录,否则将失败。
REFRESH:将数据集中的内容刷新到数据库中。如果数据库有对应的记录,则更新,没有则插入。
DELETE:删除数据库中与数据集对应的记录。
DELETE_ALL:删除表中所有的记录,如果没有对应的表,则不受影响。
TRUNCATE_TABLE:与DELETE_ALL类似,更轻量级,不能rollback。
CLEAN_INSERT:是一个组合操作,是DELETE_ALL和INSERT的组合。是getSetUpOeration的默认返回值。
*/
@DatabaseSetup( value = {"classpath:/test2.xml"},type = DatabaseOperation.CLEAN_INSERT)
@Test
public void go() {
List<User> users = userRepository.getAll();
assertEquals(users.size(), 2);
.add(new User("kk"));
service= userRepository.getAll();
users assertEquals(users.size(), 3);
}
//使用CLEAN_INSERT能保证每次数据库都是在干净的情况下再运行的
@DatabaseSetup( value = {"classpath:/test3.xml"},type = DatabaseOperation.CLEAN_INSERT)
@Test
public void go2() {
List<User> users = userRepository.getAll();
assertEquals(users.size(), 1);
}
/*
DEFAULT。数量一致,顺序一致,字段全部一致
NON_STRICT。数量一致,顺序一致,字段只验证数据集的部分
NON_STRICT_UNORDERED。数量一致,顺序可以不一致,字段只验证数据集的部分
*/
@DatabaseSetup( value = {"classpath:/test4.xml"},type = DatabaseOperation.CLEAN_INSERT)
@ExpectedDatabase(value="classpath:/test4_validate.xml",assertionMode= DatabaseAssertionMode.DEFAULT)
@Test
public void go3(){
this.service.mod(1L,"KK");
}
}
这是DBUnit的测试代码,注意:
- @ActiveProfiles,依然需要,确保指向到自己的H2数据库
- @TestExecutionListeners,确保需要,DBUnit需要知道测试用例的生命周期,以在测试前清理和注入数据,在测试后进行数据对比校验工作
- @DatabaseSetup,测试用例的数据集注入工作,注意type的选择,一般我们选择CLEAN_INSERT,以保证最干净的测试环境。这个注解也可以放在类级别,使得在类级别的测试用例只执行一次。
- @ExpectedDatabase,对数据进行校验的工作。注意有多个选项。
DBUnit的问题在于,@DatabaseSetup注入数据都是需要填写多个字段。一旦表结构新增一个无默认值的新字段,那么所有测试用例的xml文件,都需要加入这个字段,这样会破坏测试用例的可维护性。其实,对于所有无Mock的真实数据库测试都会面对这个问题。
<?xml version="1.0" encoding="UTF-8"?>
dataset>
<user
< id="1"
name="fish"
/>user
< id="2"
name="cat"
/>dataset> </
test2.xml文件,代表注入user表的2条数据。
<?xml version="1.0" encoding="UTF-8"?>
dataset>
<user
< id="1"
name="KK"
/>dataset> </
test4_validate.xml文件,代表要校验的数据
10 SpringBoot
代码在这里。
SpringBoot的单元测试为替换自定义的Bean提供了很多功能方法和注解,能帮助我们在SpringBoot框架下的测试。它提供的功能很强大,但相对运行性能就比较差了,要谨慎使用。
10.1 MockBean
package spring_test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class ServiceA {
@Autowired
private ServiceB serviceB;
public String get(){
return "A"+serviceB.get();
}
}
定义ServiceA的bean,它依赖着ServiceB
package spring_test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Created by fish on 2021/5/30.
*/
@Component
public class ServiceB {
@Autowired
private ServiceC serviceC;
public String get(){
return "B"+serviceC.get();
}
}
定义ServiceB的bean,它依赖着ServiceC
package spring_test;
import org.springframework.stereotype.Component;
@Component
public class ServiceC {
public String get(){
return "HelloC";
}
public String get2(){
return "MU";
}
}
这是ServiceC的bean
package spring_test;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import spring_test.ServiceA;
import spring_test.ServiceB;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
/**
* Created by fish on 2021/5/30.
*/
@SpringBootTest
public class Test1 {
@MockBean
private ServiceB serviceB;
@Autowired
private ServiceA serviceA;
@Test
public void test1(){
when(serviceB.get()).thenReturn("MM");
assertEquals(serviceA.get(),"AMM");
}
}
这是单元测试1,我们将ServiceB的bean替换成一个mock实现,只需要加一个@MockBean注解就可以了。这一点Mockito框架下的@Mock和@InjectMocks注解也能做到
package spring_test;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.stereotype.Component;
import org.springframework.test.annotation.DirtiesContext;
import spring_test.ServiceA;
import spring_test.ServiceC;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@SpringBootTest
public class Test2 {
@MockBean
private ServiceC serviceC;
@Autowired
private ServiceA serviceA;
@Test
public void test1(){
when(serviceC.get()).thenReturn("KK");
assertEquals(serviceA.get(),"ABKK");
}
}
这是单元测试2,我们将ServiceC的bean替换成一个mock实现,同时ServiceA依赖下的ServiceB依然能工作,只是ServiceB下的依赖ServiceC的bean被替换了。这相当于嵌套bean依赖的替换,这一点Mockito框架下的@Mock和@InjectMocks注解就做不了。
另外,如果我们要测试的Bean,我们想只替换它依赖的一部分bean,另外一部分沿用正常的bean,也只能@SpringBootTest或@JpaTest注解能做到。Mockito只能一次替换它所有依赖的bean。
10.2 SpyBean
package spring_test;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.context.annotation.Import;
import spring_test.util.MyConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@SpringBootTest
public class Test4 {
@SpyBean
private ServiceC serviceC;
@Test
public void test1(){
when(serviceC.get2()).thenReturn("UK");
assertEquals(serviceC.get(),"HelloC");
assertEquals(serviceC.get2(),"UK");
}
}
这是单元测试3,我们希望对ServiceC进行替换操作,但是只替换它的get2方法,它的get方法则继续保留原来的实现,那这个功能就需要使用@SpyBean注解,也只有@SpringBootTest或@JpaTest能实现。
10.3 TestConfiguration
package spring_test;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import spring_test.ServiceA;
import spring_test.util.MyConfig;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@SpringBootTest
@Import(MyConfig.class)
public class Test3 {
@Autowired
private MyConfig.MyServiceC myServiceC;
@Autowired
private ServiceA serviceA;
@Test
public void test1(){
.set("--MM--");
myServiceCassertEquals(serviceA.get(),"AB--MM--");
}
}
我们希望ServiceA依赖的ServiceC实现替换为一个具体的额外实现,而不是mock实现,那么我们就要用到@Import注解。
package spring_test.util;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import spring_test.ServiceC;
@TestConfiguration
public class MyConfig {
public static class MyServiceC extends ServiceC{
private String name;
public void set(String name){
this.name = name;
}
public String get(){
return this.name;
}
}
@Bean
@Primary
public ServiceC getServiceC(){
return new MyServiceC();
}
}
我们定义一个新的配置文件,MyConfig文件,同时它是使用@TestConfiguration注解,避免被默认导入。最后我们定义一个新的@Bean方法来定义bean就可以了,记得要加上@Primary注解,来覆盖原有的bean实现。我们还可以在测试里面用@Autowired来使用我们自定义的ServiceC的bean实现。
10.4 BeanFactory变化
@DataJpaTest(includeFilters = @ComponentScan.Filter(
= FilterType.ASSIGNABLE_TYPE,
type= {CurdRepository.class, QueryRepository.class}
classes ))
@ExtendWith({SnapshotExtension.class})
@Import({MyConfig.class})
@Slf4j
public class CreateOrderPayTest {
private Expect expect;
@Autowired
private BeanFactory beanFactory;
@BeforeEach
public void setUp() {
.setBeanFactory(new DefaultTestIocHelper(beanFactory));
IocHelper}
}
因为有SpyBean,MockBean和TestConfiguration的存在,每个单元测试中同一个类对应的实例化bean可能是不同的。所以,在Spring的单元测试中,不要企图将BeanFactory缓存起来,留待下一个使用。正确的做法是,在需要BeanFactory的地方,都必须保证是由Spring的Autowired注入的,如果要拿一个全局的BeanFactory,那么在每次@BeforeEach里面都要重新取新的BeanFactory。
11 JpaTest
代码在这里
@SpringBoot注解太重了,单元测试太慢,而大部分的单元测试都是围绕着数据库进行的,能不能只加载jpa依赖来进行单元测试,这样测试就快多了。有,这就是@DataJpaTest注解
11.1 Sql
package spring_test;
import lombok.Getter;
import lombok.ToString;
import javax.persistence.Entity;
import javax.persistence.Id;
@Entity
@ToString
@Getter
public class User{
private static Long globalId = 10001L;
@Id
private Long id;
private String name;
protected User(){
}
public User(String name){
this.id = globalId++;
this.name = name;
}
public void mod(String name){
this.name = name;
}
}
定义一个User实体
package spring_test;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Component;
public interface UserRepository extends CrudRepository<User,Long>{
}
定义一个User的仓库
package spring_test;
import java.util.ArrayList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.stereotype.Component;
import org.springframework.test.context.jdbc.Sql;
import java.util.Arrays;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
//可以打开H2数据库进行测试
@DataJpaTest
@Slf4j
public class Test1 {
@Autowired
private UserRepository userRepository;
private List<User> getAll(){
Iterable<User> users = userRepository.findAll();
List result = new ArrayList();
for( User user:users){
.add(user);
result}
return result;
}
//可以用@Sql来指定加载哪个数据库脚本
@Test
@Sql("classpath:/init.sql")
public void go(){
//直接读取,数据量为2个
List<User> users = this.getAll();
assertEquals(users.size(),2);
}
//每个测试都是一个事务中进行,测试完毕后会自动回滚数据。
@Test
@Sql("classpath:/init.sql")
public void go2(){
//删除了其中一条数据,数据量为1条
List<User> users = this.getAll();
.delete(users.get(0));
userRepository
List<User> users2 = this.getAll();
assertEquals(users2.size(),1);
}
@Test
@Sql("classpath:/init.sql")
public void go3(){
//重新读取后,数据量为2条。
List<User> users = this.getAll();
assertEquals(users.size(),2);
List<String> names = users.stream().map((user)->{
return user.getName();
}).sorted().collect(Collectors.toList());
assertIterableEquals(names, Arrays.asList("cat","fish"));
}
}
然后我们就能使用@DataJpaTest和@Sql进行测试了,测试的速度要比@SpringBootTest快得多,init.sql是为了初始化测试代码
insert into user(id,name) values(1,'fish'),(2,'cat');
普通的初始化语句
11.2 默认不加载其他依赖
package spring_test;
import org.springframework.stereotype.Component;
@Component
public class UserService {
public String getDefaultName(){
return "mm";
}
}
我们定义一个UserService的bean,它是有@Component注解的
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.junit.jupiter.api.Assertions.*;
//DataJpaTest的特点是:
//只加载入口类App.java,以及加载实现了CrudRepository接口的bean
//不会加载SpringMVC,入口类所在包的其他@Component类,不会加载Tomcat,所以启动速度明显更快
@DataJpaTest
@Slf4j
public class Test2{
//这个UserService虽然有@Component注解,但是并不进行加载
@Autowired(required = false)
private UserService service;
@Test
public void go(){
//因此这个service是空
assertNull(service);
}
}
但是,单元测试发现这个bean总是为null。因为DataJpaTest的特点是:
- 只加载入口类App.java,以及加载实现了CrudRepository接口的bean
- 不会加载SpringMVC,入口类所在包的其他@Component类,不会加载Tomcat,所以启动速度明显更快
11.3 额外指定其他依赖
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest(includeFilters = @ComponentScan.Filter(
= FilterType.ASSIGNABLE_TYPE,
type= {UserService.class}
classes ))
@Slf4j
public class Test3{
@Autowired
private UserService service;
@Test
public void go(){
//因此这个service是空
assertEquals(service.getDefaultName(),"mm");
}
}
要在@DataJpaTest中指定加入其他依赖,要么是使用includeFilters
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.Import;
import static org.junit.jupiter.api.Assertions.*;
import spring_test.util.MainConfig;
@DataJpaTest
//可以使用导入@TestConfiguration的方式,加入其他的bean
@Import(MainConfig.class)
@Slf4j
public class Test4{
@Autowired
private UserService service;
@Test
public void go(){
//因此这个service是空
assertEquals(service.getDefaultName(),"mm");
}
}
要么使用@Import来加载配置文件,在配置文件中加入其他的bean
package spring_test.util;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import spring_test.User;
@TestConfiguration
//通过ComponentScan的方式加入bean
@ComponentScan(basePackageClasses= User.class)
public class MainConfig {
}
这个配置文件使用@ComponentScan来加载其他的bean
11.4 SpyBean
package spring_test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Component
public class UserHelper {
@Autowired
private UserRepository userRepository;
public List<User> getAll(){
List<User> result = new ArrayList();
for( User user:userRepository.findAll()){
.add(user);
result}
return result;
}
public Optional<User> getSingle(Long id){
throw new RuntimeException("cc");
}
}
定义一个UserHelper
package spring_test;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import java.util.Arrays;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@DataJpaTest(includeFilters = @ComponentScan.Filter(
= FilterType.ASSIGNABLE_TYPE,
type= {UserHelper.class}
classes ))
@Slf4j
public class Test5{
@SpyBean
private UserHelper userHelper;
@Test
public void go(){
//mock特定的接口和参数
<User> mockResult = Optional.of(new User("bb"));
Optional<User> mockResult2 = Optional.of(new User("cc"));
OptionaldoReturn(mockResult).when(userHelper).getSingle(1L);
doReturn(mockResult2).when(userHelper).getSingle(2L);
assertEquals(userHelper.getSingle(1L),mockResult);
assertEquals(userHelper.getSingle(2L),mockResult2);
//其他的接口和参数走原来的逻辑
assertThrows(RuntimeException.class,()->{
.getSingle(3L);
userHelper});
assertEquals(userHelper.getAll(), Arrays.asList());
}
}
使用@SpyBean来mock掉它特定的接口
12 IDEA配置
12.1 全局Profile
对于单元测试项目,我们都希望触发使用SpringBoot里面test的Profile。一种方法是在类上面使用@ActiveProfiles注解,但是要在所有类上面都添加,比较麻烦。
另外一种方法,在IDEA的Run Configurations里面配置Environment Variables。配置环境变量为SPRING_PROFILES_ACTIVE=test就可以了。
12.2 测试资源文件夹
在项目文件夹选择Open Module Settings。
在文件夹中选择test文件夹下的某个文件夹,标记为Test Resources。那么test文件夹下的xml文件就会标记为资源文件,自动放在classpath文件夹里面了
就像这样的用法
12.2 覆盖率
在IDEA的Run Configurations里面配置要统计的代码覆盖率的类和包
启动的时候,选择with Coverage就可以了
这是测试覆盖率报告
13 快照测试
13.1 依赖
代码在这里
dependency>
<groupId>io.github.origin-energy</groupId>
<artifactId>java-snapshot-testing-junit5</artifactId>
<version>3.2.5</version>
<dependency>
</dependency>
<groupId>io.github.origin-energy</groupId>
<artifactId>java-snapshot-testing-plugin-jackson</artifactId>
<version>3.2.5</version>
<dependency> </
加入依赖
13.2 配置与测试代码
serializer=au.com.origin.snapshots.serializers.ToStringSnapshotSerializer
serializer.base64=au.com.origin.snapshots.serializers.Base64SnapshotSerializer
serializer.json=au.com.origin.snapshots.serializers.JacksonSnapshotSerializer
serializer.orderedJson=au.com.origin.snapshots.serializers.DeterministicJacksonSnapshotSerializer
comparator=au.com.origin.snapshots.comparators.PlainTextEqualsComparator
reporters=au.com.origin.snapshots.reporters.PlainTextSnapshotReporter
snapshot-dir=__snapshots__
output-dir=src/test/java
ci-env-var=CI
在resources文件夹加入snapshot.properties文件夹即可
package spring_test;
import au.com.origin.snapshots.junit5.SnapshotExtension;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import au.com.origin.snapshots.Expect;
/**
* Created by fish on 2021/5/30.
*/
@SpringBootTest
@Import({JacksonAutoConfiguration.class})
@ExtendWith({SnapshotExtension.class})
public class Test1 {
private Expect expect;
@Autowired
private ObjectMapper objectMapper;
public String toString(Object data)throws Exception{
return objectMapper.writeValueAsString(data);
}
//字符串测试,单用例下的多测试
@Test
public void test1() throws Exception{
= new OrderDO();
OrderDO orderDO
.setSize(1);
orderDO.setName("fish");
orderDO.scenario("1").toMatchSnapshot(toString(orderDO));
expect
= new OrderDO();
OrderDO orderDO2
.setSize(2);
orderDO2.setName("dog");
orderDO2
.scenario("2").toMatchSnapshot(toString(orderDO2));
expect
}
//json测试,就是指定一个序列化器而已,没有改变diff的方式
@Test
public void test2() throws Exception{
= new OrderDO();
OrderDO orderDO
.setSize(1);
orderDO.setName("fish");
orderDO.scenario("1").serializer("json").toMatchSnapshot(orderDO);
expect
= new OrderDO();
OrderDO orderDO2
.setSize(2);
orderDO2.setName("dog");
orderDO2
.scenario("2").serializer("json").toMatchSnapshot(orderDO2);
expect
}
}
单元测试,相当的简单暴力
单元测试运行以后,会生成.snap文件,如果下次测试不通过的话,会产生.debug文件进行对照
13.3 回归测试与结果对照
在下次新增代码以后,重新运行单元测试,必然会产生测试失败的报告,因为测试数据新增或者修改了。
这个时候如果查看.debug文件或者console控制台查看错误的地方,有点麻烦
解决办法是,先将原来的测试.snap文件删除,然后重新运行单元测试,就会生成新的.snap文件,最后我们将其加入到git中
打开VSCode,点击第三个图标,查看当前文件与上次提交时候的改动。我们就能轻松看到新旧输出的差异了。
15 总结
收获最大的地方是:
- 好的代码应该是同一抽象层上的描述,而不是一个函数将高抽象,到低抽象全部捅到底。P89,这个收获很棒,提醒了写代码要放在同一层次的抽象的编排。
- 测试不是追求按位断言,而是用断言你想检查的部分字段,以测试是否满足你的原来的期望。P50。这个也不错,过度断言会导致,对某个结构体添加一个字段时,一大堆用例就会失败,那感觉太糟糕了。
- 一个测试应该只有一个失败的原因,也应该只包含一个场景。P48,过度断言就违反了这个原则
- 本文作者: fishedee
- 版权声明: 本博客所有文章均采用 CC BY-NC-SA 3.0 CN 许可协议,转载必须注明出处!