契約測試之Pact
1. 前言
有近兩週沒有在公眾號中發表文章了,看過我之前公眾號的讀者都知道,公眾號中近期在連載《RobotFramework介面自動化系列課程》,原本計劃每週更新一篇,最近由於博主在帶一個新專案,實在是沒空抽出時間來,所以向公眾號中對連載課程一直期待的讀者說聲抱歉。
由於最近帶微服務的專案,而對於微服務其實也是近從14年才流行起來,對於這塊目前的乾貨內容還是較少,藉著機會,小結一下知識點。所以今天也先不打算連載《RobotFramework介面自動化系列課程》,如果讀者對連載的課程比較熱衷的話,可以在留言板下面給筆者留言,如果讀者反饋較多的話,博主也會適當加快調整課程分享節奏。
下面給大家淺聊一下微服務架構下的契約測試。
2. 微服務特點
Microservice微服務是一種架構風格,我們可以把每一個微服務視做一個用一組API提供業務功能的元件,且服務之間會有很多依賴關係,如下圖所示:
這些服務之間可能由一個團隊或者相互獨立的團隊開發和維護,並且它們在系統內部相互依賴,在這種情況下,介面的開發和維護可能會帶來一些問題,例如服務端調整架構或介面調整而對消費者不透明,導致介面呼叫失敗。
3. 微服務下的測試現狀
例如, 我們想測試某微服務架構中的某一個服務時,比如下圖第一排中間的服務,如:
因為它和其他服務都存在互動,一般我們有兩種方式:
- 部署所有的服務來實現端到端測試。
- 在整合測試中Mock其他服務。
下面分析一下這兩種方式優缺點:
對比分析 | 優點 | 缺點 |
---|---|---|
第一種方式:部署所有服務 | 1、模擬生成環境 2、可以真實地測試服務互動 | 1、測試其中一個服務,不是不佈署全部服務,包括各基礎設施 2、要執行很長時間 3、不能及時給予測試反饋 4、測試環境被一個測試服務鎖定,別人無法同時使用。 |
第二種方式:Mock其它服務 | 1、測試反饋快 2、沒有基礎服務依賴要求 | 1、服務的實現方建立的Stubs,可能實現與這個無關 2、無法模擬真實資料互動環境 |
4. 微服務下的開發現狀
常規我們開發的專案主要由服務提供方約定介面,雖然提供方架構調整或改變介面之前通常會通知消費者,但可能還是會存在遺漏。
當一個Service已經同時被多個使用者呼叫用的時候,怎麼保證service的修改對其它所有使用者造成影響被感知到呢?
5. 什麼是契約測試
契約測試 ,又稱之為 消費者驅動的契約測試(Consumer-Driven Contracts,簡稱CDC),根據 消費者驅動契約 ,我們可以將服務分為消費者端和生產者端,而消費者驅動的契約測試的核心思想在於是從消費者業務實現的角度出發,由消費者自己會定義需要的資料格式以及互動細節,並驅動生成一份契約檔案。然後生產者根據契約檔案來實現自己的邏輯,並在持續整合環境中持續驗證。
後文中消費者驅動的契約測試統一用cdc來代替。
5.1 cdc核心原則
- cdc是以消費者提出介面契約,交由服務提供方實現,並以測試用例對契約進行產生約束,所以服務提供方在滿足測試用例的情況下可以自行更改介面或架構實現而不影響消費者。
- cdc是一種針對外部服務的介面進行的測試,它能夠驗證服務是否滿足消費方期待的契約。 它的本質是從利益相關者的目標和動機出發,最大限度地滿足需求方的業務價值實現。
5.2 常見測試框架
業界常用的CDC測試框架有:
- Janus
- Pact
- Pacto
- Spring Cloud Contract
6、契約測試、單元測試、介面測試之間的區別
- API測試和單元測試,更強調的是覆蓋API內部邏輯。
- 契約測試,更強調是元件之間連線的正確性,除了保證元件內部,還要保證元件間的呼叫是正確的,也就是服務API之間的呼叫。
型別 | 描述 |
---|---|
單元測試 | 單元測試針對程式碼單元(通常是類)的測試,單元測試的價值在於能提供最快的反饋。另外好的單元測試還可以幫助你改善設計,在你的團隊掌握TDD的前提下,單元測試能輔助重構,幫助改善程式碼整潔度。 |
API測試 | API測試是針對業務介面進行的測試,主要測內部介面功能實現是否完整,比如說內部邏輯是不是正常,異常處理是不是正確。 |
契約測試 | 契約測試其實是為了測試服務之間連線或者說介面呼叫的正確性,為了驗證服務提供者的功能是不是真正能夠滿足消費者的需求。它其實體現了測試前移的思想,把本來要通過整合測試才能驗證的工作化作單元測試和介面測試,用更輕量的方式快速進行驗證。 |
整合測試 | 它從使用者的角度驗證整個功能的正確性,測的是端到端的流程,並且加入使用者場景和資料,驗證整個過程是不是OK,它的價值業務價值最高,是驗證一個完整的流程。 |
7. 契約測試能解決什麼問題
- 聯調成本過高,要雙方開發到某一階段後放在同一個環境上才能進行,要同時把握雙方的進度,造成資源和時間上的浪費。
- 對於介面的變動把控相當困難。由於介面變動是普遍存在的,尤其對於呼叫關係複雜的介面,一旦發生變動,如果沒有一套機制進行控制,驗證的成本巨大。 -“介面不匹配”是指服務依賴於彼此間的介面進行通訊,如何保證改變一個服務的介面會對其他所有依賴服務是否造成造成影響。
- 在發生契約變化時,提供一種可立即被服務端和消費端發現的方式。
8. 契約測試能給我們帶來什麼
- 降低服務整合的難度,把服務整合這個過程分解成了單元測試和介面測試來做,它從消費者的需求為出發點,把消費者的需求作為你的測試用例驅動出一份契約,然後驗證提供者端的功能
- 通過使用契約測試,介面呼叫雙方協商介面後就可以並行開發,並且在開發過程中就利用契約進行預整合測試,不用等到聯調再來整合調通介面,一旦成熟,在保證質量的前提下,聯調的成本可以減低到幾乎為0。
- 因為契約的存在,讓介面的變動有跡可循,即使變動也可以確保變動的安全性和準確性。
- 通過契約測試,團隊能以一種離線的方式(不需要消費者、提供者同時線上),通過契約作為中間的標準,驗證提供者提供的內容是否滿足消費者的期望。
9. 契約測試工具之Pact
9.1 Pact 術語介紹
- Consumer: 微服務介面的呼叫者
- Provider: 微服務介面的提供者
- 契約檔案: 是由consumer端和provider端共同定義的介面規範,包括介面訪問的路徑,輸入和輸出資料。在具體的實施中,是由consumer端生成的一個json檔案,並存放在pact broker上
- Pact Broker: 儲存契約檔案的伺服器
注:通常在工程實踐上,當消費者根據需要生成了契約之後,我們會將契約上傳至一個公共可訪問的地址,然後生產者在執行時會訪問這個地址,並獲得最新版本的契約,然後對著這些契約來執行相應的驗證過程。
9.2 Pact基本流程
- 第一步在消費者端Consumer端寫一個對介面傳送請求的單元測試,在執行這個單元測試的時候,Pact會將服務提供者自動用一個MockService代替,並自動生成契約檔案,這個契約檔案是Json形式的。
- 第二步在Provider端做契約驗證測試,將Provider服務啟動起來以後,通過pact外掛可以執行一個命令,比如你是用maven,就是mvn pact:verify,它會自動按照契約生成介面請求並驗證介面響應是否滿足契約中的預期,所以可以看到這個過程中,在消費者端不用啟動Provider,在服務提供端不用啟動Consumer,卻完成了與整合測試類似的驗證。
基於消費者的業務邏輯,驅動出契約 其實現步驟如下所示: 1、使用Pact的DSL,定義Mock提供者,如localhost:8080 2、將Mock地址傳給消費者並對Mock的提供者傳送請求。 3、使用Pact的DSL,定義響應內容(包括Headers、Status以及Body等)。 4、在消費者端 使用@PactVerification執行單元測試(Pact集成了JUnit、RSpec等框架),生成契約檔案。 5、當執行測試後,Pact框架記錄消費者的名稱、傳送的請求、期望的響應以及元資料,將其儲存為當前場景下的契約檔案,通常命名為[Consumer]-[Provider].json,例如 orderConsumer-orderProvider.json 6、契約檔案生成後,我們可以將其儲存在檔案系統或者Pact-Broker(Pact提供的中介軟體,用來管理契約檔案)中,以便後續提供者使用。
基於消費者驅動出的契約,對提供者進行驗證 在提供者端,我們不需要寫任何驗證的相關程式碼,Pact已經提供了驗證的介面,我們只需要做好如下配置: 1、為提供者指定契約檔案的儲存源(如檔案系統或者Pact-Broker)。 2、啟動提供者,執行PactVerify(Pact有Maven、Gradle或者Rake外掛,提供pactVerify命令)。 3、當執行pactVerify時,Pact將按照如下步驟,自動完成對提供者的驗證: 4、構建Mock的消費者。 5、根據契約檔案記錄的請求內容,向提供者傳送請求。 6、從提供者獲取響應結果。 7、驗證提供者的響應結果與Pact契約檔案定義的契約中是否一致。
9.3 Pact 特性
傳統情況下做整合測試需要把服務消費者和服務提供者兩個服務都啟動起來再進行測試,而Pact做契約測試時將它分成兩步來做,每一步裡面都不需要同時啟動兩個服務。
-
測試解耦,就是服務消費與提供者解耦,甚至可以在沒有提供者實現的情況下開始消費者的測試。
-
一致性,通過測試保證契約與現實是一致性的。
-
測試前移,可以在開發階段執行,並作為CI的一部分,甚至在開發本地就可以去做,而且可以看到一條命令就可以完成,便於儘早發現問題,降低解決問題的成本。
-
Pact提供的Pact Broker 可以自動生成一個服務呼叫關係圖,為團隊提供了全域性的服務依賴關係圖。
-
Pact提供Pact Broker這個工具來完成契約檔案管理,使用Pact Broker後,契約上傳與驗證都可以通過命令完成,且契約檔案可以制定版本。
-
降低服務間的整合測試成本,儘早驗證當提供者介面被修改時,是否破壞了消費者的期望。
-
目前僅支援採用REST通訊協議。
Pact實戰
consumer程式碼例項:
•Maven依賴
<dependency> <groupId>au.com.dius</groupId> <artifactId>pact-jvm-consumer-junit_2.11</artifactId> <version>3.5.21</version> <scope>test</scope> </dependency>
程式碼:
/** * Created by jackeymm on 2018/9/7. */ @RunWith(SpringRunner.class) public class DomainConsumerPactTest {
@Rule public PactProviderRuleMk2 stubProvider = new PactProviderRuleMk2("domain", this);
@DefaultRequestValues public void defaultRequestValues(PactDslRequestWithoutPath request){ request.headers(singletonMap(CONTENT_TYPE, APPLICATION_JSON_VALUE)); }
@DefaultResponseValues public void defaultResponseValues(PactDslResponse response){ response.headers(singletonMap(CONTENT_TYPE, APPLICATION_JSON_VALUE)); }
@Pact(consumer = "dispatcher") public RequestResponsePact createDomainNotExistPact(PactDslWithProvider builder) throws JSONException{ return builder .given("CreateDomain - domain does not exist") .uponReceiving("Normal request") .path("/domain/create") .method(HttpMethod.POST.name()) .body(new JSONObject() .put("domain","a.com") ) .willRespondWith() .status(OK.value()) .body(new PactDslJsonBody() .numberValue("code", 0) .object("data") .integerType("version") .closeObject() ) .toPact(); }
@Pact(consumer = "dispatcher") public RequestResponsePact createIllegalRequestPact(PactDslWithProvider builder) throws JSONException { return builder .given("CreateDomain - Illegal request param") .uponReceiving("Illegal request") .path("/domain/create") .method(HttpMethod.POST.name()) .body(new JSONObject() .put("pubKey","pk") .toString() ) .willRespondWith() .status(BAD_REQUEST.value()) .body(new PactDslJsonBody() .numberValue("code", BAD_REQUEST.value()) .stringType("message","param is invalid") ) .toPact();
}
@Test @PactVerification(fragment = "createDomainNotExistPact") public void verifyCreateDomainNotExistPact() throws Exception { given() .contentType(APPLICATION_JSON_VALUE) .body(new JSONObject() .put("domain","a.com") .toString() ) .when() .post(stubProvider.getUrl() + "/domain/create") .then() .statusCode(OK.value()) .body("code", is(0)); }
@Test @PactVerification(fragment = "createIllegalRequestPact") public void verifyCreateIllegalRequestPact() throws Exception{ given() .contentType(APPLICATION_JSON_VALUE) .body(new JSONObject() .put("pubKey","pubk1") .toString() ) .when() .post(stubProvider.getUrl() + "/domain/create") .then() .statusCode(BAD_REQUEST.value()) .body("code", is(BAD_REQUEST.value())); }
}
•Pact檔案釋出
ØMaven依賴
<plugin> <groupId>au.com.dius</groupId> <artifactId>pact-jvm-provider-maven_2.12</artifactId> <version>3.5.21</version> <configuration> <pactBrokerUrl>http://127.0.0.1:8888/</pactBrokerUrl> </configuration> </plugin>
Ø執行命令
mvn clean test pact:publish -Dtest=com.domain.DomainConsumerPactTest
provider程式碼示例:
•Maven依賴
<dependency> <groupId>au.com.dius</groupId> <artifactId>pact-jvm-provider-junit_2.11</artifactId> <version>3.5.21</version> <scope>test</scope> </dependency> <dependency> <groupId>au.com.dius</groupId> <artifactId>pact-jvm-provider-spring_2.11</artifactId> <version>3.5.21</version> <scope>test</scope> </dependency>
程式碼:
/** * Created by jackeymm on 2018/9/7. */ @RunWith(SpringRestPactRunner.class) @Provider("domain") @PactBroker(host = "127.0.0.1", port = "8888") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class DomainProviderPactTest { @TestTarget public final Target target = new SpringBootHttpTarget();
@MockBean private DomainMapper domainMapper;
@State("CreateDomain - domain does not exist") public void runDomainNotExistState(){ when(domainMapper.save(any(Domain.class))) .thenReturn(1); }
@State("CreateDomain - Illegal request param") public void runDomainIllegalState(){ }}