背景
如今,契約測試已經逐漸成為測試圈中一個炙手可熱的話題,特別是在微服務大行其道的行業背景下,越來越多的團隊開始關注服務之間的契約及其契約測試。
什么是契約測試
關於什么是契約測試這個問題,首先先看一下Pact官方文檔給出的定義:pact的官方文檔,是另一個可以幫助我們理解契約測試的地方。它對契約測試給出了這樣的定義:"Contract testing is a way to ensure that services (such as an API provider and a client) can communicate with each other"。這里面需要關注的重點是"communicate ",它給出了Pact對契約測試范疇(scope)的定義。契約測試又稱之為 消費者驅動的契約測試。這里的契約是指軟件系統中各個服務間交互的數據標准格式,更多的指消費端(client)和提供端(server)之間交互的數據接口的格式。
契約測試的價值
那什么是契約測試的價值呢?要說清楚契約測試的價值,就需要准確認識契約測試的精髓——"消費者驅動"
在討論契約測試的范疇里,”消費者驅動”述及的對象是契約,而不是契約測試。所以誰被驅動的對象就是契約。舉個例子,當某個provider正常上線后,某個consumer需要消費這個provider的服務,那么應該由consumer來提出期望來建立它們之間的契約測試。因為,契約測試,形式上,雖然測試的是provider,但是在價值上,保證的卻是consumer的業務。
如果
消費者對自己都不上心, 那你也不要指望生產者能操什么心。這些都是在跨團隊的微服務體系下真切的痛點。 這里舉一個契約測試的經典案例:
在上圖一個簡單的消費關系中,provider為consumer A,B,C提供服務。provider提供的結構包含name、age和gende三個簡單的字段。這份包含name、age和gender的JSON,其本身只是一個schema,並不是任何契約。因為契約一定是成對存在的,沒有確切consumer的交互定義,只是schema,不是契約。
如上圖有三個消費者,並且消費的字段各不相同,所以這里需要有三份契約(對應的,也需要三份契約測試)。
- consumer A消費page和gender,
- consumer B消費name、age和gender;
- consumer C消費name和gender
就目前provider提供的schema來說,沒有任何問題,大家相安無事。但是某日因為業務需求,consumer C期望provider提供更加詳細的name信息,包括firstName和lastName。這個需求對provider是小case,所以,provider打算對schema做類似下面的修改:

