随着微服务的普及,Web系统越来越大,各模块之间的调用越来越复杂,如果一个服务修改了,那么调用它的所有使用者都得验证一遍是否对自己有影响,导致效率低,成本高,契约测试就是在这一背景下产生的,它能很好的解决这个问题。本文就来向大家介绍契约测试,希望起到抛砖引玉的作用,看能否用到我们的项目中,达到测试左移,提升效率的目的。
现实问题
● 难以快速响应外部需求变化
如果系统的用户提出一个新需求或者需求更改的时候,很难快速的让所有后端开发团队们都明确的知道集成部分的更改细节和需求,从而快速的完成开发工作。
● 验证成本高
这么多服务,每一个服务都需要单独搭建自己的项目环境,每个服务都要执行部署,配置,确保每个服务都正常运行;这种验证成本是很高的
● 测试结果不稳定
微服务项目本身是分布式系统,服务通信是跨网络的调用,服务间的协作,网络延时等因素都会影响测试结果,这就会导致测试结果不够稳定
● 测试反馈时间周期长
服务部署到测试环境后才发现集成问题,且需要对每个服务依次定位,导致发现问题的时间延后,从而增加修复的成本
什么是契约测试?
微服务架构中,一般分为“提供者”(provider,提供接口的服务) 和“消费者”(consumer,调用接口的服务)
接口文档可以称之为对接口契约的具体描述,主要提供给人看。而契约测试,则是将契约具象为代码/工具可识别的形式,比如json、yml、DSL格式。然后借助相关测试工具,根据这份契约,自动测试“消费者/提供者”接口是否正常。
契约测试是专门测试数据结构的综合性测试方法。通常来说,它由 consumer 发起测试,然后将测试数据形成契约文件发送给 provider ,由 provider 根据契约文件执行测试,这样 provider 就能自己控制测试进度,主动获取结果。
在形式上比较类似接口测试,因为它们都是通过访问服务来确定是否通过测试,但接口测试需要关注全部的调用场景,比如正向和异常场景,而契约测试一般只关注正向的消息结构是否正确。
对于一个返回的消息,契约测试关注它有多少个字段、每个字段的值是什么类型,但是一般不会去校验字段的值到底等于多少。所以契约测试一般不需要关注数据,而是重点关注数据的结构(schema)。
在发生契约变化时,提供一种可立即被服务端和消费端发现的方式。
契约测试与接口测试的区别
契约测试分类
契约测试一般分两种,一种是消费者驱动,一种是提供者驱动。其中最常用的,是消费者驱动的契约测试(简称 CDC)。即由“消费者”定义出接口“契约”,然后测试“提供者”的接口是否符合契约。
契约测试的优势
降低服务集成的难度,把服务集成这个过程分解成了单元测试和接口测试来做,它从消费者的需求为出发点,把消费者的需求作为你的测试用例驱动出一份契约,然后验证提供者端的功能。
通过使用契约测试,接口调用双方协商接口后就可以并行开发,并且在开发过程中就利用契约进行预集成测试,不用等到联调再来集成调通接口,一旦成熟,在保证质量的前提下,联调的成本可以减低到几乎为0。
因为契约的存在,让接口的变动有迹可循,即使变动也可以确保变动的安全性和准确性。
通过契约测试,团队能以一种离线的方式(不需要消费者、提供者同时在线),通过契约作为中间的标准,验证提供者提供的内容是否满足消费者的期望。
契约测试工具
Pact
Pacto
Sprint Cloud Contract
本文讲述Pact。
pact是一个契约测试框架,目前支持java、python、ruby等多种语言。
pact工作原理
契约测试实践
下文以python为例介绍契约测试如何实践
一、思路
pact契约测试分为两步:
编写test用例,生成契约文件(不需要启动服务)。
利用pact-verifier命令和契约文件,验证接口提供者是否正确 (需要启动提供者服务)
二、安装pact
pip install pact-python
大概率会失败,参考https://www.cnblogs.com/easy-test/p/13823739.html
三、编写provider,对外提供服务
from flask import Flask
from flask import request
from flask import jsonify
app = Flask(__name__)
resp_body = [
? ? {
? ? ? ? "salary": 20000,
? ? ? ? "name": "gaoaolei",
? ? ? ? "nationality": "China",
? ? ? ? "contact": {
? ? ? ? ? ? "Email": "853573584@qq.com",
? ? ? ? ? ? "Phone": "15171001790"
? ? ? ? }
? ? },
? ? {
? ? ? ? "salary": 30000,
? ? ? ? "name": "gaoaolei2",
? ? ? ? "nationality": "China",
? ? ? ? "contact": {
? ? ? ? ? ? "Email": "888888888@qq.com",
? ? ? ? ? ? "Phone": "18888888888"
? ? ? ? }
? ? }
]
@app.route("/information", methods=["GET"])
def test():
? ? get_name = request.args.get("name", "").lower()
? ? if get_name == "gaoaolei":
? ? ? ? resp = jsonify(resp_body[0])
? ? elif get_name == "gaoaolei2":
? ? ? ? resp = jsonify(resp_body[1])
? ? else:
? ? ? ? resp = jsonify({"status": "404 Not Found"})
? ? return resp
if __name__ == "__main__":
? ? app.run(host='0.0.0.0', port=8080)
四、编写测试用例,用于生成契约文件(也可以手动编写契约文件)
import requests
import atexit
import unittest
from pact import Consumer, Provider
# 构造pact对象,定义消费者服务的名字并给它一个生产者服务
pact = Consumer('consumer gaoaolei').has_pact_with(Provider('provider'))
pact.start_service()
# 注册退出的时候关闭pact服务
atexit.register(pact.stop_service)
class GetMikuInfoContract(unittest.TestCase):
? ? def test_gaoaolei(self):
? ? ? ? # 定义响应期望的结果
? ? ? ? expected = {
? ? ? ? ? ? "salary": 20000,
? ? ? ? ? ? "name": "gaoaolei",
? ? ? ? ? ? "nationality": "China",
? ? ? ? ? ? "contact": {
? ? ? ? ? ? ? ? "Email": "853573584@qq.com",
? ? ? ? ? ? ? ? "Phone": "15171001790"
? ? ? ? ? ? }
? ? ? ? }
? ? ? ? # 定义响应头
? ? ? ? headers = {
? ? ? ? ? ? "Content-Type": "application/json"
? ? ? ? }
? ? ? ? # 定义模拟生产者提供者接受请求以及响应的方式
? ? ? ? (pact
? ? ? ? .upon_receiving('a request for gaoaolei')
? ? ? ? .with_request(
? ? ? ? ? ? method='GET',
? ? ? ? ? ? path='/information',
? ? ? ? ? ? query={'name': 'gaoaolei'}
? ? ? ? ).will_respond_with(200, headers, expected))
? ? ? ? # 定义消费者服务向模拟生产者发出请求并活得响应
? ? ? ? with pact:
? ? ? ? ? ? # pact作为模拟生产者时,其端口默认为1234
? ? ? ? ? ? result = requests.get('http://localhost:1234/information', {'name': 'gaoaolei'})
? ? ? ? # 做最后的断言
? ? ? ? self.assertEqual(result.json(), expected)
if __name__ == '__main__':
? ? unittest.main(verbosity=2)
运行后生成契约文件consumer_gaoaolei-provider.json
{
? "consumer": {
? ? "name": "consumer gaoaolei"
? },
? "provider": {
? ? "name": "provider"
? },
? "interactions": [
? ? {
? ? ? "description": "a request for gaoaolei",
? ? ? "request": {
? ? ? ? "method": "GET",
? ? ? ? "path": "/information",
? ? ? ? "query": "name=gaoaolei"
? ? ? },
? ? ? "response": {
? ? ? ? "status": 200,
? ? ? ? "headers": {
? ? ? ? ? "Content-Type": "application/json"
? ? ? ? },
? ? ? ? "body": {
? ? ? ? ? "salary": 20000,
? ? ? ? ? "name": "gaoaolei",
? ? ? ? ? "nationality": "China",
? ? ? ? ? "contact": {
? ? ? ? ? ? "Email": "853573584@qq.com",
? ? ? ? ? ? "Phone": "15171001790"
? ? ? ? ? }
? ? ? ? }
? ? ? }
? ? }
? ],
? "metadata": {
? ? "pactSpecification": {
? ? ? "version": "2.0.0"
? ? }
? }
}
五、验证provider提供的接口是否符合契约
进入契约文件目录,执行 pact-verifier --provider-base-url=http://localhost:8080 --pact-url=consumer_gaoaolei-provider.json
可以看到结果,OK表示通过,Fail表示不通过。
后续
Java实现
CI集成