気付けば後一週間くらいで年が明けます。今年も大掃除を忘れてしまったので、毎度ですが最後の日曜くらいに台所だけちょっと気合を入れる所存です。

前回の話を下敷にして、PlaywrightでAPIを上手いことモックするやつを(仕事で)作ったので、どういう感じなのかをつらつらと書いていきます。

E2EというかITというかを書く上で課題になると思うこと

前も書いた気はしますが、あらためてまとめてみます。ちなみにここではE2Eって書いてますが、実際に動かす時はIT相当(end 2 endではなく、APIの内容がモックされた状態)を前提としてます。

  • データの準備が超面倒
  • データを再現させるための手順が煩雑
  • URLにIDが含まれていたりする場合、全体の整合性を合わせないといけない
  • 複数のサーバーからAPIを呼び出しする場合、プロキシなどの設定を考慮しないといけない
  • ログインや認証が外部に依存している場合に、認証情報の扱い方を考えないといけない

結構ありますね…。E2E/ITだとデータの準備が大変、というのはもはや当たり前(?)の事実だとも思うんですが、これをどうしていこうか?というお話です。

今回の前提

ちょっとお仕事のプロダクトにおける構成の話にはなっちゃいますが、まぁこういう形だったのでこうしたよ、というところで。

  • ローカル開発環境で実装する
  • ログインがローカルにあるAPIサーバーと別サーバー経由で実施する
  • 一部CORSが含まれる
  • CIの実行先はGitHub Actions
    • お金の都合上、一番安いやつで動く、ってのが前提です

CIで利用するためのテスト実装なので、ローカルで開発できるのが絶対的な前提になりますね。まぁこれは大抵そうだと思いますが、今回の手法は、もし外部を利用するとしてもサポートできる(きっと)ようになってます。

ワークフローの紹介

さて、とりあえずどういう感じにしたのか?を書いてみようと思います。実際は色々名前が違いますが、まぁ察していただけば。

まず、以下のようなコマンド4つを作りました。名前から何をするかは大体自明かなと思います。

# S3からテストデータをダウンロードする
$ ./foo-cli pull

# playwrightをレコーディングモードで起動する
$ ./foo-cli record --user hogehoge

# レコーディングしたテストを再生する
$ ./foo-cli run

# 記録したデータをS3にアップロードする
$ ./foo-cli push

テストは次のような感じになります。 record を実行中に、このテストを実行すると、 自動的にAPIなどの情報が保存されます 。全く同じソースのままで、レコーディングモードを停止してから実行すると、保存された内容ができるだけ再生されるようになります。

import {test,expect} from '../extended-test'

test("トップページにアクセスして、テキストを確認する", async ({page, loginIfNeeded}) => {
  await loginIfNeeded("/index");

  await expect(page.getByText("Hello?")).toBeVisible();
});

シンプルなワークフローではありますね。

他の仕組みとの比較

ワークフローの紹介を見て、 いやでもこれ他のツールとか標準でできるんじゃない? と思われるかと思います。確かにそれはそうで、このワークフローでもplaywrightの標準機能を普通に使っています。しかし、他のワークフローを考えたときに、色々と課題になることが多かったため、かなり独自実装を加えています。

では、他に取り得た仕組みとの比較をしてみましょう。

さて、ここまで方法論を色々書いてきたんですが、前々から言われているモックそのものの課題はやはり残るなぁ、というのが所感です。どういう課題かというと、 モックが古くなった や、 モックが仕様通りではない 場合に、どのように対応すべきか?というものです。

大体どっちも同じ話なのですが、簡単に言えば外部APIの仕様(取得できる値の種類とかプロパティが増えたとか)が変わった場合、どのようにmockと差異があるのか?というのを気付きたい、というものですね。恐らく画一的な方法は無いので、recordingをサクっとできるようにしておく、というのがいいとは思います。

が、recordingをいつでもできるようにした場合、そのシナリオで使っていたデータが変更されていて、再度調整…みたいなのがあります。これは単純に追加の対応コストになり、チームできちんと合意が取れていないと、単純に工数増になってしまう…ということになりがちですね。外部APIを使う場合はなおさらなので。そういったものは、どれだけツールを整備したとて残ってしまう問題であり、本質的な解決はなかなか難しいです。

