領(lǐng)域驅(qū)動設(shè)計因為微服務(wù)的流行而再次火了起來,契約測試也是一樣。
為了在微服務(wù)開發(fā)模式下跨團隊協(xié)調(diào)更有效率,提升持續(xù)集成流水線自動化水平,契約測試有效彌補了集成測試的不足,強勢C位出鏡。
本文將通過逐步介紹契約測試是什么,怎么做,有哪些工具,有哪些最佳實踐和經(jīng)驗教訓,帶您一起徹底掌握契約測試。
什么是契約測試
契約測試(Contract testing)是一種測試技術(shù),它通過以隔離檢查集成點上的每個應(yīng)用的方式,確保應(yīng)用發(fā)送或接收的消息符合調(diào)用雙方共識,并允許隨著時間的推移進行演化。
為什么要做契約測試
契約測試主要解決在存在溝通邊界情況下,測試替身(Test Double)與生產(chǎn)代碼表現(xiàn)可能不一致的問題。在契約測試中,契約由代碼生成,保持與現(xiàn)實同步,而且應(yīng)用可以獨立于其它應(yīng)用而僅基于契約進行快速測試。
由于集成測試容易受到網(wǎng)絡(luò)緩慢或不可靠,以及服務(wù)不可靠等因素的影響而運行緩慢或失敗,所以通常會引入測試替身來代替真實外部服務(wù),以快速完成覆蓋度更廣的測試,讓測試真正起到作用。
但是,這樣做的同時,帶來了測試替身是否可以持續(xù)準確表示外部服務(wù)的問題。于是,需要單獨補充運行一組契約測試,來檢查所有對測試替身調(diào)用的返回結(jié)果總是與對外部服務(wù)調(diào)用的返回結(jié)果相同。
契約測試的定位
金字塔模型是構(gòu)建健康、快速、可維護測試集的成熟理論。

契約測試適合歸屬于服務(wù)測試(Service Tests)層,因為它們執(zhí)行得很快,也不需要和外部服務(wù)集成來運行。契約測試運行于發(fā)布版本之前,為成功集成提供信心。
契約測試的價值
眾所周知,越是在項目生命周期的后期發(fā)現(xiàn)Bug,其修復(fù)的成本就越高。

不同于端到端(E2E)測試,契約測試可以在開發(fā)人員推送代碼之前運行,在開發(fā)階段提早發(fā)現(xiàn)問題。
契約測試還有很多端到端測試不具備的好處:
- 不需要調(diào)用其它組件,運行得很快。
- 編寫測試不需要了解系統(tǒng)全貌,更容易維護。
- 問題只存在于被測試組件中,更容易調(diào)試和修復(fù)。
- 極易反復(fù)運行。
- 每個組件獨立測試,不會引發(fā)流水線構(gòu)建時間大幅增長。
引入契約測試,還會帶來如下福利:
- 在提供者API就緒之前就可以開發(fā)消費者應(yīng)用。
- 為提供者供應(yīng)準確的需求
- 會收獲一組文檔化良好的用例,它們確切地顯示了如何使用提供者。
- 提供者對API變更更有信息,可以準確知道使用者感興趣的字段,方便地移除未使用的字段,以及添加新的字段。
- 對提供者API進行修改,可以立即看到會影響哪些使用者。
沒有兩個團隊是完全一樣的,契約測試也不是萬能的,關(guān)鍵要看契約測試可以為團隊和項目帶來什么。
契約測試適合的場景
契約測試可以用于任何需要通信的兩個服務(wù),比如Web前端與后端API服務(wù)。
在微服務(wù)架構(gòu)體系中,因為存在更多團隊獨立、服務(wù)間調(diào)用及服務(wù)單獨演進的情形,契約測試有了更好更大的用武之地。良好的契約測試,使得開發(fā)人員很容易避免版本地獄,是微服務(wù)開發(fā)和部署的利器。
概念術(shù)語
契約測試主要涉及如下概念術(shù)語:
- 消費者(Consumer):對于調(diào)用,發(fā)起請求的一方。對于MQ,為接收消息的一方。
- 提供者(Provider):對于調(diào)用,響應(yīng)請求的一方。對于MQ,為生成消息的一方。
- 契約(Contract):消費者和提供者之間的共識,是一系列交互的集合。對于HTTP調(diào)用,包括描述消費者向提供者發(fā)送什么的預(yù)期請求,以及描述消費者希望提供者返回的最小期望響應(yīng)。對于消息交互,則描述消費者希望得到的最小期望消息。

