1. 程式人生 > >SpringBoot中基於Pact的契約測試

SpringBoot中基於Pact的契約測試

背景

     如今,契約測試已經逐漸成為測試圈中一個炙手可熱的話題,特別是在微服務大行其道的行業背景下,越來越多的團隊開始關注服務之間的契約及其契約測試。

什麼是契約測試

    關於什麼是契約測試這個問題,首先先看一下Pact官方文件給出的定義:pact的官方文件,是另一個可以幫助我們理解契約測試的地方。它對契約測試給出了這樣的定義:"Contract testing is a way to ensure that services (such as an API provider and a client) can communicate with each other"。這裡面需要關注的重點是"communicate ",它給出了Pact對契約測試範疇(scope)的定義。契約測試又稱之為 消費者驅動的契約測試。這裡的契約是指軟體系統中各個服務間互動的資料標準格式,更多的指消費端(client)和提供端(server)之間互動的資料介面的格式。

契約測試的價值是什麼

      那什麼是契約測試的價值呢?要說清楚契約測試的價值,就需要準確認識契約測試的精髓——"消費者驅動"

     在討論契約測試的範疇裡,”消費者驅動”述及的物件是契約,而不是契約測試。所以誰被驅動的物件就是契約。舉個例子,當某個provider正常上線後,某個consumer需要消費這個provider的服務,那麼應該由consumer來提出期望來建立它們之間的契約測試。因為,契約測試,形式上,雖然測試的是provider,但是在價值上,保證的卻是consumer的業務。

如果消費者對自己都不上心, 那你也不要指望生產者能操什麼心。這些都是在跨團隊的微服務體系下真切的痛點。 這裡舉一個契約測試的經典:

      在上圖一個簡單的消費關係中,provider為consumer A,B,C提供服務。provider提供的結構包含name、age和gende三個簡單的欄位。這份包含name、age和gender的JSON,其本身只是一個schema,並不是任何契約。因為契約一定是成對存在的,沒有確切consumer的互動定義,只是schema,不是契約。

    如上圖有三個消費者,並且消費的欄位各不相同,所以這裡需要有三份契約(對應的,也需要三份契約測試)。

  •   consumer A消費page和gender,
  •   consumer B消費name、age和gender;
  •   consumer C消費name和gender

        就目前provider提供的schema來說,沒有任何問題,大家相安無事。但是某日因為業務需求,consumer C期望provider提供更加詳細的name資訊,包括firstName和lastName。這個需求對provider是小case,所以,provider打算對schema做類似下面的修改:

 

  這樣的修改,很明顯對consumer C是需要的,對consumer A無所謂,但對consumer B卻是不可接受的,屬於典型的契約破壞。此時,provider和consumer B之間的契約測試就會掛掉,從而對provider提出預警(至於,剩下的,怎麼協調和consumer B的相容問題,就不是契約測試關注的問題,那需要的是團隊間的交流)。上面這個示例中的一些細節,可以幫助我們發掘契約測試的價值點

1. 應對單個provicder多個consumer

       要最大化的體現契約測試異於整合測試的價值,一定是在"單個provider對應多個consumer"的架構下來說的。因為,在只有一個provider和一個consumer的架構下,只存在一份契約,對該契約內容的任何修改,對這對provider和consumer來說,都是顯而易見的,那麼就不會出現契約破壞的情況。在這種情況下,整合測試往往就已經完整的達到了契約測試的目的。

  但是在單個provider對應多個consumer的架構下,情況就不一樣了在上文的例子中provider和consumer C之間的契約修改,對consumer A無影響,對consumer B卻是契約破壞,對這種情況,整合測試是無能為力的。在上邊例子中,有4個service,所以就會有4個整合測試,每個整合測試只會關注自己的業務正確性,provider修改後,只有consumer B的整合測試會掛掉。但那都是在provider的契約破壞生效之後的事情了。可見,雖然4個整合測試都各司其職,但都不能對這個契約破壞的問題做到防患於未然!只有契約測試,才是這個問題的最佳答案!這就是契約測試最大的價值,它只會在"單provider多consumer"的環境下(這是微服務的常見場景,但不是必然場景),才能發揮出來。

2.減少團隊溝通成本  

  真正的業務場景下,特別是一些複雜的微服務叢集,又或者是一些時間跨度很長的系統,對於某個provider,到底有多少個consumer?而provider的每一處修改,又對哪些consumer的契約造成怎樣的影響?這些往往都是很難確定的問題。當在集團業務中一個provider有十幾個 consumer時,每次provider要更新,就得八方去通知這些consumer的團隊來做迴歸測試。有時,一點小小的修改,迴歸測試一分鐘就可以搞定,但人肉聯絡各個團隊卻會花上好幾天。如果每個consumer都能和provider建立契約測試(這裡我們暫且不考慮負載和去重的問題),我們就能很好的解決這些效率問題。

契約測試和功能測試區別

      首先這裡的功能測試是指介面測試和整合測試, 學習契約測試的時候一定要弄清楚契約測試和功能測試)之間的區別。契約測試主要是用於以下幾點

  • 測試介面和介面之間的正確性
  • 驗證服務層提供的資料是否是消費端所需要的
  • 將本來需要在整合測試中體現的問題前移,更早的發現問題
  • 更快速的驗證消費端和提供端之間互動的基本正確性

