JavaScript
test
docker
docker-compose

Dockerで外部APIをモックしたE2Eテスト環境を作る

外部APIをモックした自動テスト

お盆に、「外部APIはモックするけれど、内部のシステムは全体をテストするテスト」を作ったので、「外部APIをモックした自動テスト」の作り方を記録します。
(仕事とは関係ないので、動作確認は雑です)

全体像

以下のものを使用して作成しました。

  • Docker(docker-compose)
  • MockServer
  • TestCafe(TypeScript)
  • Nginx
  • Rails, webpack-server, mysqlなど各種サーバー

外部リクエストをMockServerが仲介することで、外部に依存せずにブラウザから色々なテストをできるようにしています。
図にすると、こんな構成です。

Network Diagram (1).png

Docker

テスト中だけサーバーを立てるようにしたかったのと、テスト環境ぐらいは楽に環境を作りたかったのでDockerを使用しています。
TestCafe以外は全てDocker内部に作りました。

MockServer

Java製のモックサーバー(js用のクライアントも有る)

今回のテストのキモ
外部APIのレスポンスを固定化する目的で使用しています。
外部APIの結果を使用する箇所があるので、結果を固定したほうがテストしやすいので外部APIをモックしました。

TestCafe

コードからブラウザを動かすためのツール(HeadlessChrome、IE対応有り)

SeleniumなしでSeleniumのようなことができます。
初めて使いましたが、簡単にかけるので結構気に入りました。TypeScriptも使えます。
Promiseで結果を待つのでawaitばかりになるのが辛いですが、それでもコードがすっきりします。

Nginx

APIリクエストをMockServerへ受け流すために使用
外部通信を全てモックするのは大変なので、特定ドメインのみをnginxが受けて、結果を使用しないapiはそのまま外部へ流しています。

詳細

基本的なテストの流れは、

  1. TestCafeがブラウザを起動しページを開く
  2. TestCafe内でMockServerにレスポンスを登録する
  3. Testcafeがユーザー登録など何かしらアクションする
  4. アプリが外部APIをNginxに投げる
  5. NginxがMockServerに渡す
  6. MockServerが指定されたレスポンスを返す
  7. アプリが色々と処理をする
  8. ブラウザに結果が出る
  9. TestCafeが検証する

という流れです。

コード

実際のコードを見ていきましょう。
TestCafeでは以下のことを実行しています。

  • ブラウザを操作する
  • モックを登録する
  • Callbackなど外部サービスの動きを模倣する

ブラウザを操作する

ブラウザを操作するコードはclick()やtypeText()などを使用して行います。

fixture("Login")
    .page("http://mysite.dev");

test("Emulate a user login", async (t) => {
     await t.click("#login-modal-open-button")
        .typeText("#username", "test user")
        .typeText("#password", "password")
        .click("#login-button")
})

モックを登録する

MockServerではjavascript用のクライアントが用意されているので、それを利用します。
モック操作はテスト間で重複するので別クラスに切り出しています。

import * as mockServer from "mockserver-client";

export class MockServer {
    private mockClient: IMockClient;

    constructor(host: string, port: number) {
        this.mockClient = mockServer.mockServerClient(host, port);
    }

    public async reset() {
        await this.mockClient.clear();
    }

    public async addTestSiteMock(path: string, body: string) {
        await this.mockClient.mockAnyResponse({
            httpRequest: {
                headers: [
                    {
                        name: "Host",
                        values: ["test.site.dev"],
                    },
                ],
                method: "GET",
                path,
            },
            httpResponse: {
                body,
                statusCode: 200,
            },
            times: {
                unlimited: true,
            },
        });
    }
}

MockServerでは、Hostなどリクエストヘッダーに対して条件を書くことができるので、Mockserver#addTestSiteMock()では、test.site.dev/{path} に対するレスポンスを定義しています。
さらに、MockServerはデフォルトでは1回だけしかモックしないため、unlimitedを指定することで永久にモックしています。
Mockserver#reset()は、全てのモックを削除するためのものです。

こうすることで、TestCafeの中から、

test("Emulate a request to test.site.dev", async (t) => {
    const mockServer = new MockServer("localhost", 1080);
    await mockServer.reset();
    await mockServer.addSimpleTestSiteMock("/hello", "<html><body>Hello World</body></html>");
    ...
    t.click("#something")
    ...
})

と書くことで、test.site.dev/helloに対するモックが作れます。
あとは、ひたすらモックを書くことで、各APIをモックすることができるようになります。

Callbackなど外部サービスの動きを模倣する

外部APIの中にはこちらからリクエストを送るだけでなく、外部からリクエストを待つ必要がある場合も有ります。
例えば、時間のかかる作業をAPI経由で依頼した場合に、作業終了時に相手側から通知が来る場合などです。

今回のテストでは、任意のタイミングで終了通知をサーバーに送ることで、安定した内容をテストできるようにしています。

export enum OperationStatus {
    PROCESSING = "processing",
    COMPLETE = "complete",
}

export class OperationOrder {
    constructor(public jobId: number, public status: OperationStatus) {}

    public makeCallback(): RequestPromise {
        const callbackOptions = {
            method: "POST",
            uri: "http://mysite.dev:3000/operation_callback",
            formData: {
                job: JSON.stringify({
                    job_id: jobId,
                    status: OperationStatus[status],
                }),
            },
            resolveWithFullResponse: true,
        };

        return requestPromise(callbackOptions);
    }
}

