背景
在當(dāng)前微服務(wù)和前后端分離大行其道的行業(yè)背景下等孵,越來越多的團(tuán)隊(duì)采用了前后端分離和微服務(wù)的架構(gòu)風(fēng)格去枷。
該服務(wù)架構(gòu)下會(huì)讓各個(gè)服務(wù)之間更多的依賴關(guān)系汁政,而且通常每個(gè)服務(wù)都是獨(dú)立的團(tuán)隊(duì)維護(hù),服務(wù)和服務(wù)之間大多通過API調(diào)用磷蜀。那么這種情況下可能就會(huì)出現(xiàn)一個(gè)問題餐曹,想象一下以下的場(chǎng)景:
A團(tuán)隊(duì)開發(fā)某服務(wù)并提供對(duì)應(yīng)API服務(wù)于颖,B團(tuán)隊(duì)是A團(tuán)隊(duì)的使用者調(diào)用A團(tuán)隊(duì)的API柏锄。
A團(tuán)隊(duì)埋頭苦干坪仇,B團(tuán)隊(duì)也爭(zhēng)分奪秒杂腰,兩邊都開發(fā)完了,往往一聯(lián)調(diào)椅文,就出現(xiàn)下圖這情況喂很。
為了保證API調(diào)用的準(zhǔn)確性, 我們需要對(duì)API進(jìn)行測(cè)試皆刺。但是即使這次測(cè)試通過了少辣,可能會(huì)隨著迭代的演進(jìn),重構(gòu)等羡蛾,A團(tuán)隊(duì)可能無形中修改了原API漓帅,那么這就可能會(huì)讓B團(tuán)隊(duì)在不知情的情況下服務(wù)不可用。
對(duì)測(cè)試而言也可能因?yàn)锳團(tuán)隊(duì)未完成對(duì)應(yīng)API服務(wù)或者A服務(wù)不穩(wěn)定痴怨,而照成測(cè)試無法介入B團(tuán)隊(duì)的測(cè)試或者測(cè)試效率低下忙干。
當(dāng)然你可能為了早點(diǎn)讓B團(tuán)隊(duì)可測(cè),你可能構(gòu)建測(cè)試替身浪藻,例如使用MockService捐迫,構(gòu)建A團(tuán)隊(duì)的服務(wù)替身“看上去貌似可以測(cè)試提前又可以避免A服務(wù)的不穩(wěn)定性施戴,但是問題又來了,即使Mock測(cè)試通過了钧惧,你能確定AB團(tuán)隊(duì)聯(lián)調(diào)就能通過暇韧?也許A團(tuán)隊(duì)返回的根本不是你Mock類型的數(shù)據(jù)或者說A團(tuán)隊(duì)的服務(wù)發(fā)生了變化。
問題和困境
- API調(diào)用方對(duì)API提供方的變更經(jīng)常需要通過對(duì)API的測(cè)試來感知浓瞪。
- 直接依賴真實(shí)API的測(cè)試效果受限與API提供方的穩(wěn)定性和反應(yīng)速度懈玻。
解決方案
解決方式首先是依賴關(guān)系的解耦,去掉直接對(duì)外部API的依賴乾颁,而是內(nèi)部和外部系統(tǒng)都依賴于一個(gè)雙方共同認(rèn)可的約定—“契約”涂乌,并且約定內(nèi)容的變化會(huì)被及時(shí)感知;其次英岭,將系統(tǒng)之間的集成測(cè)試湾盒,轉(zhuǎn)換為由契約生成的單元測(cè)試,例如通過契約描述的內(nèi)容诅妹,構(gòu)建測(cè)試替身罚勾。這樣毅人,同時(shí)契約替代外部API成為信息變更的載體。
什么是契約測(cè)試
契約測(cè)試也叫消費(fèi)者驅(qū)動(dòng)測(cè)試尖殃。
兩個(gè)角色:消費(fèi)者(Consumer)和 生產(chǎn)者(Provider)
一個(gè)思想:需求驅(qū)動(dòng)(消費(fèi)者驅(qū)動(dòng))
契約文件:由Consumer端和Provider端共同定義的規(guī)范丈莺,包含API路徑,輸入送丰,輸出缔俄。通常由Consumber生成。
實(shí)現(xiàn)原理:Consumer 端提供一個(gè)類似“契約”的東西(如json 文件器躏,約定好request和response)交給Provider 端俐载,告訴Provider 有什么需求,然后Provider 根據(jù)這份“契約”去實(shí)現(xiàn)登失。
PACT demo 分享
PACT 工作原理
PACT 是契約測(cè)試其中一個(gè)主流框架遏佣,最早是ruby實(shí)現(xiàn),現(xiàn)支持大部分開發(fā)語言壁畸,下圖是PACT的工作原理圖
第一步:Consumer 寫一個(gè)對(duì)接口發(fā)送請(qǐng)求的單元測(cè)試贼急,在運(yùn)行這個(gè)單元測(cè)試的時(shí)候,Consumer 會(huì)向Pact發(fā)起一個(gè)請(qǐng)求捏萍,Pact會(huì)將服務(wù)提供者自動(dòng)用一個(gè)MockService代替返回,并生成Json格式的契約文件空闲。
第二步:在Provider端做契約驗(yàn)證測(cè)試令杈。將Provider服務(wù)啟動(dòng)起來以后,通過一些命令碴倾,Pact會(huì)自動(dòng)按照契約生成接口發(fā)起Provider服務(wù)調(diào)用請(qǐng)求逗噩,并驗(yàn)證Provider的接口響應(yīng)是否滿足契約中的預(yù)期。
所以可以看到這個(gè)過程中跌榔,在消費(fèi)者端不用啟動(dòng)Provider异雁,在服務(wù)提供端不用啟動(dòng)Consumer,卻完成了與集成測(cè)試類似的驗(yàn)證測(cè)試僧须。
Node.js 代碼實(shí)踐
- 創(chuàng)建一個(gè)Express項(xiàng)目
- 安裝Pact相關(guān)包
npm install --save-dev pact
3.創(chuàng)建test/consumer/consumer.js文件纲刀,編寫Consumer調(diào)用Provider方法,例子是再封裝成一個(gè)API:
server.use(bodyParser.json())
server.use(bodyParser.urlencoded({ extended: true }))
server.get('/1', function (req, res) {
var reqOpts = {
uri: `http://localhost:8081/user/1`,
headers: { 'Accept': 'application/json' },
json: true
}
console.log(`**** Triggering request to http://localhost:8081} ****`)
request(reqOpts)
.then(function (rep) {
console.log(rep);
console.log('**** Received response ****');
res.send(rep)
})
.catch(function (err) {
res.status(500).send(err)
})
});
server.listen(8080, function () {
console.log(`**** Consumer listening on 8080. Provider: http://localhost:8081} ****`)
})
- 創(chuàng)建test/consumer/consumer-test.js文件担平,主要用于生成pact的契約文件
1)創(chuàng)建一個(gè)Pact對(duì)象示绊,其對(duì)象表示依賴的一個(gè)生產(chǎn)者
// 1. 創(chuàng)建一個(gè)Pact對(duì)象,其表示依賴的一個(gè)生產(chǎn)者端
const provider = pact({
consumer: 'TodoApp',
provider: 'TodoService',
port: 8080,
log: path.resolve(process.cwd(), 'logs', 'pact.log'),
dir: path.resolve(process.cwd(), 'pacts'), // json文件生成位置
logLevel: 'INFO',
spec: 2
});
- 需要調(diào)用provider.setup() 啟動(dòng)mock server來mock生產(chǎn)者服務(wù)
3)定義消費(fèi)者與生產(chǎn)者相互交互的內(nèi)容暂论,與前一步代碼結(jié)合后如下
// 2. 啟動(dòng)mock server來mock生產(chǎn)者服務(wù)
provider.setup()
// 3. 定義消費(fèi)者與生產(chǎn)者相互交互的內(nèi)容
.then(() => {
provider.addInteraction({
state: 'have a matched user',
uponReceiving: 'a request for get user',
withRequest: {
method: 'GET',
path: '/1'
},
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: {
id: 1,
name: 'God'
}
}
})
})
.then(() => done())
});
這里需要說明的是state, 這里的state代表的是生產(chǎn)者所處的狀態(tài)面褐,生產(chǎn)者可以根據(jù)不同的狀態(tài)初始化不同的資源。因而消費(fèi)者在不同狀態(tài)下發(fā)送同樣的請(qǐng)求取胎,生產(chǎn)者卻因?yàn)樽陨沓跏蓟Y源的不同可以返回不同的結(jié)果展哭。
4)測(cè)試代碼中需要有發(fā)送請(qǐng)求到mock的生產(chǎn)者服務(wù)
// 4. 測(cè)試代碼中需要有邏輯請(qǐng)求mock的生產(chǎn)者服務(wù)
it('should response with user with id and name', (done) => {
request.get('http://localhost:8080/1')
.then((response) => {
const user = response.body;
expect(user.name).to.equal('God');
provider.verify();
done();
})
.catch((e) => {
console.log('error', e);
done(e);
});
});
5 將契約寫到文件中,關(guān)閉mock的生產(chǎn)者端
// 5. 將契約寫到文件中,關(guān)閉mock的生產(chǎn)者端
after(() => {
provider.finalize();
});
這時(shí)你啟動(dòng)消費(fèi)者服務(wù)匪傍,并運(yùn)行該test坝咐,你將會(huì)在工程根目錄多出pacts文件夾,并發(fā)現(xiàn)生成了todoapp-todoservice.json文件析恢,這個(gè)就是契約文件,上面描述了請(qǐng)求的發(fā)起路徑方法墨坚,返回的headers body等:
{
"consumer": {
"name": "TodoApp"
},
"provider": {
"name": "TodoService"
},
"interactions": [
{
"description": "a request for get user",
"providerState": "have a matched user",
"request": {
"method": "GET",
"path": "/1"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json; charset=utf-8"
},
"body": {
"id": 1,
"name": "God"
}
}
}
],
"metadata": {
"pactSpecification": {
"version": "0.0.0"
}
}
}
生成了契約文件,也就意味著消費(fèi)者端已經(jīng)給出了具體的接口返回字段需求映挂,剩下的就是生產(chǎn)者端根據(jù)契約文件開發(fā)對(duì)應(yīng)接口泽篮。
生產(chǎn)者端測(cè)試
為了demo簡(jiǎn)單,這里面把provider和Consumer放一個(gè)工程柑船,實(shí)際工作通常是兩個(gè)工程甚至兩個(gè)不同開發(fā)語言的工程帽撑。 而且代碼直接編寫了一個(gè)返回固定值的API,并編寫了test用于測(cè)試生產(chǎn)者端是否滿足消費(fèi)者端鞍时。
- 創(chuàng)建test/provider/provider.js文件亏拉,新建一個(gè)/user/:id接口
server.use((req, res, next) => {
res.header('Content-Type', 'application/json')
next()
});
server.get('/user/:id', (req, res) => {
res.end(JSON.stringify({
id: 1,
name: 'God'
}));
});
server.listen(8081, () => {
console.log('User Service listening on http://localhost:8081')
});
- 創(chuàng)建test/provider/ptest.js文件,用于測(cè)試生產(chǎn)者端是否滿足消費(fèi)者的需求
const verifier = require('pact').Verifier;
const path = require('path');
// 驗(yàn)證生產(chǎn)者滿足消費(fèi)者的需求
describe('Pact Verification', () => {
it('should validate the expectations of Matching Service', () => {
const opts = {
providerBaseUrl: 'http://localhost:8081',
providerGetUser: 'http://localhost:8081/user/:id',
pactUrls: [path.resolve(process.cwd(), './pacts/todoapp-todoservice.json')]
// pactUrls: ['http://sjqasystst04:8081/pacts/provider/TodoService/consumer/TodoApp/latest']
}
return verifier.verifyProvider(opts)
.then(output => {
console.log('Pact Verification Complete!');
console.log(output)
});
});
});
其中opts中定義了生產(chǎn)端的URL,要測(cè)試的接口URL逆巍,以及使用的契約文件及塘,并使用
require('pact').Verifier.verifyProvider
來驗(yàn)證契約
- 啟動(dòng)生產(chǎn)者端,并運(yùn)行該test锐极,會(huì)發(fā)現(xiàn)測(cè)試是通過的笙僚。如果修改下生產(chǎn)者端的接口返回,再次啟動(dòng)生產(chǎn)者端并運(yùn)行該test會(huì)發(fā)現(xiàn)測(cè)試是不會(huì)通過灵再。
Pact Broker
消費(fèi)者端生成的契約文件肋层,如果沒有一個(gè)平臺(tái)管理,勢(shì)必會(huì)比較麻煩翎迁,而Pact Broker是契約的管理者(代理人)栋猖。它提供了:
- 發(fā)布和獲取契約的接口
- 服務(wù)之間的依賴關(guān)系
- 契約的版本管理
例如上面例子,當(dāng)我把契約發(fā)布到Pact Broker上后汪榔,生產(chǎn)者端可以修改pactUrls獲取對(duì)應(yīng)的契約文件如:
pactUrls: ['http://sjqasystst04:8081/pacts/provider/TodoService/consumer/TodoApp/latest']
Pact Broker Docker服務(wù)搭建
1.新建docker-compose.yml蒲拉,如下:
db:
image: postgres:9.4
container_name: pactbrokerdb
environment:
- POSTGRES_USER=pact
- POSTGRES_PASSWORD=test
web:
image: dius/pact-broker
container_name: pactbrokerweb
ports: ["8081:80"]
links: ["db"]
environment:
- PACT_BROKER_DATABASE_USERNAME=pact
- PACT_BROKER_DATABASE_PASSWORD=test
- PACT_BROKER_DATABASE_HOST=db
- PACT_BROKER_DATABASE_NAME=pact
注意:新版是叫pact-broker 舊版是 pact_broker
- 消費(fèi)者端創(chuàng)建publishPacts.js來發(fā)布契約
pact.publishPacts({
pactUrls: [path.join(process.cwd(), 'pacts')], // 契約路徑
pactBroker: 'http://sjqasystst04:8081', // pact broker 服務(wù)地址
consumerVersion: '1.0.0'
});
-
運(yùn)行該文件,這時(shí)你就可以到pact broker上看到契約
API文檔上的黑色體字有沒發(fā)現(xiàn)都是消費(fèi)者端生成時(shí)設(shè)置的揍异。
靈活匹配
消費(fèi)者端制定的契約中每個(gè)字段不一定需要完全匹配全陨,其也支持正則匹配、類型匹配衷掷、數(shù)組匹配辱姨。
正則匹配
'gender': term({
matcher: 'F|M',
generate: 'F'
})
類型匹配
body: {
id: like(1),
name: like('Billy')
}
數(shù)組匹配
'users': eachLike({
name: like('God')
}, {
min: 2
});
min的默認(rèn)值是1, 這里表示至少有2個(gè)user戚嗅。
小結(jié)
契約測(cè)試幫我們解決了上面問題雨涛?
- 可以使得消費(fèi)端和提供端之間測(cè)試解耦枢舶,不再需要客戶端和服務(wù)端聯(lián)調(diào)才能發(fā)現(xiàn)問題
- 完全由消費(fèi)者驅(qū)動(dòng)的方式,消費(fèi)者需要什么數(shù)據(jù)替久,服務(wù)端就給什么樣的數(shù)據(jù)凉泄,數(shù)據(jù)契約也是由消費(fèi)者來定的
- 測(cè)試前移,越早的發(fā)現(xiàn)問題蚯根,保證后續(xù)測(cè)試的完整性
- 通過契約測(cè)試后众,團(tuán)隊(duì)能以一種離線的方式(不需要消費(fèi)者、提供者同時(shí)在線)颅拦,通過契約作為中間的標(biāo)準(zhǔn)蒂誉,驗(yàn)證提供者提供的內(nèi)容是否滿足消費(fèi)者的期望。
資料參考
JS測(cè)試之Pact測(cè)試
聊一聊契約測(cè)試
nodejs pact