TL;DR — ノーコード E2E ツール(mabl)で運用していた約 300 ステップのリグレッションテストを、コードベースの Playwright に移行した。単なる置き換えではなく、「待ち方」「失敗の扱い方」「一時しのぎの管理の仕方」 を作り直す作業だった。固定 wait を機械的に転記しない、データ欠如はスキップせず必ず失敗させる、環境のせいにする前に実装を疑う、そして避けられない workaround は1 箇所に撤去条件付きで集約する。この 4 つが、移行後も腐らないテストスイートの背骨になった。
はじめに:なぜノーコードから「コード」へ戻したのか
ノーコードの E2E ツールは立ち上がりが速い。画面を操作すれば記録され、誰でもシナリオを増やせる。実際それで数百ステップのリグレッションが回っていた。
だが規模が育つと、別の問題が前に出てくる。
- 待ち時間が「N 秒待つ」の積み重ねになりがちで、実行が遅く・不安定になる
- シナリオが GUI の中にあり、レビューや差分管理(Git)が効かない
- フレーク(たまに落ちる)が出たとき、なぜ落ちたかを追う手段がツールの外に持ち出しにくい
- アプリ側のリファクタ(後述する Router 移行など)と歩調を合わせて直すのが難しい
そこで、シナリオを資産として Git に乗せ、待機・リトライ・調査を全部コードで握れる Playwright へ移すことにした。この記事は、その移行で得た判断と落とし穴の記録だ。
移行の全体像:ドキュメントを「役割」で分ける
最初に決めたのは、テストコードそのものよりドキュメントの置き場所だった。移行は一度で終わらず、長く付き合うことになる。だから「どう書くべきか」「何を一時しのぎでやっているか」「どう実行するか」を混ぜずに分けた。
| ドキュメント | 役割 |
|---|---|
| ガイドライン | 「どう書くべきか」のルールブック(待機・セレクタ・命名) |
| シナリオ集 | 各テストの自然言語シナリオ(AI にコード化させる入力にもなる) |
| 一時的対応 一覧 | フレーク回避・互換ハックなど 本来のロジックでない workaround を撤去条件付きで集約 |
| CI 設計 | ステージング環境に対する自動実行の構成 |
| README | 実行コマンドと、上記への導線だけ |
特に効いたのが 3 つめの 「一時的対応 一覧」 だ。これは後述する。
設計判断その1:固定 wait を「そのまま」転記しない
ノーコードツールのシナリオには Wait for N seconds が大量にあった。これを waitForTimeout(N * 1000) に機械変換するのは最悪手だ。固定待機は遅いうえに不安定(環境次第で足りたり足りなかったりする)。
代わりに、何を待っているのかで置換ルールを表にした。
| 元の固定 wait | 置き換え |
|---|---|
| 保存ボタン直後の待機 | リダイレクトを waitForURL で待つ/成功トーストを長めの timeout で待つ |
| 検索・フィルタ変更後の待機 | waitForLoadState('networkidle') または結果行の表示を待つ |
| リロード直後の待機 | waitForLoadState('networkidle') |
| ドロップダウン選択直後の待機 | 選択で出現する後続要素の表示を待つ |
原則は「時間ではなく状態を待つ」。固定時間待機は原則禁止にした。
セレクタも優先順位を決めた。ロールベース(getByRole)を最優先、次にラベル・プレースホルダ、最後の手段が data-testid や CSS。ロールはアクセシビリティと連動するので、UI の小変更に強い。
設計判断その2:データが無いとき、スキップしてはいけない
リグレッションは前のフェーズで作ったデータを後のフェーズで検証する直列構成になっている。たとえば「ユーザーを作成」→「一覧で探して権限を確認」のように。
ここで陥りがちな罠が、こういう防御的コードだ。
// ❌ これをやってはいけない
const row = await findRow(name)
if (!row) return // データが無いのでスキップ
データが無いということは、前段の作成が壊れている可能性が高い。それをスキップで握り潰すと、リグレッションテスト本来の目的=回帰の検知が静かに死ぬ。「全部グリーンなのに、実は何も検証していなかった」が一番怖い。
だからルールはシンプルにした。データが無い前提のフローでデータが無かったら、必ず失敗させる。try/catch で return するスキップは禁止。assertion でそのまま落とし、原因を追う。
設計判断その3:避けられない workaround は「撤去条件」付きで一元管理する
実環境のテストには、どうしても本来のロジックではない一時しのぎが混ざる。ステージング環境のフレーク回避、UI 構造のクセへの対応、外部要因のラグ吸収——。
これらをコード中の // TODO コメントだけで散らすと、後で「なぜこうなっているのか」「いつ外せるのか」が誰にも分からなくなる。実際それで困った。
そこで専用ドキュメント「一時的対応 一覧」を作り、すべての workaround をアルファベット ID で索引化した。各項目は必ず次の粒度で書く。
- 概要(何をやっているか)
- 発生原因(なぜ必要か)
- 現在の対応(どこで・どう実装しているか)
- 影響範囲(ファイル・関数)
- 撤去条件(どうなったら外せるか)
- 撤去手順(外すとき何をするか)
ポイントは 「撤去条件」を判定可能な形で書くこと。「ステージングが安定したら」ではなく、「リトライ無しで全フェーズが連続 5 回完走したら」のように、満たされたか判定できる条件にする。これで workaround が「永久に居座る借金」になるのを防ぐ。
新しい workaround を足すときは、必ずこのドキュメントに 1 項目追加することをルール化した。コードコメントだけで済ませない。
workaround は大きく 3 系統に分かれた
実際に溜まった一時しのぎを分類すると、原因がきれいに 3 つに割れた。これ自体が、移行で「何と戦っているか」を語っている。
| 系統 | 例 | 対処の方向 |
|---|---|---|
| ステージング環境のフレーク | 保存がサイレント失敗する/ID 採番がたまに重複する/作成直後の一覧がキャッシュで古い | 1 回リトライ・リロードで再フェッチ等でテスト側が吸収(恒久対処はバックエンド側) |
| UI フレームワーク移行に伴う構造差 | 旧実装と新実装で見出し文言・要素の role・DOM 構造が違う | テスト側を新実装に追従(ソースは触らない) |
| 非同期処理の表示ラグ | 通知トーストが数秒で自動的に消える/ジョブ完了の反映が遅れる | 成功と失敗を同時に監視・ポーリングで完了を待つ |
ハマりどころ:移行と「並行して走るリファクタ」が一番きつい
移行作業の最中、アプリ側でも UI フレームワークの移行(旧来のルーティング/UI ライブラリから新しいものへ)が進行していた。これが想像以上に厄介だった。
あるリファクタの PR がマージされた後、リグレッションが多数のフェーズで一斉に壊れた。当初は「rename 中心だからテストには透過的なはず」と高をくくっていたが、完全に外した。
核心はこうだ。テストが叩いていた画面は、リファクタ前は旧 UI ライブラリの実装が担っていた。リファクタで同じ URL の中身が新 UI ライブラリの実装に切り替わり、テストは初めて新実装を叩くことになった。結果、
- 見出しや文言の微差(「○○一覧」→「○○ の一覧」、トーストの空白有無まで)
- 要素の role が変わる(タブが
role=tabからrole=buttonへ、リンクがボタンへ) <label>が input と紐付かなくなりgetByLabelが効かない- テーブルの列構成が変わる(列の増減でセルの index がずれる)
——が一気に表面化した。
ここで効いたのが、memory に刻んでいた鉄則だ。「リファクタ進行中に E2E が落ちたら、まず移行起因を疑う」。失敗したフェーズが踏む URL を特定し、その画面が新実装に移っているか旧実装のままかをソースで裏取りしてから、移行起因か否かを根拠付きで切り分ける。推測で除外しない。
「フル実行でだけ壊れる」という最悪の罠
一番手こずったのは、フル実行(同一ブラウザで 270 以上のフェーズを連続実行)でのみ再現する不具合だった。あるドロップダウンが、フル実行の終盤でだけ「クリックしてもメニューが開かない」。
切り分けに使ったのは孤立再現だ。怪しい部分だけを切り出した一時的なテストを作り、本番ビルド + ブラウザ表示ありで回す。フル実行は 1 回 40 分かかるが、孤立させれば 3 分で再現/検証できる。
結果、その操作は手動でも・孤立テストでも必ず成功した。つまりアプリのバグでも・開発/本番ビルドの差でもなく、「長時間同一ページで動かし続けた末の累積状態」でしか出ない、と切り分けられた。対処は割り切って page.reload() で状態をリセット——だが真因不明であることを workaround ドキュメントに正直に書いた。「なぜか動く」を放置しないために。
進め方の鉄則:環境のせいにする前に、実装を疑う
これは技術というより姿勢の話だが、移行を通じて一番染みた教訓だ。
E2E が落ちたとき、ログに「サーバーの一時エラー」「ホットリロード中」みたいな文字列があると、ついそれを根本原因だと即断したくなる。だが実際にそれをやって、真因は別(新しく追加したヘルパーに既知の互換ハックを当て忘れていただけ)だったことがある。
そこで調査手順を固定した。
- 失敗箇所のヘルパー実装・呼び出し元・DOM スナップショットをまず読む
- 「クリックは届いたが URL が変わっていない」等の構造的な兆候を先に探す
- 既知の workaround 一覧と照合し、類似パターンがないか確認する
- 環境要因(サーバーエラー等)は、他でも独立に再現する確証が揃ってから言及する
- 「新しく追加した箇所で落ちている」こと自体が、新規コード起因のシグナル
「なんでも環境のせい」は、調査の放棄だ。
実行まわりの地味だが重要な学び
最後に、ローカル/CI で安定して回すための非自明な勘所をいくつか。
- ローカル E2E は開発サーバーではなく本番ビルドで回す。 開発サーバーのオンデマンドコンパイルは、長時間(40 分超)の連続実行でチャンク読み込み失敗やメモリ起因の再起動を起こす。本番ビルドなら全チャンクが事前生成済みで安定する。
- 直列スイートのローカル実行にはリトライを付ける。 直列構成だと、1 フェーズが一過性のフレークで落ちただけで以降が全部 “did not run” になり完走できない。CI はリトライで吸収するので、ローカルだけ早期に死んで見える。
- 実行前に残存サーバーを kill する。 前回の失敗が残したサーバーを古いビルドのまま再利用すると、環境変数が焼かれておらずログインから死ぬ。
- テスト対象の向き先は常にステージング/ローカルに固定。 「本番ビルド」はあくまでビルド手法であって、本番環境を叩くことではない。ここは絶対に混同しない。
これは何「ではない」か(限界)
- mabl が悪いという話ではない。 立ち上げの速さ・非エンジニアでも書ける点は本物の強みだ。コード化が効くのは、シナリオが大規模になり・アプリと密に歩調を合わせる段階に入ってから。
- workaround をゼロにできたわけではない。 外部環境のフレークは残る。やったのは消すことではなく、撤去条件付きで可視化して管理下に置くこと。
- 移行は一度では終わらない。 アプリ側のリファクタが続く限り、テストは追従し続ける。「壊れる前提」で運用に組み込むのが現実的。
まとめ
- 時間ではなく状態を待つ。 固定 wait の機械転記をやめ、「何を待っているか」で置き換える。
- データ欠如はスキップせず失敗させる。 直列リグレッションで握り潰すと、回帰検知が静かに死ぬ。
- 避けられない workaround は 1 箇所に、撤去条件付きで集約する。 コメントに散らさない。借金を可視化する。
- 環境のせいにする前に実装を疑う。 調査手順を固定し、構造的兆候と既知パターンを先に当たる。
ツールを乗り換えること自体は本質ではなかった。「待ち方・失敗の扱い方・一時しのぎの管理」という運用の型を作り直せたことが、移行で得た一番の資産だった——というのが、300 フェーズを移してみての実感だ。
