外部APIをモックした自動テスト
お盆に、「外部APIはモックするけれど、内部のシステムは全体をテストするテスト」を作ったので、「外部APIをモックした自動テスト」の作り方を記録します。
(仕事とは関係ないので、動作確認は雑です)
全体像
以下のものを使用して作成しました。
- Docker(docker-compose)
- MockServer
- TestCafe(TypeScript)
- Nginx
- Rails, webpack-server, mysqlなど各種サーバー
外部リクエストをMockServerが仲介することで、外部に依存せずにブラウザから色々なテストをできるようにしています。
図にすると、こんな構成です。
Docker
テスト中だけサーバーを立てるようにしたかったのと、テスト環境ぐらいは楽に環境を作りたかったのでDockerを使用しています。
TestCafe以外は全てDocker内部に作りました。
MockServer
Java製のモックサーバー(js用のクライアントも有る)
今回のテストのキモ
外部APIのレスポンスを固定化する目的で使用しています。
外部APIの結果を使用する箇所があるので、結果を固定したほうがテストしやすいので外部APIをモックしました。
TestCafe
コードからブラウザを動かすためのツール(HeadlessChrome、IE対応有り)
SeleniumなしでSeleniumのようなことができます。
初めて使いましたが、簡単にかけるので結構気に入りました。TypeScriptも使えます。
Promiseで結果を待つのでawaitばかりになるのが辛いですが、それでもコードがすっきりします。
Nginx
APIリクエストをMockServerへ受け流すために使用
外部通信を全てモックするのは大変なので、特定ドメインのみをnginxが受けて、結果を使用しないapiはそのまま外部へ流しています。
詳細
基本的なテストの流れは、
- TestCafeがブラウザを起動しページを開く
- TestCafe内でMockServerにレスポンスを登録する
- Testcafeがユーザー登録など何かしらアクションする
- アプリが外部APIをNginxに投げる
- NginxがMockServerに渡す
- MockServerが指定されたレスポンスを返す
- アプリが色々と処理をする
- ブラウザに結果が出る
- 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をどう上手く活用するかといった感じです。