test("Emulate a request to test.site.dev", async (t) => {
    const order = new OperationOrder(123456, OperationStatus.PROCESSING);
    ...
    order.status = OperationStatus.COMPLETE;
    await order.makeCallback().then((response) => {
        return t.expect(response.statusCode).eql(202);
    });
    ...
})

このようにすることで、外部からのコールバックを模倣し、コールバックが終了したことを確認したあとに処理を継続できます。

Docker部分

今回のテストでは、TestCafe以外の全てのプロセスをDockerに押し込んでいます。
docker-composeは次のような構成になります。

version: "3"

services:
    rails:
        build: ./rails
        volumes:
            - ./rails/src:/src
            - /src/node_modules
        ports:
            - "3000:3000"
            - "3008:3008"
        environment:
            - RAILS_ENV=auto_integration_test
    mysql:
        build: ./mysql
    ....

    nginx:
        build: ./nginx
        volumes:
            - ./nginx/nginx.conf:/etc/nginx/nginx.conf
        networks:
            default:
                aliases:
                    - test.site.dev
                    - api.site2.dev
                    ...
    mock-server:
        image: jamesdbloom/mockserver
        entrypoint: /opt/mockserver/run_mockserver.sh -logLevel INFO -serverPort 1080
        ports:
            - 1080:1080

やっているのは

  • railsやmysqlなどテスト対象用のコンテナ作成
  • test.site.devなどをnginxに向けるようにする
  • MockServerを1080番ポートで動かす

などです。
nginxでは、80と443の両方を開くのでDockerfile内で自己証明書を作成し、既存のnginx.confを上書きしています。
nginx.confのserver部分はこんな感じです。

upstream backend {
    server mock-server:1080;
}

server {
    listen 443 default;
    ssl on;
    ssl_certificate      /etc/nginx/certs/server.crt;
    ssl_certificate_key  /etc/nginx/certs/server.key;

    location / {
        proxy_set_header Host $http_host;
        proxy_pass    https://backend/;
    }
}

server {
    listen 80;

    location / {
        proxy_set_header Host $http_host;
        proxy_pass    http://backend/;
    }
}

ハマったところ

この流れであれば1日か2日で終わるかなと考えていたのですが、予想外にハマって時間がかかってしまったので、
ハマったところを列挙します。

Docker化するという作業

Docker化していない部分があったので、その分をDockerfileに書いていたのですが、意外と量があり疲れました。
しかも、volumeを使用するとビルドしたファイルたち(bundlerやyarn結果)が消えるので、戸惑いました。
結局、このあたりを見て、パス名を書くと元のものを使用でできることがわかりました。

ユーザーをどう作るか

普通のテストでも起きる問題ですが、ユーザーをどう作るか考えてしまいました。

  • Seedなどで直接DBを変える
  • ユーザーを作成するRakeを作ってテストコードから実行する
  • 毎回サインアップする

などの選択肢がありますが、
直接DBを変えるとスキーマの変更があったときにテストコードも対応する必要があるのであまり好きではありません。
また、Rakeを叩くことについて、今回は「ブラウザの操作だけでテストしたい」という思いがあったので、これにも躊躇しました。

結局、毎回ユーザーを作ることにしました。
ただユーザーを作る時間が無駄なので、いつかはユーザー作成処理を最初の一回だけにするようにしたいと思います。

名前解決

外部APIを別コンテナに向けるためにdocker-composeのaliasesを使用しました。
DNSを立てるとか、default-gatewayを変えるとかできそうですが、aliasesで問題ないのでこれにしました。

証明書

モックサーバーは正規の証明書を使用できないので、証明書のチェックをすり抜ける必要があります。
最初はオレオレ認証局立てるかな、と考えていたのですがRubyでは

OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE

とすれば証明書チェックを無視することができることがわかったので、これでしのぎました。

MockServerが複数ポートで動かせない

APIは全部HTTPSかと思いきや、速度の問題からかHTTPを使用しているものもあったので2ポート分モックする必要がありました。
が、MockServerは1ポートしか対応していないようでした。
そのため、80と443番のために2つMockServerを立てるか、nginxなどからリバースプロキシでまとめるか考えた結果、
リバースプロキシを使って、MockServerは1プロセスにしました。

管理者による承認が必要な部分

テスト対象の中には「管理者が承認したら処理を続ける」というものがありました。
困ったのが「一般ユーザーが管理者を作る」というのがブラウザからの操作ではできないことで、なんとかして管理者の動きを模倣する必要がありました。

解決策としては、

  • 管理者コマンドを実行できるようにHTTPリクエストに抜け道を作る
  • 専用のRakeタスクを作る
  • DBを直接変える
  • RailsのコードをTestCafe側にも用意して、Rubyを直接動かせるようにする
  • 自動で承認する

など考えましたが、テストのためにセキュリティホールっぽいものを作るのも嫌なのと、テスト用に別のコードを書くのも面倒だったので、
とりあえず自動テストをしている環境では、管理者なしで自動的に承認されるようにしました。

まとめ

これで、色々なことがありながらとりあえずテストは普通に動くようになりました。
重要だったのは、MockServerをどう上手く活用するかといった感じです。