在微服务体系中,开发者要进行
接口测试,一般有以下几种方法:
1. 搭建完整的微服务环境,将所有依赖的微服务全部运行起来,然后针对要测试的微服务写
测试用例;
2. 使用 Mock 来模拟依赖的微服务以及
数据库的读写;
3. 契约测试,服务的提供者和消费者按照同样的契约编写自己的测试用例。
这其中,方法1的工作量比较大,维护这么一个环境也是一个麻烦的事情,但是能真实模拟请求的完整流程;方法2能让测试集中于自己的微服务中,但是一旦依赖的接口有变化,Mock并不能及时的反映出来,要到集成测试的时候才可能发现,这是个隐患;方法3在微服务架构中是一个比较好的方法,服务的提供者和消费者同时按照同一个版本的契约进行各自独立的开发和测试,又不用完整的运行整套微服务体系,在便捷性和准确性上都有一定的保证。
本文介绍在 Spring Cloud 微服务中,如何优雅的编写接口测试用例,这其中依赖到了 Spring Cloud Contract(契约测试框架),DbUnit(数据库工具,用来模拟数据库的读写)。一个好的测试用例,应该在测试接口逻辑的完整性的条件下,不会对数据库造成破坏(这就要使用DbUnit工具),运行测试用例时不会依赖其他的微服务(这就要使用契约测试)。
首先介绍下示例项目依赖的版本:
Spring Cloud: Greenwich.RELEASE
DbUnit: 2.6.0
具体的依赖还需要根据实际的 Spring Cloud 版本进行更换。
一、使用 DbUnit 完成对数据库层面的Mock
DnUnit工具具体使用方法请自行
百度,它的实现逻辑是根据你提供的数据库连接信息,将对应的数据库进行备份,然后将你准备的测试数据写入到数据库中,之后执行测试用例,所有测试用例执行完毕之后,再将备份信息还原到数据库中,这样就避免了对数据库的破坏。
首先准备测试数据,在 src/test/resources 下面建立 testData.xml 文件,按照如下格式写入测试数据。
< xml version=”1.0” encoding=”UTF-8” >
<dataset>
<user_ user_uuid="11111111" acCount="zhangsan" user_name="张三"/>
<user_ user_uuid="22222222" acCount="lisi" user_name="李四"/>
</dataset>
假设我们有一个接口 http://localhost:8080/user/${userUuid} 根据 userUuid 获取用户信息,具体的实现不列出了,这不是这篇
文章的重点,我们只要有这个接口存在就行,它会返回如下格式的json数据。
{
"errorCode": 0,
"errorMsg": "SUCCESS",
"data": {
"userUuid": "11111111",
"acCount": "zhangsan",
"userName": "张三"
}
}
然后编写测试类:
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@Transactional(transactionManager = "transactionManager")
@Rollback(value = true)
@TestExecutionListeners({
DependencyInjectionTestExecutionListener.class,
DirtiesContextTestExecutionListener.class,
TransactionDbUnitTestExecutionListener.class,
DbUnitTestExecutionListener.class })
@DatabaseSetup("/testData.xml")
public class UserControllerTest {
private static final Logger LOGGER = LoggerFactory.getLogger(UserControllerTest.class);
private ObjectMapper mapper;
@Autowired
public MockMvc mvc;
@Before
public void setUp() {
LOGGER.info("UserControllerTest init");
RestAssuredMockMvc.mockMvc(mvc);
this.mapper = new ObjectMapper();
}
@Test
public void testCreateUser() throws Exception {
this.mvc.perform(MockMvcRequestBuilders.get("/user/11111111")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.an dExpect(MockMvcResultMatchers.status().isOk())
.an dExpect(MockMvcResultMatchers.jsonPath("errorCode").value(0))
.an dReturn();
}
}
编译运行,测试用例通过,可以查看下实际的数据库是不是还是原来的状态,如果是则表示 DbUnit 工具引入成功。当然这过程中编写类似创建用户的测试用例,更能看出来的 DbUnit 是否生效。
这中间过程中你可能会碰到一个问题: org.dbunit.database.AmbiguousTableNameException: EVALUATE ,这是一个很坑的问题,我在这个问题上纠结了两天,各种百度 google 无果,最后发现是 Spring Cloud Greenwich.RELEASE 版本使用的 mysql-connector-java 是 8.0的版本,需要将其改成 5.X的版本才能使得 DbUnit 正常运行。
DbUnit 完美运行之后,接下来就是契约测试了。
二、Spring Cloud Contract
先说下契约这个东西,对于服务提供者而言,契约可以用来约束其
单元测试用例,服务提供者编写的测试用例,必须符合这个契约,才能保证服务提供者提供的接口确实是符合这个契约的。对于服务消费者而言,契约可以模拟其调用这个微服务时,会得到什么样的结果。编写契约可以使用 groovy 或者 yml,Spring Cloud Contract 可以根据这个契约生成 测试用例,我们可以有效利用这一点,简化服务提供者的单元测试用例的编写工作。
服务提供者:
引入依赖
<dependencies>
...
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
...
</dependencies>
<build>
<plugins>
...
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>2.1.0.RELEASE</version>
<!--Don”t forget about this value !!-->
<extensions>true</extensions>
<configuration>
<!--MvcMockTest为生成本地测试案例的基类-->
<baseClassForTests>com.walli.user.service.test.UserControllerTest</baseClassForTests>
</configuration>
</plugin>
...
</plugins>
</build>
这里说明下 baseClassForTests 这个属性配置,这里声明 Spring Cloud Contract 自动生成测试用例的时候的基类,在这个基类中,你需要注入 MockMvc 的上下文(上面的测试类示例代码中的@Before RestAssuredMockMvc.mockMvc(mvc) 这一行)。
然后编写契约,我采用的是 yml 方式, groovy 不是太熟,但是使用 groovy 肯定灵活性更高。
Spring Cloud Contract 默认会去 src/test/resources/contracts 目录下去加载契约文件,这里简单一点我们就不改目录了, 直接在这个目录下创建契约文件 getUser.yml(契约文件的具体内容,还需要根据你实际的接口规则去编写,此处返回的状态等都只适合我的测试代码,你可以组织各种各样不同的参数提交来模拟各种复杂情况,以提高测试的代码覆盖率)
## 此文件为 get user by userUuid 接口的契约
## 测试用户不存在
request:
method: GET
url: /user/33333333
headers:
Content-Type: application/json
response:
status: 500
body:
errorCode: 990004
headers:
Content-Type: application/json;charset=UTF-8
---
## 测试用户正常获取
request:
method: GET
url: /user/11111111
headers:
Content-Type: application/json
response:
status: 200
body:
errorCode: 0
errorMsg: SUCCESS
data:
userUuid: 11111111
userName: 张三
acCount: zhangsan
headers:
Content-Type: application/json;charset=UTF-8
契约编写完成之后,直接 mvn clean install 编译,如果成功,你可以在 代码目录的 target 目录下看到一个叫做 XXXX-stub.jar 的文件,这个 stub 文件就是你可以交给服务消费者使用的文件,你可以把它放到你们自己的 maven 仓库中,供别人下载。
然后,你可以在 targetgenerated-test-sources 找到一个 ContractVerifierTest 的类,它 extends 你写的 UserControllerTest 类,这里面,就是根据契约自动生成的测试用例。
服务消费方:
服务消费方关键就是要引入服务提供方给出的 stub 文件,有远程和本地两种引入方式。
首先需要引入依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>
本地引入 stub 时,需要先获取 服务提供方的代码然后编译完成,即保证本地的 maven 仓库中有对应的 stub 文件。
然后编写消费方的测试代码:
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureStubRunner(ids = {"com.walli:cloud-user-service-server:1.0.1:stubs:9900"},
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class SsoControllerTest {
private static final Logger LOGGER = LoggerFactory.getLogger(SsoControllerTest.class);
private ObjectMapper mapper;
@Autowired
public MockMvc mvc;
@Before
public void setUp() {
LOGGER.info("SsoControllerTest init");
this.mapper = new ObjectMapper();
}
@Test
public void testLogin() throws Exception {
this.mvc.perform(MockMvcRequestBuilders.get("/user/111111")
.contentType(MediaType.APPLICATION_JSON)
.content(this.mapper.writeValueAsString(param))
.accept(MediaType.APPLICATION_JSON))
.an dExpect(MockMvcResultMatchers.status().isOk())
.an dExpect(MockMvcResultMatchers.jsonPath("errorCode").value(0))
.an dReturn();
}
}
其中
@AutoConfigureStubRunner(ids = {"com.walli:cloud-user-service-server:1.0.1:stubs:9900"},
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
这一段即为本地使用 stub 文件,如果是远程调用,需要按照如下方式进行:
首先在 application.yml 文件中声明stub依赖方式:
stubrunner:
ids: ”com.walli:cloud-user-service-server:1.0.1:stubs:9900”
repositoryRoot: http://repo.spring.io/libs-snapshot
repositoryRoot 改成自己的,然后把测试代码上的 @AutoConfigureStubRunner 中StubsMode 改成 REMOTE即可。
编译运行测试用例通过,即表示消费方契约测试成功,因为你并没有启动 cloud-user-service-server,但是你的测试用例还是通过了,并且调用的接口返回值是契约中约定的值。
关键概念&知识点
1. priority 数值越小,优先级越高
在契约定义中,我们有时候不仅要定义接口的正常返回值,也有可能要定义异常的返回值(即消费者按照此规则传递参数之后获得的是一个错误),这种情况我们可以利用 priority,将错误返回值的契约设置较高优先级,将正常返回值的契约优先级降低,这样有利于消费者需求错误响应时能拿到相应的错误,而不是匹配上正确的响应结果。
2. request 中的一些概念
request 中的参数,不管是 queryParameters 还是 body 中的参数,如果你不写相应的 matcher 的话,契约中就是默认生成参数必须严格相等的契约。
request.matchers 是用来定义请求参数的规则的,即消费者必须按照此规则来提交参数;并且,matchers 中最好只定义必传参数的规则,否则就会面临不必传的参数,消费者使用契约时必须填写该参数才行;契约中不存在的参数,默认都是可传可不传的。
request matchers queryParameters 的 type 可用如下值:
equal_to_json, equal_to, not_matching, matching, containing, absent, equal_to_xml
3. response 中的一些概念
response.body 的返回值,是用来模拟给消费者的返回值的,同时也用来校验spring cloud contract 自动生成测试用例的返回结果是不是跟这个值匹配
response.matchers 的用处是当 spring cloud contract 自动生成测试用例得到的返回结果与 response.body 中定义的值不一样时,比如创建用户生成 uuid,这个 uuid 必然是随机的,response.body 必然无法自定义,所以这时候需要写 response.matchers 定义 uuid 规则,只要实际的返回值符合这个规则,测试用例就认为可以通过。matchers 不特殊定义匹配规则的字段,就是严格等于 response.body 中定义的值
response.body 中最好写调用此接口必然会返回的值,同时 response.matchers 中需要对应 response.body 返回的字段有动态值的字段写好相应的 matcher,否则自动生成的测试用例无法通过
response matchers type 可用如下值:
by_type, by_comman d, by_time, by_date, by_timestamp, by_null, by_equality, by_regex
response matchers type=by_regex时,使用 predefined 可用如下预定义的正则表达式:
non_blank, iso_date_time, iso_8601_with_offset, iso_time, iso_date, only_alpha_unicode, url, hostname, any_boolean, uuid, ip_address, any_double, number, non_empty, email
4. matchers 中,url 无法用正常的正则表达式(关键是不能用 ^ 和 $,包括 groovy 中也是如此),YML可以。
局限性
1. 在类似 新增/更新 这种参数不确定的请求中,request 的参数只能写必传值,response 也只能写必然会返回的数据(更新可以所有有效字段)。
2. reuqest.queryParameters 无法编写数组类型的参数契约,只能默认全都允许。(即 request matchers 中判断其不为空即可)
3. request.body 中的参数对象如果每个字段都可为空,这种契约是没法编写的,契约的 request.body 不写的话代码会报 body 不能为空的错误,写的话又强制了定下契约body的字段必须传递,这是个矛盾点,所以只能挑一个绝大多数情况下都会传递的参数写到 request.body 里面,消费者调用的时候传递一下这个参数。
实践过程中遇到的问题
1. 单元测试在 jenkins 脚本中编译可以,但是测试用例完全无法运行,出错的现象是无法访问其他微服务,比如 config-server。
解决办法:
这个问题困扰了很久,最后发现是编译时使用的maven镜像并没有加入整个Spring Cloud 的网络环境中。整体的环境是,Docker中启动Jenkins,Jenkins 创建多流水线任务调用项目代码中的 Jenkinsfile,Jenkinsfile 中定义 Pipeline,最开始,pipeline 中使用的 agent 如下:
pipeline {
agent {
docker {
image ”maven:3.6.0-jdk-8”
args ”-v /root/.m2:/root/.m2”
}
}
。。。。。。
}
可以看出来使用的官方的 maven 镜像来进行编译工作,但是这个镜像是官方的,并没有加入我们自定义的SpringCloud使用的网络中,于是在 args 中增加网络参数,即可解决
pipeline {
agent {
docker {
image ”maven:3.6.0-jdk-8”
args ”-v /root/.m2:/root/.m2 --net=servicenet”
}
}
。。。。。。
}
2. DBUnit 解析的时间不对,xml 数据中,以“yyyy-MM-dd HH:MM:SS” 的格式写入时间,代码中会自动将其转换成UTC时间格式,发现在本地开发的时候,正常转换没有问题,UTC字符比实际的时间少8小时,但是在Jenkins中编译时,UTC字符跟xml里面时间是一样的,导致了测试用例失败。
解决办法:
其实就是个时区的问题,jenkins 通过 Docker 容器启动,Docker 容器没有设置为正确的时区的话,openjdk8默认是从系统的 /etc/timezone 文件中读取时区的,因此造成了 xml 中的时间没有被正确的解析,默认的 timezone 就是0时区。解决办法是启动jenkins时,把相应的时间、时区都设置进去,比如 docker-compose.yml:
version: ”3”
services:
jenkins:
image: wx.ankoninc.com.cn/jenkins:2.222.4
user: root
privileged: true
container_name: jenkins
environment:
# 这一行也很重要,告诉 JVM 时区
JAVA_OPTS: -Duser.timezone=Asia/Shanghai
volumes:
# 容器同步宿主机时间
- /etc/localtime:/etc/localtime
# 容器同步宿主机时区
- /etc/timezone:/etc/timezone
- jenkins-data:/var/jenkins_home
- /var/run/docker.sock:/var/run/docker.sock
- /usr/bin/docker:/usr/bin/docker
- /root/.docker:/root/.docker
ports:
- 8080:8080
- 50000:50000
networks:
- servicenet
volumes:
jenkins-data:
driver: local
networks:
servicenet:
external: true
如果宿主机中 timezone 文件不存在,自己创建一个即可:
echo "Asia/Shanghai" >> /etc/timezone