こんにちは。フロントエンドエンジニアの@1010realです。 昨年の暮れに第一子が誕生し、現在は子育てに仕事に奮闘中です(ガンバルゾ!)
今回はCypressを用いたE2Eテストを自社プロダクトに導入した件について、経緯や仕組みなどを紹介しようと思います。
Cypressは、主に開発者とQAエンジニア向けに作られた、Javascript製のオープンソースなE2Eテスティングフレームワークです。 Cypressは従来のE2Eテストの主役であったSeleniumは使用していません。Selemiumベースのテスティングフレームワークとの決定的な違いは、All-in-oneなテスティングフレームワークである事です。 Seleniumは様々な言語で書けて、ライブラリを組み合わせる事で様々なテストができる反面、継続的なメンテナンスが必須となり、メンテナンスをサボった挙句、テストがぶっ壊れて放置なんてこともザラです。 一方、CypressはJavascript(※1)でしか書けませんが、cypressさえ入れれば、E2Eテストに必要な要素は一通り行うことができます(テストケースの登録と実行、スクリーンショットやビデオの保存他) > ※1 もちろんTypeScript化は可能
弊社のサービスの一つであるSmartMat Cloudは、アーキテクチャを刷新し、開発効率を上げるため、リプレースを行いました。昨年の春の話です。 リプレースした当初は、コードも整理されており、ビジネスとコードの乖離も少なく、ガンガン新機能を開発できていましたが、それから1年が過ぎ、ビジネスの成長に合わせてシステムも複雑化してきました。 最近ではリファクタリングと機能開発を並行で進めていますが、既存機能がデグレしていないかどうかをチェックするのがかなり大変になってきています(仕様の把握や単純なテストケース量の面において) そのため、E2Eテストを通して、主要な機能がこれまで通り動くかを定期的に担保することで、リファクタや機能開発の心理的安全性を高めようというのが背景となります。
Cypressの公式ページを見れば、現プロジェクトにCypressをインストールし、まずは一つテストを書いて動かすというチュートリアルがありますので、Cypressの動作をまず確認したいということであれば、ローカルでそれらを試して見ると良いかと思います。
ただ、管理やメンテナンス性を考えると個人的にはTypescript Deep Diveに書かれている分離インストールをおすすめします。理由は、既存のプロジェクトのnodejsのバージョンやjs or tsなどの設定に引っ張られず、最新のバージョンを使用できるためです。 尚、スマートショッピングでは、フロントエンドとは別リポジトリでE2Eテストを管理しているため、その手順に沿って紹介しますが、基本は上記の分離インストールと同じです。(ただ、テストケースに使用するDOMセレクタの定義をフロントと共有したい場合は、リポジトリを分けない方が良いです)
npm init -y
yarn add cypress typescript tsc
yarn tsc --init --types cypress --lib dom,es5
echo {} > cypress.json
"scripts": {
"cypress:install": "./node_modules/cypress/bin/cypress install",
"cypress:verify": "./node_modules/cypress/bin/cypress verify",
"cypress:run": "./node_modules/cypress/bin/cypress run",
"cypress:open": "./node_modules/cypress/bin/cypress open"
},
yarn cypress:open
を実行します
以下のような画面が表示されます。cypressがテストケースのサンプルをいくつもインストールしてくれるので一つ選択してみるとテストの様子が確認できると思います。
cypressが用意してくれたサンプルは全てjsなので、削除しちゃいます(マストではないです)
rm -rf cypress/integration/examples
最初のテストを cypress/integration/basic.spec.ts
に書きます
it('should perform basic google search', () => {
cy.visit('https://google.com');
cy.get('[name="q"]')
.type('subscribe')
.type('{enter}');
});
yarn cypress:open
でテストケースを実行してみてくださいこれまでの設定で、テストケースはTypescriptで書けるようになったのですが、CypressにはPlugin(※2)とCustom Commands(※3)という機能があり、そちらはTS化できていません。
※2 Pluginとは: Cypressの内部動作を変更するための機能。特定のイベントに紐つけて、挙動を記載できるので、テストの開始/終了時に時間を計測し、テストの実行時間を取得したり、ブラウザを開く前にextensionをロードしたりできます。
※3 Custom Commands: cy.visitやcy.getなどと同様に扱えるカスタムコマンドを追加できます。例えばローカルストレージやcookieの値をチェックするようなコマンドを作ることができます。
これらもTS化していきます。ついでにコマンドライン引数によってテストを実行する環境を切り分ける処理をプラグインに定義していきます。
"compilerOption": {
...
"baseUrl": "./",
...
},
"include": ["**/*.ts"]
local.json
{
"baseUrl": "https://localhost:3000" // ローカル環境のURL
}
development.json
{
"baseUrl": "https://xxxxxxxxx" // QA環境のURL
}
yarn add -D @types/node @types/fs-extra
ts.config
"types": [
"cypress",
"node", // 追加
"fs-extra" // 追加
]
import { readJsonSync } from "fs-extra";
import * as path from "path";
const getConfigurationByFile = (file: string): Cypress.PluginConfig => {
const pathToConfigFile = path.resolve("cypress", "config", `${file}.json`);
return readJsonSync(pathToConfigFile);
};
// モジュールをexportしている部分をまるっと変更
export default (
on: Function,
config: Cypress.ResolvedConfigOptions
): Cypress.PluginConfig => {
const file = config.env.configFile || "development";
return getConfigurationByFile(file);
};
package.json
"scripts": {
...
"cypress:open_local": "./node_modules/cypress/bin/cypress open --env configFile=local",
},
これで、pluginとcustom commandのTS化と、 yarn cypress:open_local
でローカル開発環境を対象としてテストを実行できるようになりました。
また、テストケースを書く際には、以下のようにドメインを含めずに書くと、指定環境にてテストが行えます。
cy.visit("/login")
弊社では毎朝10時に開発環境においてE2Eテストを回しています。 定期的に実行するならコンテナ化しておくと便利です。 ※ポイント絞って、解説していきます。
Dockerfile ※イメージサイズ縮小のため、マルチステージビルドを採用しています。
# ※1
FROM cypress/base:14.16.0 as base
ARG E2E_DIR
ENV E2E_DIR ${E2E_DIR}
# ※2
ENV CYPRESS_CACHE_FOLDER /${E2E_DIR}/.cache/Cypress
WORKDIR /${E2E_DIR}/
COPY ./docker-entrypoint.sh /usr/local/bin
COPY ./package.json /${E2E_DIR}/
COPY ./yarn.lock /${E2E_DIR}/
COPY ./tsconfig.json /${E2E_DIR}/
COPY ./cypress.json /${E2E_DIR}/
COPY ./cypress /${E2E_DIR}/cypress/
FROM base as builder
WORKDIR /build/
COPY . .
RUN yarn
RUN yarn cypress:install
RUN yarn cypress:verify
RUN cp -R node_modules all_node_modules
RUN cp -R $CYPRESS_CACHE_FOLDER cypress_cache
FROM base as release
ARG E2E_DIR
WORKDIR /${E2E_DIR}/
COPY --from=builder /build/all_node_modules ./node_modules
COPY --from=builder /build/cypress_cache $CYPRESS_CACHE_FOLDER
# ※3 xxxxには、実行時ユーザの uid/gid を指定
RUN chown -R xxxx:xxxx /${E2E_DIR}/
# ※4
# ENTRYPOINT ["/bin/bash"]
※1 cypressはスクリーンショットを保存したり、動画を記録したりするので、前提として必要なモジュールが結構あるので、cypress/baseなイメージを利用するのが吉です。 まっさらなイメージから本当に必要なモジュールだけインストールしたい方は、こちらを参考にすると良いと思います。
※2 Cypressは、バージョンごとにバイナリキャッシュを保存し、実行時にそれを参照します。デフォルトではそれが、ユーザディレクトリ配下(linuxなら
~/.cache/Cypress
に保存されるのですが、このままだとdocker image作成時のユーザと実行ユーザが異なると、バイナリが見つけられずエラーとなってしまうため、明示的にユーザ配下ではない場所を指定しています。 参考:バイナリのキャッシュについて※3 Cypressは、テストが失敗した際に、スクリーンショットや動画を保存しますので、フォルダに書き込み権限が必要です。これもdocker image作成時のユーザと実行時のユーザが異なるとエラーとなってしまうため、オーナーを変更しています
※4 ベースイメージであるcypress/base:14.16.0 では、既に
docker-entrypoint.sh
がエントリーポイントに指定されているので、再度ENTRYPOINTを定義する必要はないのですが、逆にdockerを起動したままにして、デバッグしたい場合は、ENTRYPOINTを書き換えます。
docker-entrypoint.sh
#!/bin/sh
echo 'start E2E testing';
# ※5
yarn $@ 2>&1 | tee /${E2E_DIR}/cypress.log
# ※6
RET=$(grep '^error Command failed with exit code' /${E2E_DIR}/cypress.log | grep -o '[0-9]*')
if [ $RET ]; then
echo "There are $RET of error specs. Check please.";
else
echo "All green!";
fi
exit 0
※5 テストをいくつかのケースに分けて実行したい場合もあると思うので、コマンドライン引数を受け取って実行するようにしています。 また、実際にテストに失敗した際に、何のテストに失敗したかをスクリーンショットや動画と一緒に確認できるようにしたいので、実行時のログを標準出力とファイルの両方に書き出しています。
※6 出力されたファイル内に特定の文字列が存在した場合に、その文字列内からエラーの数を抽出します。
ちなみに、cypress:runの終了ステータスは失敗したエラーの数に依存します。(つまり、全てのテストが成功したら、 exit 0
、テストケース3つが失敗した場合には exit 3
)
そのため、 docker-entrypoint.sh等を挟んで、コンテナ実行プロセスの終了コードと分離してあげないと、テストケースが一つでもNGな場合に、コンテナが異常終了したと判定されてしまいます。(自分はこれで結構ハマりました。テストケースが一つでもNGだと、コンテナがリトライ実行し続けたり。。。)
コンテナ内でE2Eテストを行った場合、実行終了と同時にコンテナも終了してしまうと、せっかくコンテナ内部に保存されているエラー時のスクリーンショットや動画にアクセスできなくなってしまいます。とはいえ、それらにアクセスするためにコンテナをずっと立ち上げておくのも最近のサーバレスな流れとは相反するので、弊社ではエラー時のエビデンス(ログ、画面キャプチャ、動画)をS3にアップロードしています。
これ以降はいろいろなやり方があると思いますが、弊社での実装を紹介させていただきます。 ※尚、cypressをSaaS利用(有料)するなら、この辺は実装しなくても勝手にやってくれるはずです。
必要なライブラリをインストールします。
yarn add ts-node @aws-sdk/client-s3
以下ファイルを作成します。
uploadEvidencesToS3.ts
import { readFileSync } from "fs";
import { S3Client, PutObjectCommand, S3ClientConfig } from "@aws-sdk/client-s3";
// create S3Client instance
const region = "xxxxxxxx"; // S3の存在するリージョンを指定
const bucketName = "xxxxxxxx"; // S3バケットネームを指定
const config: S3ClientConfig = {
region, // ※1
};
const S3ClientInstance = new S3Client(config);
// create params
const uploadFileName = process.argv[2];
const body = readFileSync(uploadFileName);
const uploadParams = {
Bucket: bucketName,
Key: uploadFileName,
Body: body,
};
// ※2
process.on("SIGINT", function () {
process.exit(0);
});
// upload files to S3
(async () => {
try {
await S3ClientInstance.send(new PutObjectCommand(uploadParams));
console.log(
"Successfully uploaded object: " +
uploadParams.Bucket +
"/" +
uploadParams.Key
);
process.exit(0); // ※3
} catch (err) {
console.log("Error", err);
process.exit(1);
}
})();
※1 開発環境のエラー内容なので、VPCエンドポイントを利用し、インターネットを経由せずにS3へファイルをアップロードしています。そのため、ここではリージョン指定のみですが、別途、VPCエンドポイントの設定等が必要となります。VPCエンドポイントについてはこちらの記事等を参考にさせていただきました。
※2 デバッグ時などに、ctrl+cで強制終了した際の挙動を定義しています
※3 S3へのアップロード終了後にずっとプロセスが終了してくれない状態に陥ったため、明示的にプロセスを終了しています
...
"scripts": {
...
"uploadEvidences": "ts-node ./uploadEvidencesToS3.ts" // 追加
}
...
...
COPY ./cypress /${E2E_DIR}/cypress/
# 以下追加
COPY ./uploadEvidencesToS3.ts /${E2E_DIR}/
...
...
if [ $RET ]; then
// 追加ここから
NOW=`date '+%Y%m%d%H%M%S'`
mkdir /${E2E_DIR}/${NOW}
cp /${E2E_DIR}/cypress.log /${E2E_DIR}/${NOW}/
cp -r /${E2E_DIR}/cypress/screenshots /${E2E_DIR}/${NOW}/
cp -r /${E2E_DIR}/cypress/videos /${E2E_DIR}/${NOW}/
tar czf ./${NOW}.tar.gz ./${NOW}
yarn uploadEvidences $NOW.tar.gz
// 追加ここまで
...
else
...
テストが失敗した際に通知が来ないと意味がないので、Slackへの通知を行います。
必要なライブラリをインストールします。
yarn add @slack/webhook
以下ファイルを作成します。
postToSlack.ts
const { IncomingWebhook } = require("@slack/webhook");
const WEBHOOK_URL: string =
"https://hooks.slack.com/xxxxxxxx"; // ※1
const message: string = `ちょ、 *${process.argv[2]}件のテストケースがコケてる* じゃない!:woman-facepalming:\n*このままデプロイなんてしたら許さない* んだからっ:hocho:`;
const webhook = new IncomingWebhook(WEBHOOK_URL);
(async () => {
await webhook.send({
fallback: message,
pretext: message,
fields: [
{
title: `対象ファイル: ${process.argv[3]}`,
value:
"<https://s3.console.aws.amazon.com/s3/buckets/xxxxxx|Show bucket>", // xxxxxxには、エビデンスのアップロード先S3バケットを指定 ※2
short: false,
},
],
});
})();
※1 Slackへの通知には、Incoming WebHooksを用いました。 Webhook URLの取得はSlackのWebhook URL取得手順を参考にさせていただきました。
※2
めんどくさかったセキュリティ担保のため、S3を直接観に行ってもらっています
...
"scripts": {
...
"postToSlack": "ts-node ./postToSlack.ts", // 追加
}
...
...
COPY ./uploadEvidencesToS3.ts /${E2E_DIR}/
# 以下追加
COPY ./postToSlack.ts /${E2E_DIR}/
...
...
if [ $RET ]; then
yarn uploadEvidences $NOW.tar.gz
// 追加ここから
yarn postToSlack $RET $NOW.tar.gz
// 追加ここまで
...
else
...
これでE2Eテストがコケた時に、猟奇的なCypress chanがメンションしてくれるようになります。
あとはCI/CDで、ECRにイメージを保存しつつ、Fargateやk8sでcron実行すれば良いのかなと思ってます。最近コンテナサポートされたLambdaでも良いかもしれませんね。
ちなみにVPCエンドポイントを利用しているので、ローカルからS3へのアップロードのテストは基本的にはできませんが、コンテナを常時立ち上げっぱなしにしておいて、telepresenceでコンテナをスワップすると、VPC内のアクセスとなるので、k8s上でS3へのアップロードのデバッグをする場合には、Kubernetesクラスターと同期する、マイクロサービスのためのローカル開発環境を参考にすると良いかもしれません。
もちろん、単体テストでも十分に心理的安全性を確保できます。ただ、jestによりコンポーネントの挙動やロジックの確認するには、そのコンポーネントが動作する前提となるstoreやstorageの準備とpluginの読み込みなど、実際のテストを書き始めるまでに考慮しなければならないことが多く、私を含めフロントエンドエンジニアは疲弊していました。そのため、単体テストのコードカバレッジはまだまだという感じです。 その点、CypressによるE2Eテストは、実際にサービスの画面を操作してテストするため、「どの画面でどのような操作をした時にどうなるか」を純粋に定義していくだけでいいので、単体テストよりも直感的にテストをかけて、しかも画面の動きを見ながらテストを修正していけるので、かける工数がかなり少ない印象です。 新機能開発、あるいは機能改善の際に、その機能に関するコンポーネントの詳細な動作を単体テストを書きながら担保しつつ、それ以外の機能の基本動作についてはE2Eテストで担保することで、単体テストがまだかけていない部分を補っていこうとしております。(厳密には担保できる範囲や粒度が違うので、どちらもカバレッジをあげていかなければいけないとは思います)
これはテスト全般に言えることですが、テストケース(カバレッジ)が増えていかないと、なかなかデグレ防止にはならないので、効果が出てくるには時間がかかると思います。 弊社でも単体テストを書き始めた当初は、なかなかテストを書くコストばかりに目が行きがちでしたが、最近大幅にリファクタする機会があり、単体テストでかなり救われた等の声も出てきています。なので、根気強く続けていくことが大事だと感じています。 また、運用していく中で、いろいろ気づきが出てくると思うので、また紹介していけたらと思います。
Cypressはテストを行いながら画面録画をし、テストが終わったタイミングでその動画の圧縮処理を行います。そのため、かなりCPUとメモリのリソースを必要とするようです。(実際にテスト中と動画圧縮でコンテナのCPUが100%になっているのを何度か確認しました) メモリが足りない場合にはOOMKilledされるので、すぐに気づくことができるのですが、CPUが足りない場合には動作に不備が生じた上でテストに失敗したと通知されるので、中々CPUリソース不足と言うことに気づくことができませんでした。 現状、弊社ではE2Eコンテナへのリソース割り当ては以下で運用しています。参考になればと思います。 CPU: request 1, limit 1.5 Memory: request 800Mi, limit 1.5Gi