如今罐旗,契約測試已經(jīng)逐漸成為測試圈中一個炙手可熱的話題士八,特別是在微服務(wù)大行其道的行業(yè)背景下健田,越來越多的團隊開始關(guān)注服務(wù)之間的契約及其契約測試娄昆。
從2015年開始我就在Thoughtworks和QA Community里推廣契約測試佩微,收到了不錯的成效缝彬,期間有不少同學(xué)和我討論過如何上手契約測試萌焰,發(fā)現(xiàn)網(wǎng)上介紹契約測試的講義、博客不乏其數(shù)(當(dāng)然谷浅,質(zhì)量也參差不齊)扒俯,可手把手教你寫契約測試的文章卻幾乎沒有奶卓,原因恐怕就是契約測試的特性吧。契約測試是架設(shè)在消費者和服務(wù)者之間的一種比較特殊的測試活動撼玄,如果你只是想自己學(xué)習(xí)而又沒有合適的項目環(huán)境夺姑,那你得自己先準(zhǔn)備適當(dāng)?shù)南M者和服務(wù)者程序源代碼,然后再開始寫契約測試掌猛,而不是像寫個Selenium測試那樣盏浙,兩三行代碼就可以隨隨便便地調(diào)戲度娘±蟛纾~( ̄▽ ̄~)
所以废膘,我花了些時間磨嘰出了這片文章……
本文不會涉及契約測試的基本概念,因為相應(yīng)的文章網(wǎng)上太多了慕蔚,請大家自己去撈吧丐黄。本文也不會討論契約測試的使用場景以及對其的正確理解,這方面的話題請閱讀我后來的文章《契約測試之核心解惑》
OK孔飒,以下開始正文灌闺!
契約測試的精髓在于消費者驅(qū)動,實踐消費者驅(qū)動契約測試的工具主要有Pact坏瞄,Pacto 和 Spring Cloud Contract桂对,其中Pact是目前最為推薦的,我下面的例子都將使用Pact來練習(xí)鸠匀。Pact最早是用Ruby實現(xiàn)的接校,目前已經(jīng)擴展支撐Java,.NET狮崩,Javascript蛛勉,Go,Swift睦柴,Python和PHP诽凌,其中Java(JVM)是我們目前項目中使用最頻繁的,所以我的例子亦都是基于PACT JVM來實現(xiàn)(觀眾A:我們都用Pyhton坦敌,你丫給我說Java(╯°□°)╯︵┻━┻)
示例源碼
大家可以從Github上獲取本文示例的源碼侣诵,也可以從PACT JVM官網(wǎng)上面找到對應(yīng)的PACT-JVM-Example的鏈接
示例應(yīng)用
示例應(yīng)用非常簡單,一個服務(wù)提供者Provider狱窘,兩個服務(wù)消費者Miku和Nanoha(啥杜顺?你不知道Miku和Nanoha是什么?......問度娘吧......(~o ̄3 ̄)~......)蘸炸。
Provider
Provider是一個簡單的API躬络,返回一些個人信息。
啟動Provider:
./gradlew :example-provider:bootRun
然后訪問 http://localhost:8080/information?name=Miku
或者訪問 http://localhost:8080/information?name=Nanoha
消費者 Miku & Nanoha
Miku和Nanoha調(diào)用Provider的API拿到各自的數(shù)據(jù)搭儒,然后顯示在前端頁面上穷当。
啟動Miku:
./gradlew :example-consumer-miku:bootRun
然后用瀏覽器訪問 http://localhost:8081/miku
啟動Nanoha:
./gradlew :example-consumer-nanoha:bootRun
然后用瀏覽器訪問 http://localhost:8082/nanoha
Miku和Nanoha做的事情基本一樣提茁,差別就是Nanoha會去多拿.nationality
這個字段,而.salary
這個字段Miku和Nanoha都沒有用到馁菜。
示例中的1個Provider和2個Consumer都在一個codebase里面茴扁,這只是為了方便管理示例代碼,而實際的項目中汪疮,絕大多數(shù)的Provider和Consumer都是在不同的codebase里面管理的峭火,請注意喲!
Provider與Miku間的契約測試
好了智嚷,大概了解示例應(yīng)用之后躲胳,我們就可以開始寫契約測試了(當(dāng)然,如果你還想再撩撩示例的源碼纤勒,也是可以的啦坯苹,不過相信我,里面沒多少油水的)
我們先從Provider和Miku之間
的契約測試開始摇天。
請注意"之間"這個關(guān)鍵詞粹湃,當(dāng)我們談?wù)撈跫s測試時,一定要明確它是建立在某一對Provider和Consumer之間的測試活動泉坐。沒有Provider为鳄,Consumer做不了契約測試;沒有Consumer腕让,Provider不需要做契約測試孤钦。
編寫消費者Miku端的測試案例
目前,PACT JVM在消費者端的契約測試主要有三種寫法:
- 基本的Junit
- Junit Rule
- Junit DSL
它們都能完成消費者端契約文件的生成纯丸,只是寫法有所不同偏形,帶來的代碼簡潔度和部分功能有些許差異。
所有的契約測試代碼都已經(jīng)寫好了觉鼻,你可以在
src/test/java/ariman/pact/consumer
下面找到俊扭。
基本的Junit
“talk is cheap, show you the code”
PactBaseConsumerTest.java
public class PactBaseConsumerTest extends ConsumerPactTestMk2 {
@Override
@Pact(provider="ExampleProvider", consumer="BaseConsumer")
public RequestResponsePact createPact(PactDslWithProvider builder) {
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json;charset=UTF-8");
return builder
.given("")
.uponReceiving("Pact JVM example Pact interaction")
.path("/information")
.query("fullName=Miku")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body("{\n" +
" \"salary\": 45000,\n" +
" \"fullName\": \"Hatsune Miku\",\n" +
" \"nationality\": \"Japan\",\n" +
" \"contact\": {\n" +
" \"Email\": \"hatsune.miku@ariman.com\",\n" +
" \"Phone Number\": \"9090950\"\n" +
" }\n" +
"}")
.toPact();
}
@Override
protected String providerName() {
return "ExampleProvider";
}
@Override
protected String consumerName() {
return "BaseConsumer";
}
@Override
protected void runTest(MockServer mockServer) throws IOException {
ProviderHandler providerHandler = new ProviderHandler();
providerHandler.setBackendURL(mockServer.getUrl());
Information information = providerHandler.getInformation();
assertEquals(information.getName(), "Hatsune Miku");
}
}
這里的關(guān)鍵是createPact
和runTest
這兩個方法:
-
createPact
直接定義了契約交互的全部內(nèi)容,比如Request的路徑和參數(shù)坠陈,以及返回的Response的具體內(nèi)容萨惑; -
runTest
是執(zhí)行測試的方法,其中ProviderHandler
是Miku應(yīng)用代碼中的類仇矾,我們直接使用它來發(fā)送真正的Request庸蔼,發(fā)給誰呢?發(fā)給mockServer
贮匕,Pact會啟動一個mockServer, 基于Java原生的HttpServer封裝姐仅,用來代替真正的Provider應(yīng)答createPact
中定義好的響應(yīng)內(nèi)容,繼而模擬了整個契約的內(nèi)容; - runTest中的斷言可以用來保證我們編寫的契約內(nèi)容是符合Miku期望的萍嬉,你可以把它理解為一種類似Consumer端的集成測試乌昔;
Junit Rule
PactJunitRuleTest.java
public class PactJunitRuleTest {
@Rule
public PactProviderRuleMk2 mockProvider = new PactProviderRuleMk2("ExampleProvider",this);
@Pact(consumer="JunitRuleConsumer")
public RequestResponsePact createPact(PactDslWithProvider builder) {
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json;charset=UTF-8");
return builder
.given("")
.uponReceiving("Pact JVM example Pact interaction")
.path("/information")
.query("fullName=Miku")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body("{\n" +
" \"salary\": 45000,\n" +
" \"fullName\": \"Hatsune Miku\",\n" +
" \"nationality\": \"Japan\",\n" +
" \"contact\": {\n" +
" \"Email\": \"hatsune.miku@ariman.com\",\n" +
" \"Phone Number\": \"9090950\"\n" +
" }\n" +
"}")
.toPact();
}
@Test
@PactVerification
public void runTest() {
ProviderHandler providerHandler = new ProviderHandler();
providerHandler.setBackendURL(mockProvider.getUrl());
Information information = providerHandler.getInformation();
assertEquals(information.getName(), "Hatsune Miku");
}
}
相較于基本的Junit寫法隙疚,PactProviderRuleMk2
能夠讓代碼更加的簡潔壤追,它還可以自定義Mock Provider的address和port。如果像上面的代碼一樣省略address和port供屉,則會默認(rèn)使用127.0.0.1和隨機端口行冰。Junit Rule還提供了方法讓你可以同時對多個Provider進行測試,以及讓Mock Provider使用HTTPS進行交互伶丐。
基于體力有限悼做,本示例沒有包含MultiProviders和HTTPS的例子,有需要的同學(xué)可以在PACT JVM官網(wǎng)上查詢相關(guān)的用法......別哗魂,別打呀肛走,俺承認(rèn),俺就是懶...還打...#
%^&...喂:110嗎录别?俺要報警......
Junit DSL
PactJunitDSLTest
public class PactJunitDSLTest {
private void checkResult(PactVerificationResult result) {
if (result instanceof PactVerificationResult.Error) {
throw new RuntimeException(((PactVerificationResult.Error)result).getError());
}
assertEquals(PactVerificationResult.Ok.INSTANCE, result);
}
@Test
public void testPact1() {
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json;charset=UTF-8");
RequestResponsePact pact = ConsumerPactBuilder
.consumer("JunitDSLConsumer1")
.hasPactWith("ExampleProvider")
.given("")
.uponReceiving("Query fullName is Miku")
.path("/information")
.query("fullName=Miku")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body("{\n" +
" \"salary\": 45000,\n" +
" \"fullName\": \"Hatsune Miku\",\n" +
" \"nationality\": \"Japan\",\n" +
" \"contact\": {\n" +
" \"Email\": \"hatsune.miku@ariman.com\",\n" +
" \"Phone Number\": \"9090950\"\n" +
" }\n" +
"}")
.toPact();
MockProviderConfig config = MockProviderConfig.createDefault();
PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
ProviderHandler providerHandler = new ProviderHandler();
providerHandler.setBackendURL(mockServer.getUrl(), "Miku");
Information information = providerHandler.getInformation();
assertEquals(information.getName(), "Hatsune Miku");
});
checkResult(result);
}
@Test
public void testPact2() {
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json;charset=UTF-8");
RequestResponsePact pact = ConsumerPactBuilder
.consumer("JunitDSLConsumer2")
.hasPactWith("ExampleProvider")
.given("")
.uponReceiving("Query fullName is Nanoha")
.path("/information")
.query("fullName=Nanoha")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body("{\n" +
" \"salary\": 80000,\n" +
" \"fullName\": \"Takamachi Nanoha\",\n" +
" \"nationality\": \"Japan\",\n" +
" \"contact\": {\n" +
" \"Email\": \"takamachi.nanoha@ariman.com\",\n" +
" \"Phone Number\": \"9090940\"\n" +
" }\n" +
"}")
.toPact();
MockProviderConfig config = MockProviderConfig.createDefault();
PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
ProviderHandler providerHandler = new ProviderHandler();
providerHandler.setBackendURL(mockServer.getUrl(), "Nanoha");
Information information = providerHandler.getInformation();
assertEquals(information.getName(), "Takamachi Nanoha");
});
checkResult(result);
}
}
基本的Junit和Junit Rule的寫法只能在一個測試文件里面寫一個Test Case,而使用Junit DSL則可以像上面的例子一樣寫多個Test Case。同樣仆救,你也可以通過MockProviderConfig.createDefault()
配置Mock Server的address和port础米。上面的例子使用了默認(rèn)配置。
PactJunitDSLJsonBodyTest
public class PactJunitDSLJsonBodyTest {
PactSpecVersion pactSpecVersion;
private void checkResult(PactVerificationResult result) {
if (result instanceof PactVerificationResult.Error) {
throw new RuntimeException(((PactVerificationResult.Error)result).getError());
}
assertEquals(PactVerificationResult.Ok.INSTANCE, result);
}
@Test
public void testWithPactDSLJsonBody() {
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json;charset=UTF-8");
DslPart body = new PactDslJsonBody()
.numberType("salary", 45000)
.stringType("fullName", "Hatsune Miku")
.stringType("nationality", "Japan")
.object("contact")
.stringValue("Email", "hatsune.miku@ariman.com")
.stringValue("Phone Number", "9090950")
.closeObject();
RequestResponsePact pact = ConsumerPactBuilder
.consumer("JunitDSLJsonBodyConsumer")
.hasPactWith("ExampleProvider")
.given("")
.uponReceiving("Query fullName is Miku")
.path("/information")
.query("fullName=Miku")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body(body)
.toPact();
MockProviderConfig config = MockProviderConfig.createDefault(this.pactSpecVersion.V3);
PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
ProviderHandler providerHandler = new ProviderHandler();
providerHandler.setBackendURL(mockServer.getUrl());
Information information = providerHandler.getInformation();
assertEquals(information.getName(), "Hatsune Miku");
});
checkResult(result);
}
@Test
public void testWithLambdaDSLJsonBody() {
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json;charset=UTF-8");
DslPart body = newJsonBody((root) -> {
root.numberValue("salary", 45000);
root.stringValue("fullName", "Hatsune Miku");
root.stringValue("nationality", "Japan");
root.object("contact", (contactObject) -> {
contactObject.stringMatcher("Email", ".*@ariman.com", "hatsune.miku@ariman.com");
contactObject.stringType("Phone Number", "9090950");
});
}).build();
RequestResponsePact pact = ConsumerPactBuilder
.consumer("JunitDSLLambdaJsonBodyConsumer")
.hasPactWith("ExampleProvider")
.given("")
.uponReceiving("Query fullName is Miku")
.path("/information")
.query("fullName=Miku")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body(body)
.toPact();
MockProviderConfig config = MockProviderConfig.createDefault(this.pactSpecVersion.V3);
PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
ProviderHandler providerHandler = new ProviderHandler();
providerHandler.setBackendURL(mockServer.getUrl());
Information information = providerHandler.getInformation();
assertEquals(information.getName(), "Hatsune Miku");
});
checkResult(result);
}
}
當(dāng)然崔列,Junit DSL的強大之處絕不僅僅是讓你多寫幾個Test Case梢褐, 通過使用PactDslJsonBody和Lambda DSL你可以更好的編寫你的契約測試文件:
- 對契約中Response Body的內(nèi)容,使用JsonBody代替簡單的字符串赵讯,可以讓你的代碼易讀性更好盈咳;
- JsonBody提供了強大的Check By Type和Check By Value的功能,讓你可以控制對Provider的Response測試精度边翼。比如猪贪,對于契約中的某個字段,你是要確保Provider的返回必須是具體某個數(shù)值(check by Value)讯私,還是只要數(shù)據(jù)類型相同就可以(check by type)热押,比如都是String或者Int。你甚至可以直接使用正則表達式來做更加靈活的驗證斤寇;
- 目前支持的匹配驗證方法:
method | description |
---|---|
string, stringValue | Match a string value (using string equality) |
number, numberValue | Match a number value (using Number.equals)* |
booleanValue | Match a boolean value (using equality) |
stringType | Will match all Strings |
numberType | Will match all numbers* |
integerType | Will match all numbers that are integers (both ints and longs)* |
decimalType | Will match all real numbers (floating point and decimal)* |
booleanType | Will match all boolean values (true and false) |
stringMatcher | Will match strings using the provided regular expression |
timestamp | Will match string containing timestamps. If a timestamp format is not given, will match an ISO timestamp format |
date | Will match string containing dates. If a date format is not given, will match an ISO date format |
time | Will match string containing times. If a time format is not given, will match an ISO time format |
ipAddress | Will match string containing IP4 formatted address. |
id | Will match all numbers by type |
hexValue | Will match all hexadecimal encoded strings |
uuid | Will match strings containing UUIDs |
includesStr | Will match strings containing the provided string |
equalsTo | Will match using equals |
matchUrl | Defines a matcher for URLs, given the base URL path and a sequence of path fragments. The path fragments could be strings or regular expression matchers |
- 對于Array和Map這樣的數(shù)據(jù)結(jié)構(gòu)桶癣,DSL也有相應(yīng)匹配驗證方法,我這里就不羅列了娘锁,請參考官網(wǎng)的介紹牙寞;
執(zhí)行Miku端的測試
Test Case準(zhǔn)備好后,我們就可以執(zhí)行測試了。因為我們實際上是用的Junit的框架间雀,所以和執(zhí)行一般的單元測試是一樣的:
./gradlew :example-consumer-miku:clean test
成功執(zhí)行后悔详,你就可以在Pacts\Miku
下面找到所有測試生成的契約文件。
發(fā)布契約文件到Pact Broker
契約文件惹挟,也就是Pacts\Miku
下面的那些JSON文件茄螃,可以用來驅(qū)動Provider端的契約測試。由于我們的示例把Consumer和Provider都放在了同一個Codebase下面连锯,所以Pacts\Miku
下面的契約文件對Provider是直接可見的归苍,而真實的項目中,往往不是這樣运怖,你需要通過某種途徑把契約文件從Consumer端發(fā)送給Provider端拼弃。你可以選擇把契約文件SCP到Provider的測試服務(wù)器去,也可以選擇使用中間文件服務(wù)器來共享契約文件摇展,你甚至可以直接人肉發(fā)郵件把契約文件扔給Provider的團隊吻氧,然后告訴他們“這是我們的契約,你們看著辦吧~”(當(dāng)然咏连,這樣很Low ...)盯孙,這些都是可行的。顯然捻勉,Pact提供了更加優(yōu)雅的方式镀梭,那就是使用Pact Broker。
當(dāng)你準(zhǔn)備好Broker后踱启,就可以用它來方便的實現(xiàn)真正的消費者驅(qū)動的契約測試了报账。
好吧,我得承認(rèn)埠偿,“準(zhǔn)備”這兩個字我用得有些輕描淡寫透罢,實際的情況是你可能需要費一番周折才能弄好一個Broker服務(wù)。目前有好些方法可以搭建Broker服務(wù)冠蒋,你可以直接下載官網(wǎng)的源碼然后自己折騰羽圃,也可以使用Docker來個一鍵了事,更可以直接找Pact官方申請一個公共的Broker抖剿,當(dāng)然朽寞,那樣做就得暴露你的契約給第三方服務(wù)器,真實的產(chǎn)品項目多半是不行的斩郎,但如果只是學(xué)習(xí)脑融,那就事半功倍了,比如我們當(dāng)前的這個示例就是如此缩宜。
將契約文件上傳到Broker服務(wù)器非常簡單:
./gradlew :example-consumer-miku:pactPublish
然后你會在命令行下面看到類似這樣的輸出:
> Task :example-consumer-miku:pactPublish
Publishing JunitDSLConsumer1-ExampleProvider.json ... HTTP/1.1 200 OK
Publishing JunitDSLJsonBodyConsumer-ExampleProvider.json ... HTTP/1.1 200 OK
Publishing JunitDSLLambdaJsonBodyConsumer-ExampleProvider.json ... HTTP/1.1 200 OK
Publishing BaseConsumer-ExampleProvider.json ... HTTP/1.1 200 OK
Publishing JunitRuleConsumer-ExampleProvider.json ... HTTP/1.1 200 OK
Publishing JunitRuleMultipleInteractionsConsumer-ExampleProvider.json ... HTTP/1.1 200 OK
Publishing JunitDSLConsumer2-ExampleProvider.json ... HTTP/1.1 200 OK
上傳完成之后肘迎,你就可以在我們的Broker服務(wù)器上面看到對于的契約內(nèi)容了甥温。
值得說明的是,你可以看到上面我們有7個Consumer對應(yīng)1個Provdier妓布。在真實的項目中姻蚓,不應(yīng)該是這樣的,因為現(xiàn)在我們實際上只有一個Consumer Miku匣沼。我只是在不同的契約文件中對Consumer的名字做了不同的命名狰挡,目的只是展示一下Broker的這個漂亮的調(diào)用關(guān)系圖。這只是一個示例肛著,僅此而已圆兵。
至此跺讯,Pact測試中枢贿,Consumer端的工作我們就全部搞定了,剩下的就是Provider的活了刀脏。
Provider端的測試
在Provider端局荚,我們使用Gradle的Plugin來執(zhí)行契約測試,非常的簡單愈污,不需要寫一行測試代碼:
./gradlew :example-provider:pactVerify
在Provider端執(zhí)行契約測試之前耀态,我們需要先啟動Provider的應(yīng)用。雖然通過gradle我們可以配置自動關(guān)停應(yīng)用暂雹,但對于初學(xué)者首装,我還是建議大家多手動搗鼓搗鼓,不然你都不知道這個測試是怎么個跑法杭跪。啥仙逻?不知道怎么啟動Provider?自己去本文的開頭部分找去 ...
然后涧尿,你可以在命令行下面看到類似這樣的輸出:
Arimans-MacBook-Pro:pact-jvm-example ariman$ ./gradlew :example-provider:pactVerify
> Task :example-provider:pactVerify_ExampleProvider
Verifying a pact between Miku - Base contract and ExampleProvider
[Using File /Users/ariman/Workspace/Pacting/pact-jvm-example/Pacts/Miku/BaseConsumer-ExampleProvider.json]
Given
WARNING: State Change ignored as there is no stateChange URL
Consumer Miku
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json;charset=UTF-8" (OK)
has a matching body (OK)
Given
WARNING: State Change ignored as there is no stateChange URL
Pact JVM example Pact interaction
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json;charset=UTF-8" (OK)
has a matching body (OK)
...
Verifying a pact between JunitRuleMultipleInteractionsConsumer and ExampleProvider
[from Pact Broker https://ariman.pact.dius.com.au/pacts/provider/ExampleProvider/consumer/JunitRuleMultipleInteractionsConsumer/version/1.0.0]
Given
WARNING: State Change ignored as there is no stateChange URL
Miku
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json;charset=UTF-8" (OK)
has a matching body (OK)
Given
WARNING: State Change ignored as there is no stateChange URL
Nanoha
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json;charset=UTF-8" (OK)
has a matching body (OK)
從上面的結(jié)果可以看出系奉,我們的測試既使用了來自本地的契約文件,也使用了來自Broker的契約文件姑廉。
由于我們示例使用的Broker服務(wù)器是公共的缺亮,任何調(diào)戲我們這個示例應(yīng)用的小伙伴都能上傳他們自己的契約文件,其中難免會存在錯誤的契約桥言。所以萌踱,如果你發(fā)現(xiàn)來自Broker的契約讓你的測試掛掉了,請不要驚慌喲号阿。當(dāng)然并鸵,因為是公共服務(wù)器,我會不定時的清空里面的契約文件倦西,所以哪天你要是發(fā)現(xiàn)你之前上傳的契約文件沒有了能真,也不必大驚小怪。
相關(guān)的Gradle配置
OK,Provider和Miku感情故事我們就講完了粉铐。在講Nanoha之前疼约,先讓我們來看看Gradle的一些配置內(nèi)容:
project(':example-consumer-miku') {
...
test {
systemProperties['pact.rootDir'] = "$rootDir/Pacts/Miku"
}
pact {
publish {
pactDirectory = "$rootDir/Pacts/Miku"
pactBrokerUrl = mybrokerUrl
pactBrokerUsername = mybrokerUser
pactBrokerPassword = mybrokerPassword
}
}
...
}
project(':example-consumer-nanoha') {
...
test {
systemProperties['pact.rootDir'] = "$rootDir/Pacts/Nanoha"
}
...
}
import java.net.URL
project(':example-provider') {
...
pact {
serviceProviders {
ExampleProvider {
protocol = 'http'
host = 'localhost'
port = 8080
path = '/'
// Test Pacts from local Miku
hasPactWith('Miku - Base contract') {
pactSource = file("$rootDir/Pacts/Miku/BaseConsumer-ExampleProvider.json")
}
hasPactsWith('Miku - All contracts') {
pactFileLocation = file("$rootDir/Pacts/Miku")
}
// Test Pacts from Pact Broker
hasPactsFromPactBroker(mybrokerUrl, authentication: ['Basic', mybrokerUser, mybrokerPassword])
// Test Pacts from local Nanoha
// hasPactWith('Nanoha - With Nantionality') {
// pactSource = file("$rootDir/Pacts/Nanoha/ConsumerNanohaWithNationality-ExampleProvider.json")
// }
// hasPactWith('Nanoha - No Nantionality') {
// stateChangeUrl = new URL('http://localhost:8080/pactStateChange')
// pactSource = file("$rootDir/Pacts/Nanoha/ConsumerNanohaNoNationality-ExampleProvider.json")
// }
}
}
}
}
Gradle的配置也是非常的簡單的,Provider蝙泼,Miku和Nanoha作為三個單獨的應(yīng)用程剥,都是獨立配置的,其中的一些關(guān)鍵信息:
-
systemProperties['pact.rootDir']
指定了我們生成契約文件的路徑汤踏; - Miku中的
pact { ... }
定義了我們Pact Broker的服務(wù)器地址织鲸,以及我們訪問時需要的認(rèn)證信息。
如果你想通過瀏覽器訪問Broker溪胶,比如看上面的關(guān)系圖搂擦,你也是需要這個認(rèn)證信息的。這里的配置使用的是變量哗脖,真正的用戶名和密碼在哪兒瀑踢?不告訴你,自己在代碼里面找找吧( ̄▽ ̄)~*
- Provider的
hasPactWith()
和hasPactsWith()
指定了執(zhí)行PactVerify
時會去搜索的本地路徑才避,相應(yīng)的橱夭,hasPactsFromPactBroker
則是指定了Broker的服務(wù)器地址; - 為什么要注釋掉Nanoha的契約文件路徑呢桑逝?因為目前我們還沒有生成Nanoha的契約文件棘劣,如果不注釋掉它們的話,測試會報找不到文件的錯誤楞遏。我們可以在之后生成完Nanoha的契約文件后茬暇,再打開注釋;
Provider與Nanoha間的契約測試
Nanoha端的契約測試和Miku端大同小異橱健,只是我們會在Nanoha端使用ProviderState的特性而钞。關(guān)于ProviderState的具體含義,大家可以參見官網(wǎng)的介紹.
準(zhǔn)備Provider端的ProviderState
Provider會返回一個.nationality
的字段拘荡,在真實項目里臼节,它的值可能來自數(shù)據(jù)庫(當(dāng)然,也可能來自更下一層的API調(diào)用)珊皿。在我們的示例里面网缝,簡單起見,直接使用了Static的屬性來模擬數(shù)據(jù)的存儲:
provider.ulti.Nationality
public class Nationality {
private static String nationality = "Japan";
public static String getNationality() {
return nationality;
}
public static void setNationality(String nationality) {
Nationality.nationality = nationality;
}
}
然后蟋定,通過修改.nationality
就可以模擬對存儲數(shù)據(jù)的修改粉臊。所以,我們定義了一個控制器pactController
驶兜,在/pactStateChange
上面接受POST的reqeust來修改.nationality
:
provider.PactController
@Profile("pact")
@RestController
public class PactController {
@RequestMapping(value = "/pactStateChange", method = RequestMethod.POST)
public void providerState(@RequestBody PactState body) {
switch (body.getState()) {
case "No nationality":
Nationality.setNationality(null);
System.out.println("Pact State Change >> remove nationality ...");
break;
case "Default nationality":
Nationality.setNationality("Japan");
System.out.println("Pact Sate Change >> set default nationality ...");
break;
}
}
}
因為這個控制器只是用來測試的扼仲,所以它應(yīng)該只在非產(chǎn)品環(huán)境下才能可見远寸,所以我們使用了一個pact
的Profile Annotation來限制這個控制器只能在使用pact
的profile時才能可見。
OK屠凶,總結(jié)一下就是:當(dāng)Provider使用pact
的profile運行時驰后,它會在URL/pactStateChange
上接受一個POST請求,來修改.nationality
的值矗愧,再具體一些灶芝,可以被設(shè)置成默認(rèn)值Japan,或者null唉韭。
Nanoha端的契約測試
Nanoha端的測試文件和Miku端的差不多夜涕,我們使用Lambda DSL,在一個文件里面寫兩個TestCase属愤。
public class NationalityPactTest {
PactSpecVersion pactSpecVersion;
private void checkResult(PactVerificationResult result) {
if (result instanceof PactVerificationResult.Error) {
throw new RuntimeException(((PactVerificationResult.Error)result).getError());
}
assertEquals(PactVerificationResult.Ok.INSTANCE, result);
}
@Test
public void testWithNationality() {
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json;charset=UTF-8");
DslPart body = newJsonBody((root) -> {
root.numberType("salary");
root.stringValue("fullName", "Takamachi Nanoha");
root.stringValue("nationality", "Japan");
root.object("contact", (contactObject) -> {
contactObject.stringMatcher("Email", ".*@ariman.com", "takamachi.nanoha@ariman.com");
contactObject.stringType("Phone Number", "9090940");
});
}).build();
RequestResponsePact pact = ConsumerPactBuilder
.consumer("ConsumerNanohaWithNationality")
.hasPactWith("ExampleProvider")
.given("")
.uponReceiving("Query fullName is Nanoha")
.path("/information")
.query("fullName=Nanoha")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body(body)
.toPact();
MockProviderConfig config = MockProviderConfig.createDefault(this.pactSpecVersion.V3);
PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
ProviderHandler providerHandler = new ProviderHandler();
providerHandler.setBackendURL(mockServer.getUrl());
Information information = providerHandler.getInformation();
assertEquals(information.getName(), "Takamachi Nanoha");
assertEquals(information.getNationality(), "Japan");
});
checkResult(result);
}
@Test
public void testNoNationality() {
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json;charset=UTF-8");
DslPart body = newJsonBody((root) -> {
root.numberType("salary");
root.stringValue("fullName", "Takamachi Nanoha");
root.stringValue("nationality", null);
root.object("contact", (contactObject) -> {
contactObject.stringMatcher("Email", ".*@ariman.com", "takamachi.nanoha@ariman.com");
contactObject.stringType("Phone Number", "9090940");
});
}).build();
RequestResponsePact pact = ConsumerPactBuilder
.consumer("ConsumerNanohaNoNationality")
.hasPactWith("ExampleProvider")
.given("No nationality")
.uponReceiving("Query fullName is Nanoha")
.path("/information")
.query("fullName=Nanoha")
.method("GET")
.willRespondWith()
.headers(headers)
.status(200)
.body(body)
.toPact();
MockProviderConfig config = MockProviderConfig.createDefault(this.pactSpecVersion.V3);
PactVerificationResult result = runConsumerTest(pact, config, mockServer -> {
ProviderHandler providerHandler = new ProviderHandler();
providerHandler.setBackendURL(mockServer.getUrl());
Information information = providerHandler.getInformation();
assertEquals(information.getName(), "Takamachi Nanoha");
assertEquals(information.getNationality(), null);
});
checkResult(result);
}
}
這兩個TestCase的主要區(qū)別是:
- 我們對
nationality
的期望一個Japan女器,一個是null; - 通過
.given()
方法來指定我們的ProviderState春塌,從而控制在Provider端運行測試之前修改對應(yīng)nationality
的值晓避;
Consumer端運行測試的方式還是一樣的:
./gradlew :example-consumer-nanoha:clean test
然后簇捍,就可以在Pacts\Nanoha
路徑下面找到生成的契約文件了只壳。
Provider端的契約測試
啟動Provider的應(yīng)用
上面我們提到,運行Provider需要使用pact
的profile暑塑,所以現(xiàn)在啟動Provider的命令會有所不同:
export SPRING_PROFILES_ACTIVE=pact
./gradlew :example-provider:bootRun
如果你之前已經(jīng)啟動了Provider吼句,記得要kill掉喲,不然會端口占用的啦~
修改Gradle配置文件
我們在Consumer的契約中事格,使用.given()
指定了ProviderState惕艳,但說到底,那里指定的只是一個字符串而已驹愚,真正干活的远搪,還是Gradle,所以我們需要Gradle的相關(guān)配置:
build.gralde
hasPactWith('Nanoha - With Nantionality') {
pactSource = file("$rootDir/Pacts/Nanoha/ConsumerNanohaWithNationality-ExampleProvider.json")
}
hasPactWith('Nanoha - No Nantionality') {
stateChangeUrl = new URL('http://localhost:8080/pactStateChange')
pactSource = file("$rootDir/Pacts/Nanoha/ConsumerNanohaNoNationality-ExampleProvider.json")
}
這里逢捺,我們?nèi)∠酥皩anoha的注釋谁鳍。第一個TestCase我們會測試使用默認(rèn)的nationality=Japan。第二個TestCase劫瞳,我們指定了stateChangeUrl
倘潜,它會保證在測試運行之前,先發(fā)送一個POST請求給這個URL志于,然后我們的TestCase測試nationality=null涮因。
執(zhí)行契約測試
同樣的方法執(zhí)行契約測試:
./gradlew :example-provider:pactVerify
然后你就可以在命令行下面看見對應(yīng)的輸出了。
驗證我們的測試
如果你一字不漏的玩兒到了這里伺绽,那么恭喜你养泡,你應(yīng)該可以在自己的項目里去實踐Pact了(好了嗜湃,那個抄椅子的同學(xué),你不用說了澜掩,我知道净蚤,你們用的是Python╮(╯_╰)╭)。
但是在離開本示例之前输硝,還是發(fā)揚一下我們的測試精神吧今瀑,比如,搞點小破壞~
在Provider返回的body里面点把,Miku和Nanoha都有使用字段.name
橘荠。如果某天,Provider想把.name
改成.fullname
郎逃,估計Miku和Nanoha就要跪了哥童。這是一種經(jīng)典的契約破壞場景,用來做我們的玩兒法再適合不過了褒翰≈福可是要那么玩兒的話,需要修改Provider的好些代碼优训,想必不少測試的同學(xué)朵你,特別是對Spring Boot不了解的同學(xué)就又要拍磚了。
所以還是讓我們來個簡單的吧揣非,比如霸王硬上弓抡医,直接把.name
給miku了,哦早敬,不對忌傻,是null了。
provider.InformationController
@RestController
public class InformationController {
...
information.setName(null);
return information;
}
}
喂搞监,喂水孩,干坐著干嘛,動手改呀琐驴!這行代碼可是需要你們自己加上去的喲俘种,即便它已經(jīng)簡單到只有一行。然后棍矛,那個寫Python的安疗,別告訴我你看不懂
information.setName(null)
,Okay够委? ̄▽ ̄
最后荐类,重新運行我們的契約測試,你就能看到一些長得像這樣的東東啦~:
...
Verifying a pact between Nanoha - No Nantionality and ExampleProvider
[Using File /Users/ariman/Workspace/Pacting/pact-jvm-example/Pacts/Nanoha/ConsumerNanohaNoNationality-ExampleProvider.json]
Given No nationality
Query name is Nanoha
returns a response which
has status code 200 (OK)
includes headers
"Content-Type" with value "application/json;charset=UTF-8" (OK)
has a matching body (FAILED)
Failures:
0) Verifying a pact between Miku - Base contract and ExampleProvider - Pact JVM example Pact interactionVerifying a pact between Miku - Base contract and ExampleProvider - Pact JVM example Pact interaction Given returns a response which has a matching body
$.name -> Expected 'Hatsune Miku' but received null
...