单元测试:
1.为什么要写单元测试
Mock的深入学习:https://blog.csdn.net/fortunatelx/article/details/82414668
编写单元测试的难易程度能够直接反应出代码的设计水平,能写出单元测试和写不出单元测试之间体现了编程能力上的巨大的鸿沟。无论是什么样的程序员,坚持编写一段时间的单元测试之后,都会明显感受到代码设计能力的巨大提升。
2.添加依赖
<dependencies>
<!-- Mandatory dependencies for using Spock -->
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.0-groovy-2.4</version>
<scope>test</scope>
</dependency>
<!-- Optional dependencies for using Spock -->
<dependency> <!-- use a specific Groovy version rather than the one specified by spock-core -->
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.4.3</version>
</dependency>
<dependency> <!-- enables mocking of classes (in addition to interfaces) -->
<groupId>cglib</groupId>
<artifactId>cglib-nodep</artifactId>
<version>3.1</version>
<scope>test</scope>
</dependency>
<dependency><!-- enables mocking of classes without default constructor (together with CGLIB) -->
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.1</version>
<scope>test</scope>
</dependency>
</dependencies>
写一个最简单的service:
package com.wx.service;
public class AddService {
public int sum(int first, int second) {
return first + second;
}
}
写上测试脚本:
package com.wx.service
import spock.lang.Specification
class AddServiceTest extends Specification {
def addService = new AddService();
def "addService should return param1+param2"() {
expect:
addService.sum(1, 1) == 2
}
}
然后运行一下测试代码:
3.概念的理解
1.Specification
在Spock中,待测系统(system under test; SUT) 的行为是由规格(specification) 所定义的。在使用Spock框架编写测试时,测试类需要继承自Specification类。
2.Fields
Specification类中可以定义字段,这些字段在运行每个测试方法前会被重新初始化,跟放在setup()里是一个效果。
def obj = new ClassUnderSpecification()
def coll = new Collaborator()
3.Fixture Methods
def setup() {} // run before every feature method
def cleanup() {} // run after every feature method
def setupSpec() {} // run before the first feature method
def cleanupSpec() {} // run after the last feature method
4.Feature methods
这是Spock规格(Specification)的核心,其描述了SUT应具备的各项行为。每个Specification都会包含一组相关的Feature methods,如要测试1+1是否等于2,可以编写一个函数:
def "sum should return param1+param2"() {
expect:
sum.sum(1,1) == 2
}
5.blocks
每个feature method又被划分为不同的block,不同的block处于测试执行的不同阶段,在测试运行时,各个block按照不同的顺序和规则被执行,如下图:
下面分别解释一下各个block的用途。
Setup Blocks:
setup也可以写成given,在这个block中会放置与这个测试函数相关的初始化程序,如:一般会在这个block中定义局部变量,定义mock函数等。
setup:
def stack = new Stack()
def elem = "push me"
When and Then Blocks:when与then需要搭配使用,在when中执行待测试的函数,在then中判断是否符合预期,如:
when:
stack.push(elem)
then:
!stack.empty
stack.size() == 1
stack.peek() == elem
断言:
条件类似junit中的assert,就像上面的例子,在then或expect中会默认assert所有返回值是boolean型的顶级语句。如果要在其它地方增加断言,需要显式增加assert关键字,如:
def setup() {
stack = new Stack()
assert stack.empty
}
异常断言,如果要验证有没有抛出异常,可以用thrown(),如下:
when:
stack.pop()
then:
thrown(EmptyStackException)
stack.empty
要获取抛出的异常对象,可以用以下语法:
when:
stack.pop()
then:
def e = thrown(EmptyStackException)
e.cause == null
如果要验证没有抛出某种异常,可以用notThrown():
def "HashMap accepts null key"() {
setup:
def map = new HashMap()
when:
map.put(null, "elem")
then:
notThrown(NullPointerException)
}
Expect Blocks
Cleanup Blocks函数退出前做一些清理工作,如关闭资源等。
Where Blocks:
做测试时最复杂的事情之一就是准备测试数据,尤其是要测试边界条件、测试异常分支等,这些都需要在测试之前规划好数据。但是传统的测试框架很难轻松的制造数据,要么依赖反复调用,要么用xml或者data provider函数之类难以理解和阅读的方式。比如说:
上述例子实际会跑三次测试,相当于在for循环中执行三次测试,a/b/c的值分别为3/5/5,7/0/7和0/0/0。如果在方法前声明@Unroll,则会当成三个方法运行。
更进一步,可以为标记@Unroll的方法声明动态的spec名:
class DataDriven extends Specification {
@Unroll
def "maximum of #a and #b should be #c"() {
expect:
Math.max(a, b) == c
where:
a | b || c
3 | 5 || 5
7 | 0 || 7
0 | 0 || 0
}
}
运行时,名称会被替换为实际的参数值。
除此之外,where block还有两种数据定义的方法,并且可以结合使用,如:
where:
a | _
3 | _
7 | _
0 | _
b << [5, 0, 0]
c = a > b ? a : b
4.记一次增删查改的编写
1.引入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<groupId>io.choerodon</groupId>
<artifactId>test-service</artifactId>
<version>1.0.0</version>
<!--choerodon-framework-parent dependency-->
<parent>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-framework-parent</artifactId>
<version>0.11.0.RELEASE</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<!--choerodon-starters dependency-->
<properties>
<choerodon.starters.version>0.11.1.RELEASE</choerodon.starters.version>
<choerodon.serviceBuild>true</choerodon.serviceBuild>
<choerodon.mainClass>io.choerodon.todo.TodoServiceApplication</choerodon.mainClass>
</properties>
<dependencies>
<!--spring boot-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 允许注册到注册中心时,添加此依赖-->
<!--spring cloud-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!--choerodon-->
<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-starter-core</artifactId>
<version>${choerodon.starters.version}</version>
</dependency>
<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-starter-oauth-resource</artifactId>
<version>${choerodon.starters.version}</version>
</dependency>
<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-starter-swagger</artifactId>
<version>${choerodon.starters.version}</version>
</dependency>
<!-- 配置文件中添加数据库配置后,添加以下依赖 -->
<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-starter-mybatis</artifactId>
<version>${choerodon.starters.version}</version>
</dependency>
<!--other dependencies-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- 添加cpu监控 -->
<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-starter-metric</artifactId>
<version>${choerodon.starters.version}</version>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-liquibase</artifactId>
<version>${choerodon.starters.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.197</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.1-groovy-2.4-rc-2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<version>1.1-groovy-2.4-rc-3</version>
<scope>test</scope>
</dependency>
<!--项目数据库配置-->
<!--<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-starter-mybatis</artifactId>
<version>${choerodon.starters.version}</version>
</dependency>-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-starter-actuator</artifactId>
<version>${choerodon.starters.version}</version>
</dependency>
<!--saga依赖-->
<dependency>
<groupId>io.choerodon</groupId>
<artifactId>choerodon-starter-asgard</artifactId>
<version>${choerodon.starters.version}</version>
</dependency>
</dependencies>
<build>
<finalName>test-service</finalName>
</build>
</project>
添加测试yml:
application.yml
spring:
profiles:
active: test
application-test.yml
spring:
cloud:
bus:
enabled: false
sleuth:
stream:
enabled: false
datasource:
password: sa
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=Mysql;TRACE_LEVEL_SYSTEM_OUT=2;
username: sa
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration
- org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
- org.springframework.cloud.client.discovery.simple.SimpleDiscoveryClientAutoConfiguration
- org.springframework.cloud.client.discovery.composite.CompositeDiscoveryClientAutoConfiguration
- org.springframework.cloud.client.discovery.noop.NoopDiscoveryClientAutoConfiguration
h2:
console:
enabled: true
data:
dir: src/main/resources
eureka:
client:
enabled: false
logging:
level:
io.choerodon.iam: info
配置数据库:
package com.wx.testsercice.app.service.impl
import io.choerodon.liquibase.LiquibaseConfig
import io.choerodon.liquibase.LiquibaseExecutor
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Import
import javax.annotation.PostConstruct
/**
* @author dongfan117@gmail.com
*/
@TestConfiguration
@Import(LiquibaseConfig)
class IntegrationTestConfiguration {
@Autowired
LiquibaseExecutor liquibaseExecutor
@PostConstruct
void init() {
liquibaseExecutor.execute()
}
}
测试编写:
package com.wx.testsercice.api.controller.v1
import com.wx.testsercice.app.service.impl.IntegrationTestConfiguration
import com.wx.testsercice.infra.dto.TaskDTO
import com.wx.testsercice.infra.mapper.TaskMapper
// 加入这段代码
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.context.annotation.Import
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod
import org.springframework.http.ResponseEntity
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.Stepwise
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(IntegrationTestConfiguration)
@Stepwise
class TaskControllerTest extends Specification {
private static String BASE_PATH = "/v1/tasks"
@Autowired
private TestRestTemplate restTemplate
@Autowired
private TaskMapper taskMapper
@Shared
def taskDTOS = new ArrayList<TaskDTO>()
@Shared
def needInit = true
def count = 5
def recount = 0
@Shared
def needClear = false
def setup() {
if (needInit) {
given: "构造参数"
needInit = false
for (int i = 0; i < count; i++) {
TaskDTO taskDTO = new TaskDTO()
taskDTO.setEmployeeId(1)
taskDTO.setState("F/18" + i)
taskDTO.setTaskDescription("Good" + i)
taskDTO.setTaskNumber("123" + i)
taskDTOS.add(taskDTO)
}
when: "插入数据"
for (int i = 0; i < count; i++) {
def insert = taskMapper.insert(taskDTOS.get(i))
recount = +insert
}
then: "校验参数"
recount == count
}
}
def cleanup() {
if (needClear) {
when: "调用方法"
needClear = false
def count = 0
for (TaskDTO taskDTO : taskDTOS) {
count += taskMapper.deleteByPrimaryKey(taskDTO)
}
then: "校验结果"
count == 3
}
}
def "Create"() {
given: "构造请求参数"
TaskDTO taskDTO = new TaskDTO()
taskDTO.setEmployeeId(1)
taskDTO.setState("F/18")
taskDTO.setTaskDescription("壮志凌云")
taskDTO.setTaskNumber("熊猫")
when: "调用方法"
def entity = restTemplate.postForEntity(BASE_PATH, taskDTO, TaskDTO)
then: "校验参数"
entity.statusCode.is2xxSuccessful()
entity.getBody().getTaskDescription().equals(taskDTO.getTaskDescription())
entity.getBody().getState().equals(taskDTO.getState())
entity.getBody().getTaskNumber().equals(taskDTO.getTaskNumber())
entity.getBody().getEmployeeId().equals(taskDTO.getEmployeeId())
}
def "QueryById"() {
when: "调用对应方法"
def entity = restTemplate.getForEntity(BASE_PATH + "/{id}", TaskDTO, 1L)
//needClear = true
then: "校验结果"
entity.statusCode.is2xxSuccessful()
}
def "QueryByTaskNumber"() {
when: "调用对应方法"
def entity = restTemplate.getForEntity(BASE_PATH + "/taskNumber/{taskNumber}", TaskDTO, taskDTOS.get(2).getTaskNumber())
//needClear = true
then: "校验结果"
entity.statusCode.is2xxSuccessful()
}
//更新的话需要设置版本号为1
def "Update"() {
given: "构造请求参数"
def taskDTO = taskDTOS.get(1)
taskDTO .setObjectVersionNumber(1L)
taskDTO.setTaskDescription("壮志凌云2")
when: "调用方法"
restTemplate.put(BASE_PATH + "/{id}", taskDTO, taskDTO.getId())
def entity = restTemplate.getForEntity(BASE_PATH + "/{id}", TaskDTO, taskDTO.getId())
then: "校验结果"
entity.statusCode.is2xxSuccessful()
entity.getBody().getTaskDescription().equals(taskDTO.getTaskDescription())
}
def "Delete"() {
given: "构造请求参数"
def httpEntity = new HttpEntity<Object>()
when: "调用对应方法"
def entity = restTemplate.exchange(BASE_PATH + "/{id}", HttpMethod.DELETE, httpEntity, String, taskDTOS.get(0).getId())
needClear = true
then: "校验结果"
entity.statusCode.is2xxSuccessful()
}
}
测试结果:
Mock
测试目标类初始化的方式有两种
- @RunWith(MockitoJUnitRunner.class) 换成@RunWith(PowerMockRunner.class)也可以支持这些注解。
- 被测试类上标记@InjectMocks,Mockito就会实例化该类,并将标记@Mock、@Spy注解的属性值注入到被测试类中。
- 注意@InjectMocks的注入顺序:
如果这里的TargetClass中没有显示定义构造方法,Mockito会调用默认构造函数实例化对象,然后依次寻找setter 方法 或 属性(按Mock对象的类型或名称匹配)注入@Mock对象;
如果TargetClass中显式定义了有参数的构造函数,那么 就不再寻找setter 方法和 属性注入, Mockito会选择参数个数最多的构造函数实例化并注入@Mock对象(这样可以尽可能注入多的属性);
但是有多个最大构造函数时,Mockito 究竟选择哪一个就混乱了,测试时应该避免这种情况的发生,很容易发生空指针。
- Mock发生的时机:当以下语法出现时,Mock就发生了,此时称作设置测试桩(Stubbing)
如果方法有返回值:
对象= mock (类名.class);
when (对象.方法 (参数)).thenReturn (方法的返回值);
when(mockMapper.insert(any())).thenReturn(888);
when(mockMapper.insert(any())).thenThrow(new RuntimeException("db操作异常"));
when(mockService.methodReturnId(any(OrderInfo.class))).thenAnswer(demoAnswer);
doReturn(888).when(mockMapper).insert(any());
如果方法没有返回值:
doNothing().when(mockService).voidMethod(any(String.class), any(Integer.class));
doThrow(new RuntimeException("")).when(mockService).voidMethod(eq("ex"), eq(10001));
doAnswer(demoAnswer).when(mockService).methodVoid(any(OrderInfo.class));
- Stubbing连缀调用
第一次调用返回1,第二次调用返回2,以下三种写法等价的:
when(mockService.addStr(anyString())).thenReturn("1").thenReturn("2");
when(mockService.addStr(anyString())).thenReturn("1", "2");
doReturn("1").doReturn("2").when(mockService).addStr(anyString());
String relt1 = mockService.addStr("x");
String relt2 = mockService.addStr("x");
String relt3 = mockService.addStr("x");
Assert.assertEquals("1", relt1);
Assert.assertEquals("2", relt2);
Assert.assertEquals("2", relt3);//后续调用一直返回2
第一次调用什么也不做,第二次调用抛出异常:
doNothing().doThrow(new RuntimeException("调用两次了")).when(mockService).methodVoid(any());
mockService.methodVoid(any());
try {
mockService.methodVoid(any());
} catch (Exception e) {
Assert.assertEquals("调用两次了", e.getMessage());
}
下面写法结果就变了,第二次stubbing覆盖第一次的:
when(mockService.addStr(anyString())).thenReturn("1");
when(mockService.addStr(anyString())).thenReturn("2");
String relt1 = mockService.addStr("x");
String relt2 = mockService.addStr("x");
Assert.assertEquals("2", relt1);
Assert.assertEquals("2", relt2);
参考博客:
http://blog.2baxb.me/archives/1398