今回はPlaywrightから呼ぶAPIをどうモッキングするか?というところに終始しましたが、モックは適用されるレイヤーも多く、それに対応して利点と対立する課題も多く、考察が絶えないです。個人的には速度と安定のバランスをどうとっていくか?をもうちょっと考察していきたいところです。大体はケースバイケースになっちゃうんですけど。

標準のrouteのみ利用

新ワークフローでも内部的に page.route を利用していますが、これだけだと煩雑になりすぎますね。まぁ上手いこと色々ラップしていると思ってもらえれば。実際、routeをtestの内部に露出させると、 データが共有されてしまって大変困る という自体にまれによくなります。というかなってました。

routeWithHAR

自分の趣味ではこれで十分だったので使ってはいましたが、こちらについてもそのままだと課題が多かったため、回避しました。

  • HARとして保存される内容が多すぎる
    • Cookieとかまで保存されてしまう
  • HARにおけるrequest/responseのマッチングが厳密すぎる
    • cookieとかの値が完全一致していないと拾ってくれないので、recordingしたあと自前でなんとかする必要があります
  • record/replayでソースを書き換える必要がある
    • 忘れるとアレ?ってなりますし、そのままcommitしてしまうと、複数人開発だと悪夢になりえます

特にreplay時の比較が厳密すぎるのと、元々archive用途(HAR == HTTP Archive)で利用されている形式のため、本質的に情報過多なのは変わりません。

Wiremock

https://wiremock.org/

以前検討しましたが、こちらも今回利用しないことに決定しました。理由としては、

  • mappingするためにローカルでwiremockも起動し、proxyとして構成しなければならない
    • すでにローカルでは別のproxyが二段構えになっており、さらなる複雑化は回避したかった
  • 同じURLに対するmappingが別物として扱われる
    • wiremockのrecordingでは、各リクエストがそれぞれUUIDで別々に作成されます
    • これの厄介なポイントとして、 すべてが同一でも別のUUIDになる ため、最後出力された後に これどうしよう… って途方に暮れるケースがよくありました
  • graphqlの再生時に課題がある
    • https://wiremock.org/docs/solutions/graphql/
    • 一応extensionを入れたらなんとかなるのですが、それでも正直結構厳しいです
    • あくまでstandaloneモードで利用する前提なので、これをやるのは大分しんどいと判断しました

大まかな仕組み

さて、ワークフローの内部でどういう感じにしているのか?については、多分想像は付きやすい構造だとは思います。route使ってやる、となったらやる方式はそこまで多くありませんので…。

でも自分の整理のために、どういうものをどういう形でどういう風にして保存しているのか?を書いてみます。

レコーディング時

前提としては、 page.routeを利用してます。routeには fetchという超便利メソッドがあり、これを利用してます。

  1. routeを定義する
  2. routeの中でレコーディングモード中かどうかを確認する
  3. レコーディングモードだった場合、まず route.fetch を呼び出す
  4. 3.の結果をメモリ上に記録する
  5. 3.の結果をそのまま route.fulfill で流す
  6. テストケース全体が終わったら、メモリ上にあるものをmappingとして書き出す

書いてみるとまぁそりゃそうだね、ってところになりますね。いくつか工夫しているところとしては、 異なるテストケース間では、同じAPIであっても異なるものとして管理する ということをしている点です。これは権限などをAPIで取得している中で、同じ権限であっても別々のテストデータとして扱うことで、テストデータの共通化という誘惑を断ち切ることを狙っています。

なお、本当の意味でのE2Eであっても、テスト毎にclean upなどをするなどして、冪等であるべきだ、みたいな話があります。完全な冪等は大変難しいのですが。

リプレイ時

リプレイも同様にpage.routeを利用してます。

  1. routeを定義する
  2. routeの中でレコーディングモード中かどうかを確認する
  3. レコーディングモードではなかったら、記録済のmappingから記録済のものがあるかどうかを調べる
  4. 3.で記録してあるmappingがあったら、それをそのまま route.fulfill する。なかった場合は route.continue する

