FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

自作プロキシサーバーのE2EテストをPactを流用して作ってみた

こんにちは!菅野です。 この記事は10周年記念ブログリレーの28日目の投稿です!

突然ですが、皆さんが作るアプリはきちんと自動テストを書いてますか? 私は普段はユニットテストを書いて保守性を高めているのですが、最近作った認証プロキシサーバーではE2Eテストも書くことで保守性を確保することにしました。

背景としてはDependabot*1が各ライブラリのバージョンの更新をするプルリクエストを自動的に作成するのですが、これを承認するのにかかる手間を削減したかったからです。ライブラリの更新で問題が発生したらいち早く検知できるし、E2Eテストの内容でリリースに問題がないことが確認ができればそのままマージするだけで済むので楽チンです!

E2Eテストをする方法はいくらかあると思いますが、今回はPactを使って実現できないかどうか試してみました。

Pactとは

Pactの概要

Introduction | Pact Docs

Pactはマイクロサービス間での契約テストと呼ばれるものを実行するためのツールです。コンシューマとプロバイダの仕様についての契約が書かれたjsonファイルを作り、それを各サービスでテストすることによって契約が守られていることを保証します。

契約についてはコンシューマ側のテストコード*2で作ることが出来て、契約が書かれたjsonファイルをPactブローカーサーバーにアップロードして管理することによって他のサービスでのテスト時に利用することが出来ます。
Pactブローカーには各サービスのテスト状況を把握して、あるバージョンがリリース可能かどうかを管理する機能もあります。

Pactにはプロバイダをモックしたり、コンシューマからのアクセスを再現したりする機能があり、それぞれのHTTP通信内容の検証をすることが出来るのでPactだけでマイクロサービスの契約テストが完結します。
また、様々な言語での実装が公開されているのでマイクロサービスがどんな言語で書かれていても困ることは少ないと思います。

PactをサーバーのE2Eテストに使ってみたい

さて、冒頭で言っていた認証プロキシサーバーのE2Eテストを書きたい事案なのですがどうやって実現しましょう?
ちなみにサーバーはRustで書かれています。

WireMockでプロキシ先をモックするのは良いとしても、クライアント側のリクエスト送信とレスポンスの検証も必要です。
手書きでレスポンスの検証をしても良いのですが何らかのテストツールを使えたらそっちを使いたいですよね。 そこでどうにかしてPactを使えないか試してみました。

Pactを使うメリット?

契約テストじゃないけどPactを使うメリットを挙げてみます。

  • Pactが契約通りにクライアントとサーバーのモックをするので楽チン
  • リクエストとレスポンスの検証もPactがするので検証方法について悩む必要がない
  • Pactを知っていればほとんどのプログラミング言語でナレッジを活かせる

ちょっと試して見る価値はあるんじゃないでしょうか?

Pactでテストを書いてみる

下記のようなコードが完成しました。

// tests/integration_test.rs
use std::env;
use std::sync::Arc;

use anyhow::Result;
use expectest::prelude::*;
use pact_consumer::prelude::*;
use pact_models::pact::{read_pact, write_pact};
use pact_models::PactSpecification;
use pact_verifier::callback_executors::HttpRequestProviderStateExecutor;
use pact_verifier::verification_result::VerificationResult;
use pact_verifier::*;


async fn start_proxy_app(port: u16) {
    // テスト対象のサーバーを良い感じに起動するコード
}

/// 引数の契約ファイルを使ってPactでリクエストを発行してレスポンスを検証する
async fn verify(fixture_path: &str) -> Result<VerificationResult> {
    let provider_info = ProviderInfo {
        transports: vec![ProviderTransport {
            port: Some(8888),
            ..Default::default()
        }],
        ..Default::default()
    };
    let pact_file = env::current_dir()?.join(fixture_path).to_owned();
    let pact = read_pact(pact_file.as_path())?;
    let verification_options = VerificationOptions {
        request_filter: None::<Arc<NullRequestFilterExecutor>>,
        no_pacts_is_error: true,
        ..Default::default()
    };
    let provider_state_executor = Arc::new(HttpRequestProviderStateExecutor::default());
    pact_verifier::verify_pact_internal(
        &provider_info,
        &FilterInfo::None,
        pact,
        &verification_options,
        &provider_state_executor,
        false,
        Default::default(),
    )
    .await
}

