Contents

消費者與提供者之間的合約測試 - Pact.io

Introduction

Pact是一個程式碼優先工具,使用contract tests測試 HTTP 以及訊息整合。Contract test斷言應用程式對應用程式之間的訊息,有符合文件所記錄的共識。如果沒有contract testing,那麼確保應用程式之前協作正確的唯一方式就只有使用容易壞掉又維護性高的整合測試了。

測試煙霧警報器的時候,會使用測試按鈕,不會直接燒了房子,同理可證,Pact提供程式碼這樣一個測試按鈕,讓你在部署好所有微服務之前,能夠先安全地確認程式碼正確執行。

Pact合約測試流程動圖

  1. Consumer 針對provider mock執行單元測試,驗證自己程式的行為
  2. 跨系統之間的互動行為會捕捉至contract
  3. Contract由不同團隊之間共享,使用Pactflow工具能夠確保協同合作性
  4. Contract中的requests 重演了 provider API 並驗證 consumer 的預期值
  5. Provider 的測試中所有其他系統都用替身,所以能隔離測試

Javascript code guide, 5 mins

1
2
3
4
5
group = 'au.com.dius.pactworkshop'

dependencies {
    testImplementation 'au.com.dius.pact.consumer:junit5:4.6.5'
}

contract testing是什麼

Contract testing 是用來測試某個integration point的技術,藉由隔離測試個別的APP,以確保它接收或傳送出的訊息,符合紀載在合約上的共識。

如果是透過HTTP通訊的應用程式,這些訊息就會是HTTP請求/回覆,如果是使用Queue的應用程式,這些訊息就會是要傳送到隊列的內容。

Pact實作合約測試的方式,就是確認所有呼叫你的test doubles的測試都會得到如同實際app一樣的回傳值

什麼時候使用contract testing

當你有兩個需要互通有無的service時,contract testing就可以立刻派上用場,例如API client要與web front-end溝通的應用場景。雖然單一客戶端對單一個服務是很常建的use case,contract testing在微服務架構(有多個services的環境)更彰顯其價值。如果有格式良好的合約測試,開發就要避免版本地域就更家容易。合約測試可說是微服務開發與部署的殺手級應用。

合約測試術語

consumer: 例如需要接收資料的client provider: 例如server上的API,提供client所需的資料 通常在微服務架構下,client/server的術語不適合所有情境,例如透過訊息隊列的溝通就不適合。所以這裡會以consumerprovider為主。

消費者驅動的contracts

Pact是程式碼優先、消費者驅動的合約測試工具,通常由寫程式的開發或測試人員使用。在自動化的consumer tests過程中產生這個contract。這種模式的一個主要優勢是,全部的communication中,只測試到 consumer會實際使用到的部分communication。也就是說,任何不會被當前的consumer所使用到的 provider行為,都可以在不弄壞測試結果的前提下做異動修改。

另外Pact contract也不像schema或者規格 (例如 OAS) 這類靜態的artefact定義資源的所有可能的狀態,pact contract實施方式是藉由執行測試案例的集合,每個元素描述了具體的請求/回應對。Pact實際上就是範例式合約。參考Schema測試與Contract測試的差異

供應者的合約測試

contract testing 又稱 provider contract testing 在其他文件中有時候是指standalone provider的測試,而不是integration合約測試。如果是用在這類文本,contract testing指的是確保 provider 的實際行為符合文件合約 (e.g. OpenAPI文件)。這類的合約測試透過確保provider的程式碼與文件之間的同步一致性,避免整合時會發生的錯誤。然而這並不提供任何測試上的保證,用以確保consumer用正確的方式呼叫provider,或者provider提供的資料符合consumer所預期。所以在預防串接上的錯誤效果並不佳。 本文的contract testing指的都是 intergration 整合合約測試。

How Pact works

  • Consumer: 藉由使用其他application提供的功能或資料來完成工作。例如:使用HTTP通訊時,無論資料流向,consumer就是發起http請求的一方 (前端)。使用queue隊列通訊時,consumer就是從queue讀取訊息的接收方。
  • Provider:提供功能或者資料給其他應用程式使用的一個應用程式 (通常稱為服務service)。使用HTTP通訊時,provider就是提供回傳結果的一方。使用queue隊列通訊時,provider (又稱為 producer) 就是將訊息寫進queue的一方。

