こんにちは。Inflabでバックエンド開発を担当しているINTです。
私は最近オープンした採用サービスRallitのプロジェクトに参加しました。
既存のInflearnのサービスとは異なるスタックと環境で開発されており、その過程で多くの試行錯誤と検討を重ねました。
本記事では、その中からAWSサービスを使うロジックの統合テストのために localstack を導入した過程を共有したいと思います。
統合テスト
私たちは、正しく動作するきれいなコードのためにテストを書きます。
テストは依存関係の有無によって、大きく3種類に分けられます。
- e2e
- integration
- unit
本記事で取り上げるテーマは統合(integration)テストで、その中でもAWSリソースと連携する必要があるテストを扱います。
今回新規プロジェクトを進める中で SES と S3 のサービスが必要になり、関連するモジュールを作ることになりました。
作成したモジュールに対してユニットテストを書きましたが、AWSを使うロジックをユニットテストだけで検証するには不十分だと感じました。
そこで統合テストを書こうと考え、そのために実際のAWSリソースを使おうとしましたが、次のような問題が予想されました。
- コスト
テストが実行される状況は、通常は次のとおりです。
- 開発者がそれぞれローカルで実行
- CI / CD
1日に何十回もテストが実行される状況で毎回AWSリソースを使っていると、無視できないコストの問題が発生する可能性があります。
- secretの管理
実際のAWSリソースにアクセスするには、secret access key のような接続情報が必要です。
そのためには各開発者のローカルにその情報を提供する必要があり、その分漏洩の可能性も高くなります。
- テスト間の独立性
正しいテストのためには、テストケース間の独立性が重要です。
つまり、テストの順序によって結果が変わったり、あるテストの結果が別のテストに影響を与えたりしてはいけません。
しかし1つのAWSリソースを使う場合、テストを並列実行すると問題が発生する可能性があります。
例えば、SQSにメッセージを入れるテストコードと取り出すテストコードがあり、2つのテストが同時に実行されると、タイミングによっては取り出したメッセージが期待した値と異なり、ときどきテストが失敗することがあります。
これを解決するためにテストごとに別のAWSリソースを使うようにすることもできますが、リソースを無制限に増やすことはできないという限界があります。
こうした問題を解決するために、今回 localstack を導入しました。
Localstack
localstackは、AWS APIをシミュレートするフレームワークです。
Dockerコンテナの形で提供されており、AWS REST API の仕様に沿ったモックHTTPサーバーを起動します。
無料版と有料版があり、有料版ではより多くのAWSサービスが提供されます。
上の図のように、ローカル環境とCI環境でそれぞれ独立したテスト環境を構築できます。
コンテナを起動する方法としてはdockerを使う方法と testcontainers を使う方法がありますが、まずはそれぞれの長所と短所を見ていきたいと思います。
Testcontainersで実行する
紹介
Testcontainersは、その名のとおりテストのためのコンテナのライフサイクルを管理するライブラリです。
つまり、コンテナの起動、初期化の待機、終了を制御できます。
java、node.js、go など、さまざまなプログラミング言語をサポートしています。
node.js で使う場合は、beforeAll や beforeEach でこのライブラリを使ってコンテナを立ち上げます。
let localstackPort: number;
let container: GenericContainer;
// コンテナの起動
beforeAll(async () => {
container = await new GenericContainer("localstack/localstack")
// 内部の4566ポートを外部の任意のポートで公開
.withExposedPorts(4566)
// 環境変数の設定
.withEnv("SERVICES", "ses")
// コンテナのログにこの文言が出るまで待機
.withWaitStrategy(Wait.forLogMessage('Execution of "preload_services"'))
.start();
localstackPort = container.getMappedPort(4566);
});
// コンテナの終了
afterAll(() => container.close());
testcontainers は、複数のコンテナを同時に起動しても、外部に公開されるポートが重複しないようにしてくれます。
そのため、テストごとに別々のコンテナに接続させることができ、並列実行が可能で、テスト間の独立性も保証されます。
デメリット
私たちは testcontainers の導入を検討しましたが、最終的にtestcontainersの代わりにdocker-composeを活用することにしました。その理由は次のとおりです。
単発のテスト実行が遅くなる
テストを実行するかどうかに関係なく常にコンテナを起動しておけるdockerの方式とは異なり、テストを実行するときにコンテナを起動し、テスト終了後に後処理をする手順が必要です。
そのため、テストを1件だけ実行する場合の速度が、dockerに比べてかなり遅くなります。
開発プロセス全体では、全テストよりもテストを1件ずつ実行することのほうが多いため、生産性の面ではむしろ損だと判断しました。
思ったほど並列実行のメリットが得られない
testcontainersによって得られる独立性を活かして複数のテストを並列に実行できますが、大量のコンテナを立ち上げたり停止したりする負荷が無視できないため、大きな速度向上は期待しにくい状況でした。
Docker composeで実行する
以上の理由から、私たちはdockerで単一のlocalstackを起動することに決めました。ここからはその過程を説明します。
例ではSESとS3を使っています。
環境変数
localstack には、コンテナ設定のための環境変数が複数用意されています。
今回の例で使う環境変数は SERVICES で、localstack がどのサービスを起動するかを設定します。
何も値を指定しない場合はすべてのサービスを起動し、その分時間がかかるため、必要なサービスだけを指定するのがおすすめです。
そのほかの環境変数については、以下のリンクを参照してください。
初期化スクリプトの作成
SESを使うには送信者のメールアドレスを事前に登録しておく必要があり、S3はバケットを作成する必要があります。
localstackも実際にメールを送ったりバケットを作ったりはしませんが、登録APIやバケット作成APIを事前に呼び出していない場合はエラーレスポンスを返します。
SDKを使ってテストコードの実行前に登録APIを呼び出してもよいのですが、初期化スクリプトを活用すると、事前準備を手軽に行えます。
localstack は、コンテナの起動時に /docker-entrypoint-initaws.d パス配下のスクリプトファイルを読み込んで実行します。
これを利用して、まず以下の内容のスクリプトを localstack-init ディレクトリ配下に作成します。
- init.sh
#!/bin/sh
echo "Init localstack"
awslocal s3 mb s3://test-bucket
awslocal ses verify-email-identity --email-address test@email.com
awslocal コマンドは、localstack のAPIサーバーにリクエストを送る点を除けば、aws cliと使い方は同じです。
内容を見ると、test-bucket という名前のバケットを作成し、test@email.com のメールアドレスを認証する処理を行っています。
docker-compose.ymlの作成
次に、docker composeファイルを以下のように作成します。
- docker-compose.yml
version: "3.9"
services:
localstack:
image: localstack/localstack
ports:
- "4566:4566"
environment:
- SERVICES=ses,s3
container_name: localstack
volumes:
- "./localstack-init:/docker-entrypoint-initaws.d"
localstackの起動
以下のコマンドを実行するとコンテナが起動し、ログから、初期化スクリプトが実行され、S3とSESのサービスが4566ポートで立ち上がったことを確認できます。
$ docker-compose up
テストコード
次のような簡単なテストコードを書いて実行すると、成功することを確認できます。
import { SendEmailCommand, SESClient } from "@aws-sdk/client-ses";
describe("SESのテスト", () => {
const client = new SESClient({
region: "local", // 任意のregionを指定しても正常に動作します。
credentials: { secretAccessKey: "test", accessKeyId: "test" }, // アカウント情報も任意の値で正常に動作します。
endpoint: "http://localhost:4566", // localstackのアドレスに変更します。
});
it("登録済みの送信者アドレスでメール送信をリクエストすると成功レスポンスを受け取る", async () => {
// given
const from = "test@email.com";
const command = new SendEmailCommand({
Source: from,
Destination: {
ToAddresses: ["foo@domain.com"],
},
Message: {
Subject: {
Data: "メールの件名",
Charset: "UTF-8",
},
Body: {
Html: {
Data: "メールの本文",
Charset: "UTF-8",
},
},
},
});
// when
const response = await client.send(command);
// then
expect(response.$metadata.httpStatusCode).toBe(200);
expect(response.MessageId).toBeTruthy();
});
it("未登録の送信者アドレスでメール送信をリクエストするとエラーが発生する", async () => {
// given
const from = "invalid@email.com";
const command = new SendEmailCommand({
Source: from,
Destination: {
ToAddresses: ["foo@domain.com"],
},
Message: {
Subject: {
Data: "メールの件名",
Charset: "UTF-8",
},
Body: {
Html: {
Data: "メールの本文",
Charset: "UTF-8",
},
},
},
});
// when
const send = () => client.send(command);
// then
await expect(send).rejects.toThrowError("MessageRejected");
});
});
Localstackを使う際の注意点
このようにlocalstackを使うと統合テストを行ううえで大いに助けになりますが、いくつか注意すべき点があります。
SES API v2に対応していない
本記事の執筆時点(2021年12月)では、まだSES APIのv2には対応していません。
実は最初、SESのテストのためにv2のSDKを使ったところ、テスト時に以下のようなエラーが出ました。
エラーメッセージだけでは、原因を突き止めるのは簡単ではありませんでした。
しかし、下のスタックトレースを掘り下げてみると、localstack が以下のようなレスポンスを返し、SDKがそれをJSONとしてパースしようとしてエラーが発生していたことが分かりました。
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title xmlns="http://ses.amazonaws.com/doc/2010-01-31/">404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
404エラーが発生したということは、SDKが呼び出したAPIが localstack に実装されていないのだろうと推測しました。
GitHubのissueを検索してみると、予想どおりv2に対応していないことが確認できました。
このissueを見ると、最近
available-in-proタグが付けられていることから、Pro版では対応しているようです。
統合テストをあきらめてv2を使うか、それともv1を使うか悩んだ末、v1を使うことにしました。
まだv1がdeprecatedになっておらず、テストによって安定性を確保することを優先したためです。
逐次実行と冪等性
すべてのテストが1つのlocalstackコンテナを使うため、独立性を保つには逐次実行する必要があります。
jest では、逐次実行のために --runInBand オプションを使います。
また、あるテストが localstack の状態を変更する場合、別のテストの実行前に状態を元に戻す処理をしなければ、逐次実行であってもテストが失敗する可能性があります。
つまり、冪等性が保証されない可能性があります。
以上の理由から逐次実行は避けられませんが、テストの数に比例して遅くなるというデメリットがあります。
これを解決するために、コードを複数のモジュールに分割し、全体テストでは各モジュールを並列に実行しつつ、モジュール内部でのみ逐次実行する方法が考えられます。
現在、私たちは Nest.js フレームワークを使っており、サービスごとに monorepo で構成しています。
そこで、サービス単位では並列に実行し、各サービスのテストは順番に実行するようにしました。
- package.json
{
"scripts": {
// 各サービスのテストは逐次実行します
"test:ci-service-a": "jest app/service-a --runInBand",
"test:ci-service-b": "jest app/service-b --runInBand",
"test:ci-service-c": "jest app/service-c --runInBand",
"test:ci-all": "run-p test:ci-*" // すべてのモジュールは並列で実行します
}
}
このように構成した場合、テストを実行するサーバー環境によって誤差はありますが、全体のテスト時間は3つのサービスのうち最も時間のかかるテストの時間と一致します。
CIではlocalstackが完全に起動してからテストを実行する必要がある
localstackはコンテナの起動後にも初期化処理が必要なため、コンテナ起動直後にテストを実行すると、ときどき失敗することがあります。
ローカル環境ではコンテナを起動したままにしておくので問題ありませんが、CIでは毎回起動するため問題になることがあります。
これを解決するには、コンテナをできるだけ早く起動しておき、ほかの事前作業をしている間に完了することを期待する方法や、
初期化が完了するまで定期的にlocalstackへHTTPリクエストを送り、正常なレスポンスが返ってくるまで待機する方法があります。
ただし注意すべき点として、コンテナの /docker-entrypoint-initaws.d パスにスクリプトを置いた場合、そのスクリプトはHTTPサーバーが立ち上がった後に実行されます。
この場合は、localstack コンテナのログを確認し続けて、そのスクリプトが実行されたかどうかを確認する必要があります。
例えば、以下のようなスクリプトの実行が必要な場合は、
#!/bin/sh
echo "Init localstack"
awslocal s3 mb s3://test-bucket
echo "Init localstack finished"
以下のスクリプトをコンテナの起動後に実行し、コンテナのログに Init localstack finished が出るまで待ちます。
#!/bin/sh
echo "checking if localstack id ready"
while true; do
echo "checking localstack..."
if docker logs -n 3 localstack 2>&1 | grep -q 'Init localstack finished'; then
break
fi
sleep 3
done
おわりに
ここまで、統合テストのための localstack の導入過程を見てきました。
Inflearnに転職して初めて AWS と localstack を使ったこともあり、多くの試行錯誤を重ねたように思います。
testcontainers と docker から始まり、トレードオフのある選択肢が次々と現れ、どれが良いか悩んだ過程が印象に残っています。
この記事が、localstack を導入しようとしている誰かの役に立てば幸いです。
最後に、長文を読んでいただきありがとうございました。



