本来打算是把Spock的使用写成一篇的,后来发现太长了而且结构比较冗杂,还是拆分出来比较好,而且这样也比较好检索。初次使用,如果有误,请轻喷,并指出问题。我会及时纠正。
要使用Spock,必须要有Junit和Mock的基本概念和使用。建议在使用Spock之前先懂了解一下Junit和Mockitio
上一篇博客:《SpringBoot+Spock的熟悉之路(二):Spock,Mock和Mockitio的关系》
下一篇博客:《SpringBoot+Spock的熟悉之路(四):用Spock+H2对SpringBoot进行集成测试》
一定要注意版本的问题!一定要注意版本的问题!一定要注意版本的问题!
Tool | Version |
---|---|
Intellij IDEA | 2018.3 Ultimate |
SpringBoot | 2.0.1 |
Java | 1.8 |
mybatis-spring-boot | 2.0.1 |
Groovy | 2.4.6 |
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.1</version>
</dependency>
<!--服务器配置的是Oracle-->
<dependency>
<groupId>com.oracle</groupId>
<artifactId>ojdbc8</artifactId>
<version>12.2.0.1</version>
</dependency>
<!---------------Spock必须的------------------->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.3-groovy-2.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<version>1.3-RC1-groovy-2.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.4.6</version>
</dependency>
<!---------- 让Spock支持类似于@InjectMocks的功能 ------>
<dependency>
<groupId>com.blogspot.toomuchcoding</groupId>
<artifactId>spock-subjects-collaborators-extension</artifactId>
<version>1.2.2</version>
<scope>test</scope>
</dependency>
<!----------- @MybatisTest的依赖 ------------>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<!-------------- 可选项 ----------------->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency><!--方便MockMvc的时候模拟数据--->
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.51</version>
</dependency>
<dependency> <!-- 允许MockInterface等 -->
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.9.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.6</version>
<scope>test</scope>
</dependency>
<!-------用于在测试Controller时进行请求参数的拼接----------->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.51</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
<!-------为了能让项目识别资源文件--------->
<resources>
<resource>
<directory>src/main/resources</directory>
<includes>
<include>**/*.properties</include>
<include>**/*.xml</include>
<include>**/*.sql</include>
</includes>
<filtering>false</filtering>
</resource>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.properties</include>
<include>**/*.xml</include>
<include>**/*.sql</include>
</includes>
<filtering>false</filtering>
</resource>
</resources>
</build>
首先要搞清楚什么是单元测试。
平时我们会用postman,或者一些API文档。现在文本框里把数据填好,然后启动项目,最后点一下“运行”,最后看一下数据库里的数据是否正常,或者看select的返回值是否符合预期。
这种从Controller到Service到Dao层的运行个遍,叫集成测试。就算是只从Service到Dao层,那也算是部分集成测试。这种测试方法是需要启动整个,或者说大部分SpringBoot容器的,那么这个会在我的下一篇专门讲集成测试的博客里说,只需要一个@SpringBootTest注解就可以完成,集成测试甚至不应该出现Mock。
而单元测试相反,测试Controller层的时候,只需要启动MVC相关的配置,不需要启动数据库连接;测试Service层的时候,既不需要启动数据库连接,也不需要启动web容器;测试Dao层的时候,就应该只关心Sql语句的逻辑,不应该去关心Controller和Service层的逻辑,所以单元测试的时候需要大量的Mock和Stub。
虽然这种想法有点激进,那就是单元测试就不应该出现@SpringBootTest
注解。
之前曾说过,Spock不支持@InjectMocks和Mocks的组合,因此需要先引入Mockitio专门Spock开发的第三方插件
在Maven下面添加依赖
<dependency>
<groupId>com.blogspot.toomuchcoding</groupId>
<artifactId>spock-subjects-collaborators-extension</artifactId>
<version>1.2.2</version>
<scope>test</scope>
</dependency>
关于配置这块,test包会优先找自己的resources下面的配置文件,找不到再去main中去寻找。这一点同样适用于Application启动类。基于单元测试要尽量简单的思路,test下的启动类不应该包含类似于@MapperScan,@Component 等要去寻找其他资源的注解,不然就算指定了要测试的Controller,因为要启动Controller对应的环境,SpringBoot会去启动类里加载部分资源,这个时候会报xxxxRepository找不到的问题
同时为了尽量不污染main中的配置文件,我们最好在test包里的resourecs添加我们测试的配置文件
大概样子类似于这样,顺便模拟了一下多个运行环境的不同的配置
application.properties文件里的内容如下
server.port= 9090
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.datasource.url=jdbc:oracle:thin:@//********:***/*****
spring.datasource.username=********
spring.datasource.password=********
# MyBatis配置
mybatis.config-location=classpath:mybatis.xml
另外两个配置文件目前暂时为空
package com.example.demo.base;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.client.RestTemplate;
public abstract class BaseController {
@Autowired
protected RestTemplate restTemplate;
}
package com.example.demo.base;
import com.example.demo.repository.BaseRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import javax.annotation.PostConstruct;
import java.util.List;
public abstract class BaseService {
//加一个init方法,模拟我们容器完全启动之前会去数据库里查询字典值等额外数据
@PostConstruct
public List<String> getDictionary(){
return baseRepository.getDictionary();
}
@Autowired
protected RedisTemplate<String, String> redisTemplate;
@Autowired
private BaseRepository baseRepository;
}
package com.example.demo.repository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface BaseRepository {
List<String> getDictionary();
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.repository.BaseRepository">
<select id="getDictionary" resultType="java.lang.String">
SELECT
DICTONARY
FROM
DEMO_DICTONARY
</select>
</mapper>
package com.example.demo.controller;
import com.example.demo.base.BaseController;
import com.example.demo.entity.DemoEntity;
import com.example.demo.service.DemoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/demo")
public class DemoController extends BaseController {
@GetMapping(path = "", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public DemoEntity getDemo(Integer demoId) {
return demoService.getDemo(demoId);
}
@Autowired
private DemoService demoService;
}
package com.example.demo.service;
import com.example.demo.entity.DemoEntity;
public interface DemoService {
DemoEntity getDemo(Integer demoId);
}
package com.example.demo.service.impl;
import com.example.demo.entity.DemoEntity;
import com.example.demo.repository.DemoRepository;
import com.example.demo.service.DemoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class DemoServiceImpl extends BaseService implements DemoService {
@Autowired
private DemoRepository demoRepository;
@Override
public DemoEntity getDemo(Integer demoId) {
return demoRepository.getDemo(demoId);
}
}
package com.example.demo.repository;
import com.example.demo.entity.DemoEntity;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface DemoRepository {
DemoEntity getDemo(@Param("demoId") Integer demoId);
Integer createDemo(@Param("param")DemoEntity demoEntity);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.repository.DemoRepository">
<select id="getDemo" resultType="com.example.demo.entity.DemoEntity">
SELECT
DEMO_ID demoId,
DEMO_STR demoStr
FROM
DEMO
WHERE
DEMO_ID = #{
demoId,jdbcType=NUMERIC}
</select>
<insert id="createDemo">
INSERT INTO
DEMO(
DEMO_ID,
DEMO_STR
)
VALUES(
#{
param.demoId,jdbcType=NUMERIC},
#{
param.demoStr,jdbcType=VARCHAR}
)
</insert>
</mapper>
Controller层的测试采用SpringBoot的注解@WebMvcTest
使用此注解后,测试类将会只启动和Controller有关的配置。注意,该注解只能用来测试SpringMvc写出来的Controller
先看一个简单的例子
package com.example.demo.controller
import com.alibaba.fastjson.JSONObject
import com.example.demo.service.DemoService
import org.mybatis.spring.boot.test.autoconfigure.AutoConfigureMybatis
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import spock.lang.Specification
import spock.mock.DetachedMockFactory
@WebMvcTest(controllers = [DemoController.class])
@ActiveProfiles("qa")
@AutoConfigureMybatis
class DemoControllerSpec extends Specification {
@Autowired
MockMvc mockMvc
@Autowired
DemoService demoService
def setup(){
demoService.getDemo(_ as Integer) >> 2
}
def "mvc test"(){
given:
def demoId = 1
expect:
mockMvc.perform(MockMvcRequestBuilders.get("/demo").
contentType(MediaType.APPLICATION_JSON_UTF8).content(JSONObject.toJSONString(demoId)))
.andExpect(MockMvcResultMatchers.status().isOk())
}
@TestConfiguration
static class MockConfig{
def detachedMockFactory = new DetachedMockFactory()
@Bean
DemoService demoService(){
return detachedMockFactory.Mock(DemoService)
}
}
}
关于这个@TestConfiguration
注解,是Spock官方建议的,在WebMvcTest的情况下使用,解决在声明变量的时候使用Mock()
无效的问题。
而@AutoConfigureMybatis
的注解是自动去装配SqlSessionFactory。
执行完毕后可以看到,且控制台没有打印我们初始化方法查找字典值的方法。节省加载初始化资源的时间。
严格意义上讲,Service也算是一种特殊的@Component
。其单测思维都近乎一样。
正常情况下,Service层应该是纯代码业务逻辑,对数据库或者web容器都没有启动依赖。
先看一下简单的例子
package com.example.demo.service.impl
import com.blogspot.toomuchcoding.spock.subjcollabs.Collaborator
import com.blogspot.toomuchcoding.spock.subjcollabs.Subject
import com.example.demo.repository.DemoRepository
import spock.lang.Specification
class DemoServiceSpec extends Specification {
@Collaborator
DemoRepository demoRepository = Mock(DemoRepository){
getDemo(_ as Integer) >> 6
}
@Subject
DemoServiceImpl demoService
def "service test"(){
expect:
demoService.getDemo(1) == null
}
}
运行后结果如下,可以看出来完全没有启动Spring容器,略过了冗长的自动装配资源
上述代码中的@Subject
和@Collaborator
组合是对照的Mockitio中的@InjectMocks
和@Mock
@Subject
注解放在实体类(可以看到我是直接用Impl去声明Service的)上,@Collaborator
则放在该实体类里的一些要Mock的成员变量上。尤其是我们加了@Autowired
注解的成员变量,理论上是必须要进行赋值的。
如果我们把两个注解给去掉,比如直接这么写:
DemoRepository demoRepository = Mock(DemoRepository){
getDemo(_ as Integer) >> 6
}
DemoService demoService = new DemoServiceImpl()
控制台会报
意思很明显,因为Service中的Repository是null,其执行方法的时候自然会抛空指针异常
Service层的复杂度可能主要还是在于怎么写测试业务上,相关依赖比较少,当然如果要测试redis或者中间件等其他对象,这个应该不属于单元测试的范畴,需要测试请加上相关的配置类,也可以在测试启动类里面注入@Bean
由于我用的是Mybatis,所以我目前只会说@MybatisTest注解相关。@DataJpaTest可能以后用到的时候才会补充。同时为了不用去启动真实的数据库,我采用H2DataBase内存数据库进行测试。
@MybatisTest的使用需要引入额外依赖,h2database同样也是
在Mybatis的一篇官方文档中有提到@MybatisTest详细信息和用法,我这里仅列出部分用到的
一定要注意版本的对应
由于目前我们的SpringBoot的版本是2.0,抱着尝试的心态去试了一下,发现2.0还可以用,所以暂时就先用2.0的
而我们使用此注解的最大的一个作用就是自动注册SqlSessionFactory
在测试类上面加上了这个注解,我们就可以直接@Autowired
我们的dao,该注解类也不会去启动SpringBoot的其他配置,就只启动和数据库相关的配置。
<!-- H2DataBase的依赖 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.197</version>
</dependency>
<!-- @MybatisTest的依赖 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
server.port=8080
#************H2 Begin****************
#db schema
#初始化数据库中的数据,可以没有
spring.datasource.schema=classpath:db/schema.sql
#remote visit
spring.h2.console.settings.web-allow-others=true
#console url
spring.h2.console.path=/h2-console
#default true
spring.h2.console.enabled=true
spring.h2.console.settings.trace=true
#DATABASE_TO_UPPER的意思是,让数据库对大小写敏感,DB_CLOSE_DELAY=-1的意思是让数据库和JVM同生命周期
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;MODE=Oracle
spring.datasource.driver-class-name=org.h2.Driver
#default sa
spring.datasource.username=sa
#default null
spring.datasource.password=
#************H2 End****************
schema.sql
CREATE TABLE DEMO(
DEMO_ID NUMBER PRIMARY KEY NOT NULL,
DEMO_STR VARCHAR2(100)
);
先给出官方文档
本篇重点看的是官方文档中的Feature部分
按照文档的说明,H2数据库有三种模式:
1. 内存模式
2. 嵌入式模式
3. 服务器模式
嵌入式和内存模式都不需要我们去官网上把数据库服务像mysql或者oracle那样下载到本地配置起来。直接引入依赖然后添加好配置文件即可。
本篇不会讨论服务器模式,团队里面的单测本来就应该是自己测自己的,互不干扰的,数据库这块也没必要专门为单测搭一个h2数据库让所有人都去访问。
内存数据库的声明周期由第一个sql连接创建开始,默认的是最后一个sql连接关闭的时候,就会将整个数据库的信息清楚,也可以通过在sql连接字符串上加参数来规定它与JVM同生命周期。正常情况下我们的SpringBootTest在启动的时候会单独开一个虚拟机,所有测试用例执行完毕后会自动关闭虚拟机,这个时候 会将内存数据库中的所有数据(包括表结构)给清除,因此使用内存数据库来进行单测的时候,数据初始化脚本是必要的
嵌入式模式,可以将数据保存在本地的指定位置,在数据连接字符串中加一些参数就可以让嵌入式模式下的H2在虚拟机关闭后依旧保存数据,再次启动的时候就不需要再对表结构进行初始化。
由于SpringbBootTest的特性,在所有的测试用例结束后会回滚之前所有的操作,也无法通过第三方桌面管理工具去查看里面的数据在某一步操作之后具体如何,因此采用嵌入式模式的意义不太大。
嵌入式模式我在下一篇集成测试的时候再讲。
package com.example.demo.repository
import com.example.demo.entity.DemoEntity
import org.mybatis.spring.annotation.MapperScan
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.annotation.Rollback
import org.springframework.test.context.ActiveProfiles
import spock.lang.Specification
@ActiveProfiles("qa")
@MybatisTest
@Rollback(false)
class DemoRepositorySpec extends Specification {
@Autowired
DemoRepository demoRepository
def "create entity test"(){
given:
DemoEntity demoEntity = new DemoEntity()
demoEntity.demoId = demoId
demoEntity.demoStr = demoStr
expect:
demoRepository.createDemo(demoEntity) == 1
where:
demoId | demoStr
1 | "str1"
2 | "str2"
}
def "get entity test"(){
expect:
demoRepository.getDemo(1) != null
}
}
运行后结果为
同时查了启动日志,发现sql语句从这里开始,并没有打印我们一开始要查询的数据字典的那条sql,证明并没有启动整个SpringBoot容器
针对上面代码说一些说明
@ActiveProfiles("qa")
注解的作用:之前可以看到我们resources下面有两种配置文件,application.properites(以下简称主配置文件)里面我们配置的是Oracle数据库,而加了这个注解后,可以指定SpringBoot在启动的时候去寻找后标为“qa”的配置文件,如果“qa”配置文件里配了和主配置文件里相同的属性,则进行覆盖,否则沿用主配置文件的。
关于@Rollback(false)
注解:正常情况下,SpringBoot的测试用例每执行完一个就会回滚一次,比如说再执行完create entity test之后会立马将刚刚insert进去的数据清除,之后执行“get entity test”的时候应该是测试用例不通过的。加了这个注解后,就可以指定某个测试方法,或者某个测试类的执行结果不进行回滚。
注意,即使加了这个注解,在所有单元测试完成之后,SpringBootTest依旧会将所有测试数据进行回滚。我目前没有找到不让他回滚的办法,如果有知道的朋友,麻烦赐教一下,谢谢。
如果将上面代码的@Rollback
注解去掉,再执行测试用例,会有如下结果
本模块主要是把自己踩过的一些比较大的坑给单独列举出来
这种情况发生在我采用Spock自带的Mock方法时出现的错误
@Collaborator
DemoService demoService = Mock()
@Subject
DemoController demoController
后来经过查证,得知在@WebMvcTest
的情况下,直接Mock()会出现失效的问题。官方给出的建议是采用@TestConfiguration
加@Autowired
的组合。更多详情和例子请参考官方使用说明
在配置maven的pom.xml的时候,看看是不是没有配置相关的资源过滤条件
如果是采用了SpringCloud或者其他微服务的朋友,数据库连接配置可能放在配置中心或者其他地方,这种时候需要打开你本地的注册中心和配置中心。
如果还是找不到,看看是不是没有加@ActiveProfiles()
注解。还有一点就是,test会优先查找自己目录下的配置文件,没有再去找main中的。项目结构不完全匹配也有可能导致这个问题。
如果test报的项目结构和man中的不匹配,可能会出现明明地址写对了,mockMvc却报404,或者说mockMvc无法装配。
这种情况下**可以(也只能)**在setup方法中指定mockMvc对应的类
def setup(){
mockMvc = MockMvcBuilders.standaloneSetup(DemoController.class).build()
}
当项目结构不完全匹配的时候,我遇到过这样一个问题,就是我明明测试的是Controller和Service,也Mock了相关对象,但是还是会不停的报我们的Mapper找不到(以我代码为例,测试service的时候会报repository是空指针)类似的问题,而且注意查看往往会发现这个抛空指针的mapper是我们整个模块里的第一个mapper文件,甚至完全和我们当前测试类八竿子打不着关系。
这种情况下我当时的处理方式是,查看我的是不是在上面加了类似于
@MapperScan("com.example.demo.**.repository")
的注解,按照SpringBootTest官方的建议是,最好不要在测试启动类上面加这些东西。
但是当我把@Mapper
注解删了之后,测试Dao层可能又会报Mapper找不到的问题,所以我们可以把@MapperScan注解放到我们要测试的DemoRepositorySpec上,也可以放在一个BaseRepositorySpec上,然后其他的RepositorySpec去继承这个BaseRepositorySpec
在没有分清Spock和Mockitio之前,可能会误认为所有的Mockitio注解在Spock里都是通用的。因此在针对@Autowired的依赖Mock的时候,可能会使用以下这种写法
class DemoServiceSpec extends Specification {
@Mock
DemoRepository demoRepository = Mock(DemoRepository){
getDemo(_ as Integer) >> 6
}
@InjectMocks
DemoServiceImpl demoService
def "service test"(){
expect:
demoService.getDemo(1) == null
}
}
然后执行测试用例的时候不停的报这个错误
需要注意的是,Spock并不是Mockitio,一定要搞清楚两者的区别。Spock的开发人员在Issues上也亲自说明了,不要在Spock中使用类似于@MockBean
,@SpyBean
的Mockitio的功能,请使用Spock自己的语法Mock(),Stub(),Spy()
。不是说有什么商业竞争,而是Spock里不一定支持Mockitio注解。或者@MockBean在Spock中能起到作用,但是@InjectMocks在Spock中是绝对没有作用的。
如果要使用类似的功能,请使用@Subject和@Collaborator组合,详细写法参考我上面章节中的“对Service层进行测试”
也可以查看提供此注解的官方gitHub
里面有不少例子可以参考
Spock in Java 慢慢爱上写单元测试
spock-testing-exceptions-with-data-tables
Spock官方文档
Spock开源GitHub
Difference between Mock / Stub / Spy in Spock test framework
Mocks Aren’t Stubs
@Mock/@InjectMocks for groovy - spock
Difference between Mock / Stub / Spy in Spock test framework
spock-subjects-collaborators-extension
mybatis-spring-boot-autoconfigure
H2官方文档