リプレイの場合はやることが少ないです。ここでの肝は3.にある記録済みのものを探索する、という行為ですね。wiremockではここにmatcherというのを定義したりするのですが、今回API呼び出しの回数がとても多い画面が大半であり、手動で作るのは非現実的と判断してます。そのため、リプレイのマッチはとてもシンプルに テストの情報 + URL + 回数 でやるようにしてます。

結局一個の処理の中で多いとは言っても、単一テストでしか使わないデータであれば、query毎に異なるマッチ…というのは過剰だと思います。また、レコーディングモードを用意したことで、そもそも一対一にマッピングすることが可能になるのと、ローカルでテスト用に動かしているものをそのまま利用できることもあり、メンテナンスコストを最小にする方を優先しています。

fixtureの管理とかは?

mappingファイルの中身はまぁ簡単なので書きませんが、この仕組みだと、ファイル数がアホみたいに増えます。実際お仕事でこの仕組みに変更して取り直したのですが、1000ファイルくらいのJSONがすぐできちゃいました。S3にアップロード/ダウンロードするという前提にある以上、 ファイル数の多さは即スローダウンに繋がります

そうなの?と思う方は、頑張って10000ファイルくらいをputしてsyncしてみるとよきかと思います

また、レスポンスをそのまま保存しているため、場合によっては容量も増えます。容量が増えるとダウンロードに時間がかかりだします。ということで、S3へのアップロード/ダウンロードは、tar + zstd(GitHub Actionsのcacheでも使われてますね)でやりとりするようにしています。こうすることで、大体1/100とかになったりします。これは、99%くらい同じAPIの結果も相当数存在するため、強烈に符号化が効きやすいためかと思います。

実際のCIは?

ここらへんまだ作ってますんで…。

ワークフローの感想

ワークフローを切り替えて、ITを全部再実行したり修正したりしましたが、このワークフローにすることで色々メリットがありました。

  • 普段利用しているserveとかのままでITを書ける
    • 専用のprojectだったり、一回ビルドしてから…みたいなのを極力意識しないようにキーを作成するようにしました
  • 一回レコーディングが終わってしまえば、後は高速にITの実装ができる
    • OS的にはファイルキャッシュとかに入るので、何回も同じファイルを読みにいっても一桁ms単位でレスポンスされてきます
  • シナリオが繋がっている場合、連続して実行することで、データの初期化〜編集〜削除、みたいなことが一連のrecordingで可能になる
    • 何回でもrecordingできるようにしているので、実際の画面でちょちょいと修正してから流しなおす、みたいなことも簡単です
    • playwrightのUIモードをフルに利用する前提なので、特定テストだけ起動するのもお手軽です

もちろん闇の部分もそれなりにあるのですが、VRTなども踏まえたデータマネジメントなども一応できたかな、とは思います。VRTなどを踏まえても、画面で調整した見た目だったり外れ値の値だったりをテストした後、そのままVRTのデータとして使う、みたいなこともできるようになってます。

仕組みを作るか持ってくるか

今回、既存の仕組みをほぼ利用せず、似たようなものを作った話をしました。これは車輪の再発明だーとか言われるものだと思いますが、個人的には再発明ではないからいいじゃん、と思ってます。

車輪は概念としての発明であって、じゃあ過去にあった車輪(ペルシアだったかに騎馬戦車とかありましたね)ってそのまま使えるん?という話だと思っていて、概念は色んなところで使えるので再発明する必要はないですが、 今に適合するものが無かった場合には実装する というのも選択肢だと思います。バチコンと合うものがあって、それがキチンとメンテナンスされているんであれば、普通にそれを利用したらいいと思いますが、OSSの世界になってくると、合うものがあってもメンテナンスに懸念があって…、とかはよくあります。

そんな場合は、複雑さや分量にもよりますが、自分達で必要な分だけ作成してしまう、というのも手段として持っておけるのもまた力かなーと思ってます。意見は色々ありますけども。