契約測試之Pact By Example

如今罐旗,契約測試已經(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)鍵是createPactrunTest這兩個方法:

  • 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

...
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末茁帽,一起剝皮案震驚了整個濱河市玉罐,隨后出現(xiàn)的幾起案子屈嗤,更是在濱河造成了極大的恐慌,老刑警劉巖吊输,帶你破解...
    沈念sama閱讀 219,366評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件饶号,死亡現(xiàn)場離奇詭異,居然都是意外死亡季蚂,警方通過查閱死者的電腦和手機茫船,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,521評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來扭屁,“玉大人算谈,你說我怎么就攤上這事×侠模” “怎么了然眼?”我有些...
    開封第一講書人閱讀 165,689評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長葵腹。 經(jīng)常有香客問我高每,道長,這世上最難降的妖魔是什么践宴? 我笑而不...
    開封第一講書人閱讀 58,925評論 1 295
  • 正文 為了忘掉前任鲸匿,我火速辦了婚禮,結(jié)果婚禮上浴井,老公的妹妹穿的比我還像新娘晒骇。我一直安慰自己,他們只是感情好磺浙,可當(dāng)我...
    茶點故事閱讀 67,942評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著徒坡,像睡著了一般撕氧。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上喇完,一...
    開封第一講書人閱讀 51,727評論 1 305
  • 那天伦泥,我揣著相機與錄音,去河邊找鬼锦溪。 笑死不脯,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的刻诊。 我是一名探鬼主播防楷,決...
    沈念sama閱讀 40,447評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼则涯!你這毒婦竟也來了复局?” 一聲冷哼從身側(cè)響起冲簿,我...
    開封第一講書人閱讀 39,349評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎亿昏,沒想到半個月后峦剔,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,820評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡角钩,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,990評論 3 337
  • 正文 我和宋清朗相戀三年吝沫,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片递礼。...
    茶點故事閱讀 40,127評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡野舶,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出宰衙,到底是詐尸還是另有隱情平道,我是刑警寧澤,帶...
    沈念sama閱讀 35,812評論 5 346
  • 正文 年R本政府宣布供炼,位于F島的核電站一屋,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏袋哼。R本人自食惡果不足惜冀墨,卻給世界環(huán)境...
    茶點故事閱讀 41,471評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望涛贯。 院中可真熱鬧诽嘉,春花似錦、人聲如沸弟翘。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,017評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽稀余。三九已至悦冀,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間睛琳,已是汗流浹背盒蟆。 一陣腳步聲響...
    開封第一講書人閱讀 33,142評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留师骗,地道東北人历等。 一個月前我還...
    沈念sama閱讀 48,388評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像辟癌,于是被迫代替她去往敵國和親寒屯。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,066評論 2 355

推薦閱讀更多精彩內(nèi)容

  • 背景 在當(dāng)前微服務(wù)和前后端分離大行其道的行業(yè)背景下愿待,越來越多的團隊采用了前后端分離和微服務(wù)的架構(gòu)風(fēng)格浩螺。該服務(wù)架構(gòu)下...
    博客已遷移I米陽閱讀 24,399評論 2 18
  • 什么是契約 如果從契約產(chǎn)生的階段來說靴患,現(xiàn)有資料表明最早要追溯到西周時期的《周恭王三年裘衛(wèi)典田契》,將契約文字刻寫在...
    ThoughtWorks閱讀 2,954評論 1 12
  • 正如大家所知,最初QA都是手動執(zhí)行測試用例患蹂,開發(fā)人員每修改一個版本或颊,QA就要手動測試一遍,隨著功能的不斷增加传于,手動...
    ThoughtWorks閱讀 2,843評論 1 18
  • 是的既然如此囱挑,它的意義在哪里呢?我就是帶著這樣的疑問進了現(xiàn)在的項目組 - API沼溜, Application Pro...
    冥冥_ddb0閱讀 554評論 1 1
  • 本文說的集成測試是指系統(tǒng)集成后的自動化測試系草。(也可以說是系統(tǒng)端對端的集成測試) 是的通熄,最初QA都是手動執(zhí)行測試用例...
    李春輝閱讀 1,073評論 2 12