消費者與提供者之間的合約就稱為pact,每個pact都是一些互動(interactions)的集合,每一筆互動描述了:

  • HTTP:
    • 預期的請求內容: 消費者會發送給提供者的請求內容
    • 最小預期回傳內容: 消費者預期提供者會提供的回傳內容
  • messages (queue):
    • 最小預期訊息: 消費者想要使用的訊息內容
 ╔═════════╡Interaction╞═════════╗
 ║                               ║
 ║     [Expected request]        ║
 ║  [Minimal expected response]  ║
 ╚═══════════════════════════════╝

撰寫pact test的首要步驟就是描述以上的 interaction。

消費者測試

consumer pact test 則操作上述提到的每一個interaction,以說明『假定provider給這個請求傳回了預期的回傳值,那消費者端的程式碼是否正確,能夠產生符合的請求內容,並且能夠處理所預期的回傳結果?』

每一個interaction都使用Pact框架測試,由consumer端的單元測試框架所驅動。

https://hackmd.io/_uploads/HJd43zifR.png

以上圖表的步驟敘述:

  1. 使用 Pact DSL,將預期請求與回傳值註冊在 mock service 中
  2. consumer 的測試程式碼發出一個實際的請求到 mock provider (這個provider由Pact框架建立)
  3. mock provider 比較預期請求跟實際請求,如果比較結果成功,則發送預期response
  4. consumer 的測試程式碼再確認這個response確實是可理解的內容

只有每個步驟都無誤地完成,Pact test才算成功。

通常,interaction的定義跟consumer test是寫在一起,參考這個範例 Simplifing Microservice testing w/ Pacts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Describe the interaction
before do
  event_api.upon_receiving('A POST request with an event').
    with(method: :post, path: '/events', headers: {'Content-Type' => 'application/json'}, body: event_json).
    will_respond_with(status: 200, headers: {'Content-Type' => 'application/json'})
end

# Trigger the client code to generate the request and receive the response
it 'is successful' do
  expect(subject.save_event(event)).to be_true
end

儘管概念上 pact interaction test 會進行很多事,但實際的測試程式碼很簡潔明瞭,這是 Pact 一項主要賣點。

Pact之中的每一項 interaction 應該都是各自獨立的,指的是每項測試只會測試他自己的一個 interaction。如果需要描述依賴在 pre-existing state 的 interaction,可以使用provider states達成。Provider states 允許你描述 provider 在產出預期response之前所需的預先狀態 (preconditions)。例如,某個使用者資料存在於provider端,這在後續的 provider 驗證區塊會再提到。

 ╔═════════╡Interaction╞═════════╗
 ║                               ║
 ║      [Provider state]         ║
 ║     [Expected request]        ║
 ║  [Minimal expected response]  ║
 ╚═══════════════════════════════╝

這樣測試不會寫成「建立使用者debbie,然後登入」,而會分成兩個不同的interactions,第一個是「建立使用者debbie」;而第二個則會是「provide state: 使用者debbie已存在」,「以使用者debbie登入」。

一旦所有的 interactions 都在消費者端測試完畢,Pact framework 就會產生一個pact file,描述每一個 interaction 的內容。這個 pact file 可以用來驗證 provider 提供的 respone 都有符合 consumer 的預期。

提供者的驗證

跟消費者測試相反,provider verification完全都由 Pact framework 所驅動。

https://hackmd.io/_uploads/rJcXnMsM0.png

每一筆預期的請求都會從mock consumer發送到Provider,接著拿provider實際給的response與最小預期response做比較。

如果每一筆request都能接到response,而這個實際的response包含文件所述的最小預期response,這樣 provider verification 就算通過。

很多情況是在請求發送時,provider需要處在某個特定狀態 (例如已登入的情況才能測試,或者已登入的用戶持有某一筆發票)。Pact framework 會讓你在上演交互之前,依provider state內容所述,預先建置好狀態資料。

https://hackmd.io/_uploads/BJWm3GsGA.png

組合起來

如果將每一個 interaction 的 consumer test 與 provider verification 兩個程序組合起來,就完成了 consumer 與 provider 之間完整的測試,不用花時間部署啟動兩邊的服務。

Non-HTTP testing (message pact)

現代的分散式架構整合時採用了鬆耦合和異步通信的方式,選用這種架構設計的趨勢增加。常見的訊息隊列像是 ActiveMQ、RabbitMQ、SNS、SQS、Kafka、Kinesis,通常藉由小規模且次數頻繁的微服務調用 (例如 lambda)來做整合。這種類的 interactions 就稱之為 message pacts