這樣的修改,很明顯對consumer C是需要的,對consumer A無所謂,但對consumer B卻是不可接受的,屬於典型的契約破壞。此時,provider和consumer B之間的契約測試就會掛掉,從而對provider提出預警(至於,剩下的,怎么協調和consumer B的兼容問題,就不是契約測試關注的問題,那需要的是團隊間的交流)上面這個示例中的一些細節,可以幫助我們發掘契約測試的兩大價值點
1. 應對單個provicder多個consumer
要最大化的體現契約測試異於集成測試的價值,一定是在"單個provider對應多個consumer"的架構下來說的。因為,在只有一個provider和一個consumer的架構下,只存在一份契約,對該契約內容的任何修改,對這對provider和consumer來說,都是顯而易見的,那么就不會出現契約破壞的情況。在這種情況下,集成測試往往就已經完整的達到了契約測試的目的。
但是在單個provider對應多個consumer的架構下,情況就不一樣了在上文的例子中provider和consumer C之間的契約修改,對consumer A無影響,對consumer B卻是契約破壞,對這種情況,集成測試是無能為力的。在上邊例子中,有4個service,所以就會有4個集成測試,每個集成測試只會關注自己的業務正確性,provider修改后,只有consumer B的集成測試會掛掉。但那都是在provider的契約破壞生效之后的事情了。可見,雖然4個集成測試都各司其職,但都不能對這個契約破壞的問題做到防患於未然!只有契約測試,才是這個問題的最佳答案!這就是契約測試最大的價值,它只會在"單provider多consumer"的環境下(這是微服務的常見場景,但不是必然場景),才能發揮出來。
2.減少團隊溝通成本
真正的業務場景下,特別是一些復雜的微服務集群,又或者是一些時間跨度很長的系統,對於某個provider,到底有多少個consumer?而provider的每一處修改,又對哪些consumer的契約造成怎樣的影響?這些往往都是很難確定的問題。當在集團業務中一個provider有十幾個 consumer時,每次provider要更新,就得八方去通知這些consumer的團隊來做回歸測試。有時,一點小小的修改,回歸測試一分鍾就可以搞定,但人肉聯系各個團隊卻會花上好幾天。如果每個consumer都能和provider建立契約測試(這里我們暫且不考慮負載和去重的問題),我們就能很好的解決這些效率問題。 當需要把provider的schema中的一個String改成Object,從契約的角度,我們還在糾結如何協調所有的consumer影響最小時,最簡單的一個解決方案就是“不把String改成Object,而是直接添加這個Object"
。
契約測試和功能測試區別
首先這里的功能測試是指接口測試和集成測試, 學習契約測試的時候一定要弄清楚契約測試和功能測試)之間的區別。契約測試主要是用於以下幾點
- 測試接口和接口之間的正確性
- 驗證服務層提供的數據是否是消費端所需要的
- 將本來需要在集成測試中體現的問題前移,更早的發現問題
- 更快速的驗證消費端和提供端之間交互的基本正確性
根據契約測試的用途我們可以發現契約測試和功能測試之間的區別如下:
Example by Pact
Pact最早是用Ruby實現的,目前已經擴展支撐Java,.NET,Javascript,Go,Swift,Python和PHP。 這里我使用springboot+PACT+gradle搭建契約測試。
1.添加依賴
在項目的build.gradle文件中添加如下依賴
buildscript { ext { pactVersion = "4.0.2" kotlin_version=1.3.50 } dependencies { classpath("au.com.dius:pact-jvm-provider-gradle:${pactVersion}") } } apply plugin: "au.com.dius.pact" dependencies { testImplementation "au.com.dius:pact-jvm-consumer-junit:${pactVersion}" testImplementation "au.com.dius:pact-jvm-consumer-java8:${pactVersion}" }
這里有幾個注意點:
1. 由於這里用的pact的版本是4.0.2的,pact插件中依賴的kotlin版本是1.3.50,所以項目中kt的版本也要是1.3.50,然而springboot現在默認自己管理kt的版本,目前項目中采用的springboot是2.1.10.RELEASE默認使用的kt是1.2.7,會導致pact加載失敗,所以要自己手動指定kotlin版本。
2.有寫時候引入的依賴包中可能會指定pact版本,而且是低版本的這個時候也要注意版本沖突。
2.編寫測試用例
想編寫一套完整的PACT測試用例一般分為以下四步
1.確定consumer需求
契約測試編寫測試用例,首先要素是根據consumer需求編寫,這里我假設consumer需求如下
- Get請求:http://ip:port/test/{id}?type=test
- 要求返回的響應
{ "id": "a7a1b044-b8a8-4ef9-ae1b-00599f2281cc", "data": { "type": "test", "projectName": "testadmin", "machineCode": "1111", "validity": "2022-01-18 23:59:59.0", "useNum":500 } }
2.編寫consumer測試代碼
根據consumer的需求編寫Test case
@Test public void testWithQuery() { // 構造consumer需要驗證的響應內容
DslPart body = newJsonBody((root) -> { root.stringType("id"); // 對應上文的data結構
root.object("data", (dataObject) -> { // 驗證返回的type的值是否是"test"
dataObject.stringValue("type", "test"); //驗證類型是否為string
dataObject.stringType("projectName", "tesadmint"); dataObject.stringType("machineCode"); dataObject.timestamp("validity"); dataObject.numberType("userNum", 500); }); }).build(); RequestResponsePact pact = buildPactResponse("test",5,body); MockProviderConfig config = MockProviderConfig.createDefault(PactSpecVersion.V3); PactVerificationResult result = runConsumerTest(pact, config, (mockServer, context) -> { // 自己的客戶端調用服務
TestRestService testRestService = new TestRestService (); // 生成一個mockServer,代替服務端返回響應
TestResponse testResponse= TestResponse .fetchLicenseGetId(mockServer.getUrl(), "/test/1?testType=test"); // 返回的響應內容
TestData testdata= testResponse.getData(); // 驗證響應內容
assertEquals(testdata.getType(), "test"); return null; }); checkResult(result); } // 返回響應的請求頭類型
private RequestResponsePact buildPactResponse(String testType, int id,DslPart body) { Map<String, String> headers = new HashMap<String, String>(); headers.put("Content-Type", "application/json;charset=UTF-8"); return ConsumerPactBuilder .consumer("TestGetIdConsumer") .hasPactWith("TestLicProvider") .given("") .uponReceiving("Query " + tesType + " lic is " + id) .matchPath("/lic/[0-9]+", "/test/" + id) .query("testType=" +testType) .method("GET") .willRespondWith() .headers(headers) .status(200) .body(body) .toPact(); }
其中TestRestService 是consumer應用代碼中的類,我們直接使用它來發送真正的Request,發給誰呢?發給mockServer
,Pact會啟動一個mockServer, 基於Java原生的HttpServer封裝,用來代替真正的Provider應答createPact
中定義好的響應內容,繼而模擬了整個契約的內容。
編寫測試類我這里使用的是Junit DSL方式,這種方式可以在一個測試類中編寫多個測試方法而基本的Junit和Junit Rule的寫法只能在一個測試文件里面寫一個Test Case。當然,Junit DSL的強大之處絕不僅僅是讓你多寫幾個Test Case,通過使用PactDslJsonBody和Lambda DSL你可以更好的編寫你的契約測試文件:
-
- 對契約中Response Body的內容,使用JsonBody代替簡單的字符串,可以讓你的代碼易讀性更好;
- JsonBody提供了強大的Check By Type和Check By Value的功能,讓你可以控制對Provider的Response測試精度。比如,對於契約中的某個字段,你是要確保Provider的返回必須是具體某個數值(check by Value),還是只要數據類型相同就可以(check by type),比如都是String或者Int。你甚至可以直接使用正則表達式來做更加靈活的驗證;
- 目前支持的匹配驗證方法請參考官方文檔,這里不多說
3.設置契約生成目錄
在build.gradle中添加契約文件存放地址
test { systemProperties['pact.rootDir'] = "${buildDir}/Pacts/" }
4. 運行測試類
在junit中運行clean test,運行成功后會生成在對應目錄下契約文件。這里我用的idea,所以直接運行gradle task即可
Provider測試
comsumer端測試代碼編寫完畢,契約也生成好了,接下來就是要執行Provider端測試了,要想執行Provider測試,首選要獲取consumer端的契約文件;契約文件,也就是上文Pacts目錄下面的那些JSON文件,可以用來驅動Provider端的契約測試。由於我們的示例把Consumer和Provider都放在了同一個codeBase下面,所以Pacts
下面的契約文件對Provider是直接可見的,而真實的項目中,往往不是這樣,你需要通過某種途徑把契約文件從Consumer端發送給Provider端。Pact提供了更加優雅的方式那就是使用Pact Broker。目前有好些方法可以搭建Broker服務,我推薦使用Docker來個一鍵了事。
要想發布到Broker上,需要配置發布地址,在gradle中加入如下配置
pact { publish {
// 契約地址 pactDirectory = "${buildDir}/${pactPath}/"
//broker url
pactBrokerUrl = mybrokerUrl } }
這里搭建的broker不需要用戶名和密碼, 所有無需配置用戶名和密碼。配置完后運行發布任務pactpublish,如果是idea的話在右邊是可以直接找到發布的task,雙擊便可執行
發布成功后在broker上可以看到consumer的契約文件。如下
consumer發布契約成功后,provicer就可以從broker上拉取契約文件了,在build.gradle的pact Task中添加serviceProviders配置
pact { publish { pactDirectory = "${buildDir}/${pactPath}/" pactBrokerUrl = mybrokerUrl } serviceProviders { SignLicProvider { protocol = 'http' host = 'localhost' port = 8880 path = '/'
// Test Pacts from local
hasPactWith('') { pactSource = file("${buildDir}/${pactPath}/TestGetIdConsumer-TestProvider.json") } // Test Pacts from Pact Broker
hasPactsFromPactBroker(mybrokerUrl) } } }
如果想把provider端測試的結果提交到broker上,需要開啟結果上傳配置。 在build.gradl中 添加 pact_verifier_publishResults=true 即可。添加成功以后,就可以執行provider端的契約測試了。在執行provider端的測試之前,要先保證provider端的服務開啟,否則無法工作。在idea中的task中可以找到新增的對應的task:pactVerify_TestProvider,然后雙擊運行。也可以手動執行task:TestProvider:pactVerify。
task執行成功后控制台會顯示契約測試是否執行通過,或者在broker上也可以看到最近提交的結果。
最后我們就可以根據契約測試的結果,進行溝通或者修改了。
總結
一般契來說約測試是在單元測試之后,集成測試之前要進行的,首先在保證各自功能正確的前提下測試消費者和提供者的契約是否相匹配,然后再進一步的測試功能的完備性和整個業務流的正確性。
- 可以使得消費端和提供端之間測試解耦,不再需要客戶端和服務端聯調才能發現問題
- 完全由消費者驅動的方式,消費者需要什么數據,服務端就給什么樣的數據,數據契約也是由消費者來定的
- 測試前移,越早的發現問題,保證后續測試的完整性
- 通過契約測試,團隊能以一種離線的方式(不需要消費者、提供者同時在線),通過契約作為中間的標准,驗證提供者提供的內容是否滿足消費者的期望
關於契約測試本身,和契約測試實施的問題,我想,遠不止上面訴及的方面。不同的人、不同的團隊,對契約測試的理解也可能都不一樣,當一種新的理念在不同的現實項目中付諸實踐時,可能遇到的問題,和思考的方式又會有所迥異,這些都是我們理解一種理念的正常途徑。
參考鏈接
https://github.com/pact-foundation/pact_broker
https://github.com/DiUS/pact-jvm/tree/master/consumer/pact-jvm-consumer-junit