契約測試模式
契約測試分為消費者驅(qū)動(consumer-driven)和提供者驅(qū)動(Provider-driven)兩種模式。
消費者驅(qū)動更具哲學意義,將API的消費者置于設(shè)計過程的核心,來倡導(dǎo)更好的內(nèi)部微服務(wù)設(shè)計。該模式的優(yōu)點在于,只有消費者正在使用的部分會得到測試,而提供者可以自由地更改消費者不使用的任何其它部分,而不必破壞任何現(xiàn)有測試。
提供者驅(qū)動思路較為常規(guī),更適合開放數(shù)據(jù)或系統(tǒng)的場景。
無論采用哪種風格,關(guān)鍵在于獲得契約測試的好處,實現(xiàn)引入契約測試的目的。
契約測試基本步驟
1、消費者驅(qū)動
消費者驅(qū)動的契約測試運行步驟如下:
- 消費者基于提供者的mock編寫和執(zhí)行消費者測試
- 消費者方通過消費者測試生成契約,并將契約共享給提供者
- 提供者根據(jù)契約編寫測試

2、提供者驅(qū)動
提供者驅(qū)動模式由提供者定義契約并驅(qū)動整個過程。

契約測試工具
流行的契約測試工具為:
- Pact:是一個命令行工具,反饋時間更短,有助于消費者和生產(chǎn)者之間更好地溝通。
- Spring Cloud Contract:主要用于JVM環(huán)境,也容易擴展到非JVM環(huán)境,主要適用于生產(chǎn)者驅(qū)動的契約測試。
利用Pact進行消費者驅(qū)動的測試
利用Pact進行契約測試的整個流程示意如下。