#[tokio::test]
async fn test_proxy() {
    // ※認証の具体的な部分は端折ってます
    let session = "FOO";
    let cookie_value = format!("TOKEN={session}");
    // クライアントからプロキシサーバへのやり取りの仕様
    let pact = PactBuilder::new("Client", "Proxy")
        // プロキシ先のレスポンスを返すパターン
        .interaction("client-proxy-destination request", "", |mut i| {
            i.request.path("/aaa/bbb");
            i.request.header("cookie", cookie_value.clone());
            i.response
                .status(200)
                .content_type("text/plain")
                .body("proxy body");
            i
        })
        // 認証前なので認証画面を表示するパターン
        .interaction("auth page", "", |mut i| {
            i.request.header("accept", "text/html").path("/aaa/bbb");
            i.response
                .status(200)
                .content_type("text/html")
                .body_matching2(Like::new("csrf: \"BAR\""), "text/html");
            i
        })
        .build();

    let pact_path = env::current_dir()
        .unwrap()
        .join("target/pacts/Client-Proxy.json")
        .to_owned();

    // クライアントとプロキシサーバー間の契約をファイルに保存
    write_pact(pact, &pact_path, PactSpecification::V4, true).unwrap();

    // プロキシサーバからプロキシ先へのやり取りの仕様
    let destination = PactBuilder::new("Proxy", "Destination")
        .interaction("proxy request", "", |mut i| {
            i.request.path("/bbb");
            i.request.header(
                "x-goog-authenticated-user-email",
                "securetoken.google.com/AUDIENCE:user@example.com",
            );
            i.request.header("x-fl-uid", "user1");
            i.request.header("authorization", "Bearer ID_TOKEN_JWT");
            i.response.content_type("text/plain").body("proxy body");
            i
        })
        .start_mock_server(None); // 仕様に従ってモックサーバーを起動

    // テスト実行
    let destination_port = destination.path("/bbb").port().unwrap();
    tokio::select! {
        _ = start_proxy_app(destination_port) => {}
        result = async {
            verify("target/pacts/Client-Proxy.json").await.unwrap()
        } => {
            expect!(result.results.get(0).unwrap().result.as_ref()).to(be_ok());
        }
    }
}

やらなきゃいけないことはプロキシサーバーが正しいレスポンスを返すかどうかとプロキシ先に正しいリクエストを送るかどうかの検証ですが、すべてPactがやってくれました。
/aaa/bbbというパスのリクエストがプロキシ先の/bbbへと向かうかどうかや、セッションが無効の場合は認証画面っぽいものが返ってきているかどうかを検証しています。

expect!で結果を検証してるようにも見えますが、実際の仕事はすべてverify関数内で行っています。
今回はPactブローカーは要らないのでローカルにjsonファイルを保存してそれを読み込んでリクエストを投げてもらうようにする部分が一番の肝です。
PactのRust実装にはちょうどよいところにverify_pact_internalという関数があったのでこれを使って簡単に実装できました。*3

他に気をつけるところは、

  • Pactが起動したプロキシ先をモックするサーバーのポートを取得し、それをプロキシ先として設定した状態でテスト対象を起動すること
  • select!などを使ってテスト対象を起動した状態でPactのテストを実行するように書くこと

などです。

Pactはもっと流行れ

このようにinternalと名付けられている関数を躊躇なく使うことによってあっさりとE2Eテストを完成させることが出来ました!
…まぁ、だめになったらきちんと作ります。

まだまだPactは巷で聞くことが少ないツールだと思います。
マイクロサービス間の契約テストに使ったり、行儀の悪い使い方をしたりしてもっとユーザーが増えて欲しいと考えています!
そうすることでPactの使い勝手や機能が進化していったらみんな幸せになれるのではないでしょうか!

ということで、Pactを使って幸せな自動テストライフを!

*1: GitHubのDependabotは依存ライブラリに脆弱性が発見された場合のアラートや、修正バージョンにアップデートするPRを自動的に作成するBot

*2: プロバイダ側から作ることもできる

*3: 使って良いのかは謎