Pact在這個應用案例的作法與http通訊的案例有點不同。Pact透過將「協議與特定的隊列技術(例如: Kafka) 」做抽象化,把關注點著重在兩者之間的訊息傳遞。

確認你使用的語言有無支持MessagePact

因為MQ類型太多,不在Pact要解決的範疇中。可以用其他測試框框架,自行寫功能測試來驗證 message queueing techonologies

當你寫測試時,Pact會取代通訊媒介 (MQ/broker等),負責確認消費者是否能夠處理 MQ 提供的事件,或者確認提供者是否能產出正確的訊息。

要怎麼寫 message pact 的測試?

建議你將這兩段程式碼分開

  • 處理協議相關的,例如 AWS lambda handler、AWS SNS input body
  • 處理payload相關的程式

對於分層架構(例如: ports 與 adapters又稱六角形架構)你可能已經有些概念,所以下面的模組化架構可以協助簡化:

Adapter        |  [Lambda Handler]
               |              ↘ 
Port           |                [Service]
               |            ↙       ↓        ↘
Business Logic | [Repository] [Domain Model] [Collaborators]

以下會用一個由 AWS SNS 發布的product event的範例來解釋

Consumer side

假定cosumer期望接收到的訊息長得像下面這樣:

{
    "id": "some-uuid-8765-4321",
    "type": "spare",
    "name": "15mm steel bolt",
    "version": "v1",
    "event": "UPDATED"
}
Adapter

Adapter的程式碼會用來處理特定queue的實作。例如,可能會是

  • Lambda handler: 接收並處理SNS傳來的訊息payload
  • function: 從Kafka訊息隊列取訊息
const handler = async (event) => {
  console.info(event);

  // Read the SNS message and pass the contents to the actual message handler
  const results = event.Records.map((e) => receiveProductUpdate(JSON.parse(e.Sns.Message)));

  return Promise.all(results);
};
Port

Port 不會知道自己是在SNS還是在跟Kafka做溝通,只會處理 domain 的資料,這裡就是指 product event

const receiveProductUpdate = (product) => {
  console.log('received product:', product)

  // do something with the product event, e.g. store in the database
  return repository.insert(new Product(product.id, product.type, product.name, product.version))
}

上面這個 function 是 Pact test 在消費者端 target

Provider (Producer) side

另一方面,我們需要找到負責製造出訊息的 Port,這裡是 ProductEvenService

class ProductEventService {
  async create(event) {
    const product = productFromJson(event);
    return this.publish(createEvent(product, "CREATED"));
  }

  async update(event) {
    const product = productFromJson(event);
    return this.publish(createEvent(product, "UPDATED"));
  }

  ...

  async publish(message) {
    const SNS = new AWS.SNS({
      endpoint: process.env.AWS_SNS_ENDPOINT,
      region: process.env.AWS_REGION
    });

    const params = {
      Message: JSON.stringify(message),
      TopicArn: TOPIC_ARN,
    };

    return SNS.publish(params).promise();
  }
}

Adapter 和 Port 的概念:

“Adapter” 通常表示用於與外部系統互動的元件。

  • 在這個例子中,publish 方法是用來將訊息發佈到 AWS SNS 的元件,因此被稱為 “Adapter”
  • 這個部分負責與外部服務進行交互,並了解與之通信的技術細節
  • publish 知道如何跟AWS SNS溝通

“Port” 表示與業務領域相關的元件。

  • 在這個例子中,updatecreate 方法屬於這個類別
  • 只處理業務邏輯,並且知道如何建立特定的事件結構
  • 這部分的功能與底層的技術實作無關,只與業務領域邏輯相關。
  • update 知道如何創建特定的事件結構
  • 是Pact要測試 provider side 的著重點

延伸閱讀:

下一步

Contract應該將重點放在訊息 (request/response)而非行為。在provider這一端可能會有人想用合約測試寫實際的功能性測試。但這樣通常會導致痛苦的寫測試經驗,還有容易壞的測試。這篇文章有關於功能性測試跟合約測試的比較

Pact test 應該要資料獨立,成功的驗證不依賴於Provider回傳的特定資料。參考撰寫interaction的最佳實作

使用broker代理機制來整合Pact與CI機制,有助於安全成功的部署,參考Pact整合最佳實作