1、消費者生產(chǎn)代碼
//消費者期望從提供者處獲得的User數(shù)據(jù)類
data class User(
val name: String,
val lastName: String,
val age: String,
)
//消費者處調(diào)用提供者獲取User對象的客戶端類
@Service
class UserClient {
fun getUser(): User {
return RestTemplate().exchange(
providerBaseUrl + "/user",
HttpMethod.GET,
HttpEntity(Headers()),
User::class.java
2、為消費者編寫測試
@PactFolder("target/pacts") //存儲pact文件的位置
@ExtendWith(PactConsumerTestExt::class, SpringExtension::class)
class ConsumerContractTest {
//@Pact接受提供者名稱、消費者名稱兩個參數(shù)
@Pact(provider = "user-provider-service", consumer = "user-consume-service")
fun userPact(builder: PactDslWithProvider): RequestResponsePact {
//使用pact DSL創(chuàng)建一個期望的響應(yīng)體樣本
val responseBody = LambdaDsl.newJsonBody { user ->
user.stringType("name", "someName")
user.stringType("age", "20")
user.stringType("lastName", "someLastName")
}
//使用pact DSL構(gòu)建請求流。當提供者接收到GET /user請求時,使用上面定義的樣本進行響應(yīng)
return builder
.given("a user is present") //定義提供者狀態(tài)
.uponReceiving("a request to get user")
.pathFromProviderState("/user", "/user")
.method("GET")
.willRespondWith()
.body(responseBody.build())
.toPact()
}
//測試
//使用者向提供者MockServer發(fā)起請求,并對響應(yīng)體進行斷言
@Test
fun `should return user`(mockServer: MockServer) {
val url = mockServer.getUrl() + "/user"
val user = UserClient().getUser(url)
//斷言key,而不是value,提升健壯性
assertTrue(user.hasProperty("age"))
assertTrue(user.hasProperty("name"))
assertTrue(user.hasProperty("lastName"))
}
}
3、生成契約文件
一旦上面的測試通過了,就會在 target/pacts 文件夾中生成一個pact契約文件,文件名稱為user-consume-service-user-provider-service.json,文件內(nèi)容如下:
{
"provider": {
"name": "user-provider-service"
},
"consumer": {
"name": "user-consume-service"
},
"interactions": [
{
"description": "a request to get user",
"request": {
"method": "GET",
"path": "/user",
"generators": {
"path": {
"type": "ProviderState",
"expression": "/user",
"dataType": "STRING"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json; charset=UTF-8"
},
"body": {
"lastName": "someLastName",
"name": "someName",
"age": "20"
},
"matchingRules": {
"body": {
"$.name": {
"matchers": [
{
"match": "type"
}
],
"combine": "AND"
},
"$.age": {
"matchers": [
{
"match": "type"
}
],
"combine": "AND"
},
"$.lastName": {
"matchers": [
{
"match": "type"
}
],
"combine": "AND"
}
},
"header": {
"Content-Type": {
"matchers": [
{
"match": "regex",
"regex": "application/json(;\\s?charset=[\\w\\-]+)?"
}
],
"combine": "AND"
}
}
}
},
"providerStates": [
{
"name": "a user is present"
}
]
}
],
"metadata": {
"pactSpecification": {
"version": "3.0.0"
},
"pact-jvm": {
"version": "4.1.9"
}
}
}
4、利用Broker共享契約
Pact Broker是一個用于共享消費者驅(qū)動的契約,并驗證結(jié)果的應(yīng)用程序,對于Pact創(chuàng)建的契約做了優(yōu)化,但也可以用于任何可以序列化為JSON的契約。
Pact Broker既支持在云上使用,也可以在本地部署。
以下是一個利用Docker Compose來本地部署Pack Broker的描述文件。
version: '3'
services:
postgres:
image: postgres
healthcheck:
test: psql postgres --command "select 1" -U postgres
ports:
- "5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
broker_app:
image: dius/pact-broker
ports:
- "80:80"
links:
- postgres
environment:
PACT_BROKER_DATABASE_USERNAME: postgres
PACT_BROKER_DATABASE_PASSWORD: password
PACT_BROKER_DATABASE_HOST: postgres
PACT_BROKER_DATABASE_NAME: postgres
PACT_BROKER_LOG_LEVEL: DEBUG
以下是Pact Broker啟動后的界面樣子。

接下來就可以使用pack-jvm的pactPublish命令將契約文件發(fā)布到Broker。
首先在build.gradle文件中添加需要的配置。
pact {
publish {
pactBrokerUrl = "http://localhost:80"
pactDirectory = "target/pacts"
}
}
然后,運行如下命令發(fā)布契約。
命令執(zhí)行成功后,即可在Pact Broker上看到已發(fā)布的契約。

同時,可以在Broker上查看契約細節(jié)。

至此,消費者方已完成契約創(chuàng)建、發(fā)布等全部工作。
5、提供者端驗證
如下為提供者的生產(chǎn)代碼。
@RestController
class UserService {
@GetMapping("/user")
fun getUser(): Map<String, String> {
return mapOf(
"name" to "Foo",
"lastName" to "Bar",
"age" to "22"
)
}
}
以下代碼用于提供者對契約的驗證。
@RunWith(SpringRestPactRunner::class)
@Provider("user-provider-service") //提供者名稱
@PactBroker(
host = "localhost",
port = "80",
scheme = "http",
consumers = ["user-consume-service"],
) //Pact Broker及消費者信息
@SpringBootTest(classes = [PactproviderApplication::class], webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, properties = ["server.port=8080"])
class UserServiceProviderTests {
@TestTarget
val target: HttpTarget = HttpTarget("localhost", 8080)
@MockkBean
lateinit var userService: UserService
@Before
fun setup() {
//如下配置用于讓 pact 在測試完成后,將測試結(jié)果發(fā)送到 broker
System.setProperty("pact.verifier.publishResults", "true")
}
@Test
@State("a user is present") //對應(yīng)于契約中的提供者狀態(tài),以及消費者測試中的 given
fun `should have a customer`() {
//以下用于定義 userService 的 mock 行為
every { userService.getUser() } returns mapOf(
"name" to "someName",
"lastName" to "someLastName",
"age" to "22"
)
}
}
以下為測試運行結(jié)果的樣子。

至此,已完成提供者測試,并證實了契約被正確履行。
在Broker上,可以看到契約被驗證通過。

至此,消費者、提供者一起完成了整個契約測試。
利用SCC進行提供者驅(qū)動的測試
利用Spring Cloud Contract進行契約測試的過程示意如下。

SCC各組件相互關(guān)系描述如下。

1、提供者添加SCC依賴和maven插件
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<extensions>true</extensions>
</plugin>
2、提供者定義契約
SCC允許通過groovy、yaml、代碼等多種方式定義契約。
契約中包含在body中定義的消息示例及在matcers中定義的字段匹配器等。
request:
method: PUT
url: /customers
body:
name: John Don
Phone: 1234567890
headers:
Content-Type: application/json
matchers:
body:
- path: $.['name']
type: by_regex
regexType: as_string
- path: $.['phone']
type: by_regex
value: "[0-9]{10}"
response:
status: 200
body:
reference: 1122334455
headers:
Content-Type: application/json
matchers:
body:
- path: $.['reference']
type: by_regex
value: "[0-9]{5}"
3、提供者驗證契約
首先創(chuàng)建一個測試基類,以便使用命令來生成測試代碼。
測試基類的作用是在每次測試運行之前初始化提供者API和其他配置。
public class BaseTestClass {
@BeforeEach
public void setup(){
RestAssuredMockMvc.standaloneSetup(new CustomerRestController());
}
}
接下來,框架將在maven插件被構(gòu)建過程調(diào)用后,基于契約生成測試代碼。
測試代碼將向提供者發(fā)送帶有契約中示例數(shù)據(jù)的請求,并解析響應(yīng),根據(jù)契約驗證響應(yīng)。
@Test
void validate_shouldCreateCustomer() throws Exception {
// given:
MockMvcRequestSpecification request = given()
.header("Content-Type", "application/json")
.body("{\"name\":\"John Don\",\"phone\":1234567890}");
// when:
ResponseOptions response = given().spec(request)
.put("/customers");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("['reference']").matches("[0-9]{5}");
}
4、存儲契約
提供者生成的契約jar文件,可以發(fā)布到Nexus、JFrog等構(gòu)建庫中存儲,同時和其他maven構(gòu)建一樣,可以通過group Id、artifact id、version等識別和獲得。
5、消費者驗證契約
在消費者端,SCC框架為其提供了一個Stub Runner,它可以獲取存根定義并將其注冊到WireMock服務(wù)器。而該Mock服務(wù)器將模擬測試用例提供者的API,以支持消費者驗證契約。
因此,首先需要為消費者應(yīng)用添加stub runner的maven依賴。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>
以下為消費者契約測試代碼。
其中@AutoConfigureStubRunner用于指定契約存根的maven組件信息,包括group id、artifact id和端口號。然后stub runner就可以從本地或遠程存儲庫獲取存根定義。
如下測試代碼將調(diào)用端口為6565的mock服務(wù)器,并對響應(yīng)進行斷言。
@SpringJUnitConfig(CustomerClient.class)
@AutoConfigureStubRunner(ids = {"space.gavinklfong.demo:customer-service:+:stubs:6565"},
stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class CustomerClientIntegrationTests {
private CustomerClient customerClient;
@BeforeEach
void setup(){
customerClient = new CustomerClient(“http://localhost:6565”);
}
@Test
void testCustomerCreation(){
Customer customer = new Customer(“Peter Pan”, 2233445566);
String ref = customerClient.create(customer);
assertNotNull(ref);
assertEquals(5, ref.length());
assertTrue(isNumeric(ref));
}
}
最佳實踐
契約測試有如下最佳實踐。
- 契約測試的關(guān)注點應(yīng)該是請求和響應(yīng)的消息,而不是其行為
- 契約測試應(yīng)該與數(shù)據(jù)無關(guān)
- 基于Broker將整個過程與CI集成
- pact用于契約測試,而不是功能測試
- 只針對那些一旦發(fā)生變化就會影響消費者的事情進行斷言
- 將最新的契約提供給提供者
經(jīng)驗教訓
1、讓所有人都上船
契約測試需要跨團隊協(xié)作,盡快讓各方都加入進來,就模式和工具等各方面達成一致,否則很快就會遇到麻煩。
2、不要低估學習曲線
契約測試是一種新型的測試方法,即使擁有豐富的單元測試、集成測試等其它測試類型的豐富經(jīng)驗,也并不能代表可以編寫有價值、可維護的契約測試,真正的挑戰(zhàn)在于如何處理API和契約隨時間的變化。
3、溝通仍然是必要的
工具并不能代替彼此之間的交流,契約測試也是一樣,至少在初始階段。而在后期,消費者更新契約之前,仍然有必要事先與提供者進行討論。
4、Pact更好用
Spring Cloud Contract不太適合消費者驅(qū)動的契約測試,總是需要消費者等待提供者完成相關(guān)工作,而Pact則不需要這種等待。
另外,SCC使用Groovy編寫的契約需要手動與消費者代碼保持同步,而Pact的API則相當成熟,會自動化生成各種代碼和文件。同時SCC在提供者端提供的的設(shè)置和斷言選項較少。Pact的社區(qū)支持也相對較好。
Pact對多語言的支持也更好。
小技巧
可以使用swagger-diff工具比較兩個版本的API
swagger-diff工具可以用來比較兩個Swagger API規(guī)范,并將結(jié)果輸出到HTML或Markdown文件中。

