コンシューマ駆動契約テストに入門してみた〜とりあえずHello World編〜

タム

2024.07.04

73

こんにちは。タムです。

久しぶりの投稿です。

今回から数回に渡り、Pactを使ったコンシューマ駆動契約テストについて投稿しようと思います。

第一回目の今日は、自分にとっても初体験になるので、とりあえずHello Worldしてみようと思います。

なお、フロントエンドはNextJS、バックエンド(API)はPython(Chalice)の構成で始めます。

前提知識ーコンシューマ駆動契約テストとはなんぞや

Hello Worldの本題に入る前に、まずは前提としてコンシューマ駆動契約テストとは何かについて説明させてください(自分の理解の為)。


マイクロサービスアーキテクチャを採用した場合に、特定のサービスAが、複数のサービスB,C,D...から呼ばれる、というような依存関係が生まれます。

その場合に、例えばサービスBのために新しい機能をサービスAに実装したとして、サービスAをリリースする際に、

サービスBが正しく動くのはもちろんですが他のサービスC,D...も正しく動くことが保証されなければなりません。

そこで、サービスBだけでなくサービスC,D...についても結合テストを全て網羅しなければならない、となると非常にリリースのコストが大きくなってしまいます。

これでは、マイクロサービスの本来のメリットである、サービス単位の素早いリリースが実現不可能になってしまいます。


コンシューマ駆動契約テストでは、コンシューマ(=呼び出す側)がプロバイダ(=呼び出される側)に対して、

どんな状況でどんなリクエストを投げた場合に、どんな応答をしてほしいかという「契約」を発行します。

具体的には、コンシューマ側で「コラボレーターオブジェクト(APIを呼び出し、結果をオブジェクトにエンコードする層)に対するテストコード」という形で実装します。

このテストを実行し成功すると、(Pactの場合はJSON形式の)「契約」が記載されたファイルが出力されます。

この契約ファイルをプロバイダに(何らかの方法で)共有し、プロバイダ側で契約が正しく守られているかを

(統合テストの形で)検証する、というのが一連の流れです。


コンシューマ駆動契約テストの特徴はその名の通り、コンシューマが自身の求めるインターフェースを率先して規定することです。

コンシューマB,C,Dがそれぞれ自身が必要とする最低限のインターフェースを規定し、

プロバイダAはそれら全ての契約を満たすように実装できて初めて、プロバイダAの変更をリリースできます。

これにより、リリースプロセスを素早く回すことが可能になります。

Hello World

前提

御託はこれくらいにして、そろそろ実装してみます。

前提として、今回テストするAPIの仕様は以下の通りです。

  • エンドポイント: /ping
  • レスポンス: {"message": "pong"}

これ以上ないほどシンプルですね。


以前短い文章を投稿する簡単なデモアプリを作成したので、

それを流用して上記のエンドポイントを新たに生やします。

デモアプリに関しては別のテーマで過去に記事にしているので、興味のある方はそちらをご覧くださいmm

フロントエンド(コンシューマ)

フロントエンドの実装は以下のようになりました。

テストコードライブラリにはJestを使用しています。

diff --git a/.gitignore b/.gitignore
index fd3dbb5..afa95f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
 
 # testing
 /coverage
+/pacts
 
 # next.js
 /.next/
diff --git a/app/api/microposts/service.ts b/app/api/microposts/service.ts
new file mode 100644
index 0000000..24979c6
--- /dev/null
+++ b/app/api/microposts/service.ts
@@ -0,0 +1,11 @@
+export default function MicropostService(baseUrl: string) {
+    const ping = async (): Promise<{message: string}> => {
+        const res = await fetch(`${baseUrl}/ping`, {
+            headers: {
+              "Content-Type": "application/json",
+            }
+        });
+        return res.json();
+    }
+    return {ping}
+}
\ No newline at end of file
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..7113c5a
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,8 @@
+/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
+module.exports = {
+  preset: 'ts-jest',
+  testEnvironment: 'node',
+  moduleNameMapper: {
+    '^@/app/(.*)$': '<rootDir>/app/$1'
+  },
+};
\ No newline at end of file
diff --git a/tests/api.pact.spec.ts b/tests/api.pact.spec.ts
new file mode 100644
index 0000000..330712b
--- /dev/null
+++ b/tests/api.pact.spec.ts
@@ -0,0 +1,33 @@
+import MicropostService from "@/app/api/microposts/service";
+import { MatchersV3, PactV3 } from "@pact-foundation/pact";
+import path from "path";
+
+const provider = new PactV3({
+    dir: path.resolve(process.cwd(), 'pacts'),
+    consumer: 'auth0-next-custom',
+    provider: 'google-login-back'
+});
+
+describe('GET /ping', () => {
+    it('200レスポンス(pong)を返すこと', async () => {
+        provider.addInteraction({
+            states: [{description: 'pongレスポンスに成功する場合'}],
+            uponReceiving: 'pingリクエストを送信する',
+            withRequest: {
+                method: 'GET',
+                path: '/ping',
+            },
+            willRespondWith: {
+                status: 200,
+                headers: {'Content-Type': 'application/json'},
+                body: MatchersV3.equal({message: 'pong'}),
+            }
+        });
+
+        await provider.executeTest(async (mockserver) => {
+            const service = MicropostService(mockserver.url);
+            const response = await service.ping();
+            expect(response.message).toStrictEqual('pong');
+        })
+    })
+});