根據契約測試的用途我們可以發現契約測試和功能測試之間的區別如下:

     1. 功能測試關注的是provider的正實現其設計,契約測試關注的是provider的實現(也包括設計)是否滿足每一個consumer的需求。注意,功能測試只關注provider自身,而契約測試則關注每一個co;      2. 功能測試的測試案例,由provider的團隊提供,契約測試的測試案例,基於消費者驅動,由各個consumer團隊提供;      3. 一個provider只會有一個功能測試,但契約測試,理論上,可以無限,有多少consumer就可以有多少個契約測試;      4. 整合測試的測試物件是一定是consumer,或者說是一個服務作為consumer的角色(因為,某個服務經常既是consumer,又是provider,而契約測試的被測試物件一定是provider;

Example by Pact

      Pact最早是用Ruby實現的,目前已經擴充套件支撐Java,.NET,Javascript,Go,Swift,Python和PHP。 這裡我使用springboot+PACT+gradle搭建契約測試。

  1.新增依賴

  在專案的build.gradle檔案中新增如下依賴    

buildscript {
  ext {
        pactVersion = "4.0.2"
        kotlin_version=1.3.50
    }
    dependencies {
        classpath("au.com.dius:pact-jvm-provider-gradle:${pactVersion}")
    }
}

apply plugin: "au.com.dius.pact"
dependencies {
    testImplementation "au.com.dius:pact-jvm-consumer-junit:${pactVersion}"
    testImplementation "au.com.dius:pact-jvm-consumer-java8:${pactVersion}"
}

       這裡有幾個注意點:

       1. 由於這裡用的pact的版本是4.0.2的,pact外掛中依賴的kotlin版本是1.3.50,所以專案中kt的版本也要是1.3.50,然而springboot現在預設自己管理kt的版本,目前專案中採用的springboot是2.1.10.RELEASE預設使用的kt是1.2.7,會導致pact載入失敗,所以要自己手動指定kotlin版本。

     2.有寫時候引入的依賴包中可能會指定pact版本,而且是低版本的這個時候也要注意版本衝突。

 2.編寫測試用例

         想編寫一套完整的PACT測試用例一般分為以下四步

   1.確定consumer需求

       契約測試編寫測試用例,首先要素是根據consumer需求編寫,這裡我假設consumer需求如下

  •   Get請求:http://ip:port/ic/{id}?type=test
  •    要求返回的響應 
    {
      "id": "a7a1b044-b8a8-4ef9-ae1b-00599f2281cc",
      "data": {
        "type": "test",
        "projectName": "testadmin",
        "machineCode": "1111",
        "validity": "2022-01-18 23:59:59.0",
           "useNum":500
      }
    }

2.編寫consumer測試程式碼

  根據consumer的需求編寫Test case

@Test
  public void testWithQuery() {

    // 構造consumer血藥驗證的響應內容
    DslPart body = newJsonBody((root) -> {
      root.stringType("id");
      // 對應上文的data結構
      root.object("data", (dataObject) -> {
        // 驗證返回的type的值是否是"test"
        dataObject.stringValue("type", "test");
        //驗證型別是否為string
        dataObject.stringType("projectName", "tesadmint");
        dataObject.stringType("machineCode");
        dataObject.timestamp("validity");
        dataObject.numberType("userNum", 500);
      });
    }).build();

     RequestResponsePact pact = buildPactResponse("test",5,body);

    MockProviderConfig config = MockProviderConfig.createDefault(PactSpecVersion.V3);
    PactVerificationResult result = runConsumerTest(pact, config, (mockServer, context) -> {
      // 自己的客戶端呼叫服務
      TestRestService testRestService = new TestRestService ();
        // 生成一個mockServer,代替服務端返回響應     
         TestResponse testResponse= TestResponse 
          .fetchLicenseGetId(mockServer.getUrl(), "/lic/1?licenseType=test");
     // 返回的響應內容
      TestData testdata= testResponse.getData();
      // 驗證響應內容
      assertEquals(testdata.getType(), "test");
      return null;
    });
    checkResult(result);
  }
  
//  返回響應的請求頭型別
  private RequestResponsePact buildPactResponse(String licType, int id,DslPart body) {
    Map<String, String> headers = new HashMap<String, String>();
    headers.put("Content-Type", "application/json;charset=UTF-8");
    return ConsumerPactBuilder
        .consumer("SignLicGetIdConsumer")
        .hasPactWith("SignLicProvider")
        .given("")
        .uponReceiving("Query " + licType + " lic is " + id)
        .matchPath("/lic/[0-9]+", "/lic/" + id)
        .query("licType=" + licType)
        .method("GET")
        .willRespondWith()
        .headers(headers)
        .status(200)
        .body(body)
        .toPact();
  }

      其中TestRestService 是consumer應用程式碼中的類,我們直接使用它來發送真正的Request,發給誰呢?發給mockServer,Pact會啟動一個mockServer, 基於Java原生的HttpServer封裝,用來代替真正的Provider應答createPact中定義好的響應內容,繼而模擬了整個契約的內容。

       編寫測試類我這裡使用的是Junit DSL方式,這種方式可以在一個測試類中編寫多個測試方法而基本的Junit和Junit Rule的寫法只能在一個測試檔案裡面寫一個Test Case。當然,Junit DSL的強大之處絕不僅僅是讓你多寫幾個Test Case,通過使用PactDslJsonBody和Lambda DSL你可以更好的編寫你的契約測試檔案:

    • 對契約中Response Body的內容,使用JsonBody代替簡單的字串,可以讓你的程式碼易讀性更好;
    • JsonBody提供了強大的Check By Type和Check By Value的功能,讓你可以控制對Provider的Response測試精度。比如,對於契約中的某個欄位,你是要確保Provider的返回必須是具體某個數值(check by Value),還是隻要資料型別相同就可以(check by type),比如都是String或者Int。你甚至可以直接使用正則表示式來做更加靈活的驗證;
    • 目前支援的匹配驗證方法請參考官方文件,這裡不多說

3.設定契約生成目錄

    在build.gradle中新增契約檔案存放地址

test {
    systemProperties['pact.rootDir'] = "${buildDir}/Pacts/"
}

4. 執行測試類  

      在junit中執行clean test,執行成功後會生成在對應目錄下契約檔案。這裡我用的idea,所以直接執行gradle task即可

        

 Provider測試

      comsumer端測試程式碼編寫完畢,契約也生成好了,接下來就是要執行Provider端測試了,要想執行Provider測試,首選要獲取consumer端的契約檔案;契約檔案,也就是上文Pacts目錄下面的那些JSON檔案,可以用來驅動Provider端的契約測試。由於我們的示例把Consumer和Provider都放在了同一個codeBase下面,所以Pacts下面的契約檔案對Provider是直接可見的,而真實的專案中,往往不是這樣,你需要通過某種途徑把契約檔案從Consumer端傳送給Provider端。Pact提供了更加優雅的方式那就是使用Pact Broker。目前有好些方法可以搭建Broker服務,我推薦使用Docker來個一鍵了事。

    要想釋出到Broker上,需要配置釋出地址,在gradle中加入如下配置

pact {
    publish {
// 契約地址 pactDirectory = "${buildDir}/${pactPath}/"
//broker url
pactBrokerUrl = mybrokerUrl } }

        這裡搭建的broker不需要使用者名稱和密碼, 所有無需配置使用者名稱和密碼。配置完後執行釋出任務pactpublish,如果是idea的話在右邊是可以直接找到釋出的task,雙擊便可執行

  釋出成功後在broker上可以看到consumer的契約檔案。如下:

        consumer釋出契約成功後,provicer就可以從broker上拉取契約檔案了,在build.gradle的pact Task中新增serviceProviders配置 

pact {
    publish {
        pactDirectory = "${buildDir}/${pactPath}/"
        pactBrokerUrl = mybrokerUrl
    }
    serviceProviders {
        SignLicProvider {
            protocol = 'http'
            host = 'localhost'
            port = 8880
            path = '/'

            // Test Pacts from local
            hasPactWith('') {
                pactSource = file("${buildDir}/${pactPath}/SignLicGetIdConsumer-SignLicProvider.json")
            }

            // Test Pacts from Pact Broker
            hasPactsFromPactBroker(mybrokerUrl)
        }
    }
}

        如果想把provider端測試的結果提交到broker上,需要開啟結果上傳配置。 在build.gradl中 新增pact_verifier_publishResults=true即可。新增成功以後,就可以執行provider端的契約測試了。在執行provider端的測試之前,要先保證provider端的服務開啟,否則無法工作。在idea中的task中可以找到新增的對應的task:pactVerify_SignLicProvider,然後雙擊執行。也可以手動執行task:SignLicProviderr:pactVerify。

     task執行成功後控制檯會顯示契約測試是否執行通過,或者在broker上也可以看到最近提交的結果。

 

    最後我們就可以根據契約測試的結果,進行溝通或者修改了。

 總結

      一般契來說約測試是在單元測試之後,整合測試之前要進行的,首先在保證各自功能正確的前提下測試消費者和提供者的契約是否相匹配,然後再進一步的測試功能的完備性和整個業務流的正確性。

       從上文可以得出契約測試可以解決如下問題:
  1. 可以使得消費端和提供端之間測試解耦,不再需要客戶端和服務端聯調才能發現問題
  2. 完全由消費者驅動的方式,消費者需要什麼資料,服務端就給什麼樣的資料,資料契約也是由消費者來定的
  3. 測試前移,越早的發現問題,保證後續測試的完整性
  4. 通過契約測試,團隊能以一種離線的方式(不需要消費者、提供者同時線上),通過契約作為中間的標準,驗證提供者提供的內容是否滿足消費者的期望

 參考連結

    https://github.com/pact-foundation/pact_broker

   https://github.com/DiUS/pact-jvm/tree/master/consumer/pact-jvm-consumer-junit

   https://www.jianshu.com/p/ca82cde5b125