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

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

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

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

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

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

1、消費(fèi)者生產(chǎn)代碼
//消費(fèi)者期望從提供者處獲得的User數(shù)據(jù)類
data class User(
val name: String,
val lastName: String,
val age: String,
)
//消費(fèi)者處調(diào)用提供者獲取User對(duì)象的客戶端類
@Service
class UserClient {
fun getUser(): User {
return RestTemplate().exchange(
providerBaseUrl + "/user",
HttpMethod.GET,
HttpEntity(Headers()),
User::class.java
2、為消費(fèi)者編寫測(cè)試
@PactFolder("target/pacts") //存儲(chǔ)pact文件的位置
@ExtendWith(PactConsumerTestExt::class, SpringExtension::class)
class ConsumerContractTest {
//@Pact接受提供者名稱、消費(fèi)者名稱兩個(gè)參數(shù)
@Pact(provider = "user-provider-service", consumer = "user-consume-service")
fun userPact(builder: PactDslWithProvider): RequestResponsePact {
//使用pact DSL創(chuàng)建一個(gè)期望的響應(yīng)體樣本
val responseBody = LambdaDsl.newJsonBody { user ->
user.stringType("name", "someName")
user.stringType("age", "20")
user.stringType("lastName", "someLastName")
}
//使用pact DSL構(gòu)建請(qǐng)求流。當(dāng)提供者接收到GET /user請(qǐng)求時(shí),使用上面定義的樣本進(jìn)行響應(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()
}
//測(cè)試
//使用者向提供者M(jìn)ockServer發(fā)起請(qǐng)求,并對(duì)響應(yīng)體進(jìn)行斷言
@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、生成契約文件
一旦上面的測(cè)試通過了,就會(huì)在 target/pacts 文件夾中生成一個(gè)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是一個(gè)用于共享消費(fèi)者驅(qū)動(dòng)的契約,并驗(yàn)證結(jié)果的應(yīng)用程序,對(duì)于Pact創(chuàng)建的契約做了優(yōu)化,但也可以用于任何可以序列化為JSON的契約。
Pact Broker既支持在云上使用,也可以在本地部署。
以下是一個(gè)利用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啟動(dòng)后的界面樣子。

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

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

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

至此,已完成提供者測(cè)試,并證實(shí)了契約被正確履行。
在Broker上,可以看到契約被驗(yàn)證通過。

至此,消費(fèi)者、提供者一起完成了整個(gè)契約測(cè)試。
利用SCC進(jìn)行提供者驅(qū)動(dòng)的測(cè)試
利用Spring Cloud Contract進(jìn)行契約測(cè)試的過程示意如下。

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、提供者驗(yàn)證契約
首先創(chuàng)建一個(gè)測(cè)試基類,以便使用命令來生成測(cè)試代碼。
測(cè)試基類的作用是在每次測(cè)試運(yùn)行之前初始化提供者API和其他配置。
public class BaseTestClass {
@BeforeEach
public void setup(){
RestAssuredMockMvc.standaloneSetup(new CustomerRestController());
}
}
接下來,框架將在maven插件被構(gòu)建過程調(diào)用后,基于契約生成測(cè)試代碼。
測(cè)試代碼將向提供者發(fā)送帶有契約中示例數(shù)據(jù)的請(qǐng)求,并解析響應(yīng),根據(jù)契約驗(yàn)證響應(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、存儲(chǔ)契約
提供者生成的契約jar文件,可以發(fā)布到Nexus、JFrog等構(gòu)建庫(kù)中存儲(chǔ),同時(shí)和其他maven構(gòu)建一樣,可以通過group Id、artifact id、version等識(shí)別和獲得。
5、消費(fèi)者驗(yàn)證契約
在消費(fèi)者端,SCC框架為其提供了一個(gè)Stub Runner,它可以獲取存根定義并將其注冊(cè)到WireMock服務(wù)器。而該Mock服務(wù)器將模擬測(cè)試用例提供者的API,以支持消費(fèi)者驗(yàn)證契約。
因此,首先需要為消費(fèi)者應(yīng)用添加stub runner的maven依賴。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>
以下為消費(fèi)者契約測(cè)試代碼。
其中@AutoConfigureStubRunner用于指定契約存根的maven組件信息,包括group id、artifact id和端口號(hào)。然后stub runner就可以從本地或遠(yuǎn)程存儲(chǔ)庫(kù)獲取存根定義。
如下測(cè)試代碼將調(diào)用端口為6565的mock服務(wù)器,并對(duì)響應(yīng)進(jìn)行斷言。
@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));
}
}
最佳實(shí)踐
契約測(cè)試有如下最佳實(shí)踐。
- 契約測(cè)試的關(guān)注點(diǎn)應(yīng)該是請(qǐng)求和響應(yīng)的消息,而不是其行為
- 契約測(cè)試應(yīng)該與數(shù)據(jù)無關(guān)
- 基于Broker將整個(gè)過程與CI集成
- pact用于契約測(cè)試,而不是功能測(cè)試
- 只針對(duì)那些一旦發(fā)生變化就會(huì)影響消費(fèi)者的事情進(jìn)行斷言
- 將最新的契約提供給提供者
經(jīng)驗(yàn)教訓(xùn)
1、讓所有人都上船
契約測(cè)試需要跨團(tuán)隊(duì)協(xié)作,盡快讓各方都加入進(jìn)來,就模式和工具等各方面達(dá)成一致,否則很快就會(huì)遇到麻煩。
2、不要低估學(xué)習(xí)曲線
契約測(cè)試是一種新型的測(cè)試方法,即使擁有豐富的單元測(cè)試、集成測(cè)試等其它測(cè)試類型的豐富經(jīng)驗(yàn),也并不能代表可以編寫有價(jià)值、可維護(hù)的契約測(cè)試,真正的挑戰(zhàn)在于如何處理API和契約隨時(shí)間的變化。
3、溝通仍然是必要的
工具并不能代替彼此之間的交流,契約測(cè)試也是一樣,至少在初始階段。而在后期,消費(fèi)者更新契約之前,仍然有必要事先與提供者進(jìn)行討論。
4、Pact更好用
Spring Cloud Contract不太適合消費(fèi)者驅(qū)動(dòng)的契約測(cè)試,總是需要消費(fèi)者等待提供者完成相關(guān)工作,而Pact則不需要這種等待。
另外,SCC使用Groovy編寫的契約需要手動(dòng)與消費(fèi)者代碼保持同步,而Pact的API則相當(dāng)成熟,會(huì)自動(dòng)化生成各種代碼和文件。同時(shí)SCC在提供者端提供的的設(shè)置和斷言選項(xiàng)較少。Pact的社區(qū)支持也相對(duì)較好。
Pact對(duì)多語(yǔ)言的支持也更好。
小技巧
可以使用swagger-diff工具比較兩個(gè)版本的API
swagger-diff工具可以用來比較兩個(gè)Swagger API規(guī)范,并將結(jié)果輸出到HTML或Markdown文件中。