Pactは記載した契約の通りに動作してくれるモックサーバを提供してくれます。

今回の実装ではAPIサービスの名前がMicropostServiceとなっていますが

これは将来的に投稿に関するAPIをサポートしたいという意味であまり気にしないでください。

テストを実行すると/pactsに契約ファイル(JSON)が置かれます。

生成された契約ファイル(JSON)

上記を実行したところ以下のファイルが生成されていました。


pacts/auth0-next-custom-google-login-back.json

{
  "consumer": {
    "name": "auth0-next-custom"
  },
  "interactions": [
    {
      "description": "pingリクエストを送信する",
      "providerStates": [
        {
          "name": "pongレスポンスに成功する場合"
        }
      ],
      "request": {
        "method": "GET",
        "path": "/ping"
      },
      "response": {
        "body": {
          "message": "pong"
        },
        "headers": {
          "Content-Type": "application/json"
        },
        "matchingRules": {
          "body": {
            "$": {
              "combine": "AND",
              "matchers": [
                {
                  "match": "equality"
                }
              ]
            }
          },
          "header": {},
          "status": {}
        },
        "status": 200
      }
    }
  ],
  "metadata": {
    "pact-js": {
      "version": "13.1.0"
    },
    "pactRust": {
      "ffi": "0.4.21",
      "models": "1.2.2"
    },
    "pactSpecification": {
      "version": "3.0.0"
    }
  },
  "provider": {
    "name": "google-login-back"
  }
}


バックエンド(プロバイダ)

バックエンドの実装は以下のとおりです。

テストコードライブラリにはpytestを使用しています。

diff --git a/.gitignore b/.gitignore
index 25a656d..7a5766a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
 __pycache__/
 .vscode/
 dynamodb/
+pacts/
diff --git a/app.py b/app.py
index fead48f..51b7f80 100644
--- a/app.py
+++ b/app.py
@@ -179,3 +179,8 @@ def get_micropost_for_auth0():
     posts = Microposts.query(payload.get("sub"))
     res = [post.to_simple_dict() for post in posts]
     return Response(body=res, status_code=200)
+
+
+@app.route("/ping", methods=["GET"])
+def ping():
+    return Response(body={"message": "pong"})
diff --git a/docker-compose.yml b/docker-compose.yml
index e3d4662..cca3044 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,8 @@ services:
       - 8002:8002
     environment:
       DB_ENDPOINT: http://dynamo:8000
+      TEST_PORT: 8008
+      PYTEST_ADDOPTS: "-v --pact-files=pacts/*.json"
     command: chalice local --host=0.0.0.0 --port=8002
   dynamo:
     image: amazon/dynamodb-local
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..80d7fd0
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,18 @@
+import pytest
+import subprocess
+import time
+import os
+
+
+@pytest.fixture(scope="session")
+def start_chalice_local():
+    process = subprocess.Popen(
+        ["chalice", "local", "--host=0.0.0.0", f"--port={os.getenv('TEST_PORT')}"],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    # サービスが完全に起動するまで待機
+    time.sleep(5)
+    yield
+    process.terminate()
+    process.wait()
diff --git a/tests/test_pacts.py b/tests/test_pacts.py
new file mode 100644
index 0000000..e103c0c
--- /dev/null
+++ b/tests/test_pacts.py
@@ -0,0 +1,9 @@
+import os
+
+
+def provider_state(name, **params):
+    pass
+
+
+def test_pacts(pact_verifier, start_chalice_local):
+    pact_verifier.verify(f"http://localhost:{os.getenv('TEST_PORT')}", provider_state)


契約ファイルはフロント側で生成されたものをバックエンドのリポジトリ配下の/pactsに手動でコピーしています。

実運用ならもちろん手動なんてありえないですが、今後の課題とします。


上記で、実質的なテストの中身は以下の1行だけです。

pact_verifier.verify(f"http://localhost:{os.getenv('TEST_PORT')}", provider_state)


実行時に参照するpactファイルの場所を指定する必要がありますが、pytestの実行オプション(--pact-files=pacts/*.json)として指定します。

毎回指定するのは面倒なので、PYTEST_ADDOPTS環境変数に指定しました。


工夫した点としてはconftest.pyでテスト実行ごとにchaliceサーバを立ち上げるようにしたことです。

dockerで起動しているのでそこに直に接続でもいいのですが、テスト環境はローカル開発環境とは切り離したいです。

またchaliceは基本的にはホットリロードですがグローバル変数などの変更は再起動が必要だったと思うので、

そういった意味でもテストごとに起動したほうが何かと都合がいいと思います。

またscope="session"にすることで1回のテスト実行の中ではサーバの起動は最初の1回だけでいいようにしています。

今後の課題

今回はとりあえずPactにHello Worldしてみました。

今後やってみたいことを以下にリストアップしておきます。

  • CI/CDパイプラインに組み込む
  • 契約ファイルの共有を自動化する
    • ドキュメントとしても活用できたら最高
  • 状態を定義する
    • DBの状態や認証状態など
  • プロバイダ自身が外部に依存している場合
  • プロバイダがAPIではなくキューを使ったイベント駆動の場合

上記でき次第また記事にしようと思います。

ご覧頂きありがとうございました。

この記事をシェアする