ハコヅミを作る #6:E2Eテストが不安定になった4つの原因と直し方
前回(#5)のコンテナの機能を作っている途中で、E2Eテストが通ったり落ちたりするようになりました。 落ちるテストが実行のたびに変わる、いわゆる「不安定なテスト」です。
原因は1つではなく、4つありました。今回は、その見つけ方と直し方の記録です。
前提:E2Eテストの作り
ハコヅミのE2Eテストは、Playwrightで次のように動かしています。
- CIの中(と手元)でアプリをビルドし、
wrangler devで起動する - アプリは、ローカルのD1(SQLiteのファイル)を使う
- 画面から確かめられないこと(他人のデータが見えない、など)は、テストから
wrangler d1 execute --localでローカルのD1を直接読み書きして確かめる
テストが20件を超えたあたりから、落ち始めました。
原因1:ハイドレーションが終わる前に入力すると、値が消える
最初に見つかったのは、コンテナの登録で「名前を入力してください」と出て落ちるものでした。テストは、ちゃんと名前を入力しています。
失敗したときの画面を見ると、入力欄が空になっていました。 原因はハイドレーションです。SvelteKitのページは、サーバーで作ったHTMLがまず表示され、そのあとJavaScriptが画面を引き継ぎます(ハイドレーション)。
- テストが、表示されたHTMLの入力欄に名前を入力する
- そのあとハイドレーションが走り、入力欄が初期値(空)で上書きされる
- 空のまま送信され、「名前を入力してください」になる
テストが増えて並行で動く数が多くなり、ハイドレーションが遅れたことで表に出たようです。
直し方:ハイドレーションが終わった印を待つ
アプリの一番外側のレイアウトで、ハイドレーションが終わったら <html> に印を付けるようにしました。
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
// ハイドレーションが終わった印。E2E はこれを待ってから操作する
onMount(() => {
document.documentElement.dataset.hydrated = 'true';
});
</script>
E2Eテストでは、ページを開いたらこの印を待ってから操作します。
// tests/e2e/page.ts
export async function open(page: Page, url: string) {
const response = await page.goto(url);
await page.locator('html[data-hydrated="true"]').waitFor({ state: 'attached' });
return response;
}
ちなみに、これはテストだけの問題ではありません。通信が遅い環境では、実際の利用者でも「画面が出てすぐ入力した内容が消える」ことが起こりえます。今は自分しか使っていないので対応していませんが、覚えておくべき点です。
原因2:送信が終わる前に次のページへ移り、送信が取り消される
次に落ちたのは、コンテナを登録したあと、一覧から編集画面を開くテストでした。一覧に、登録したはずのコンテナがありません。
テストの補助関数が「保存」を押したあと、送信が終わるのを待たずに次のページへ移っていました。ブラウザは、ページを移るときに実行中の送信を取り消します。そのため、コンテナが登録されないことがありました。
保存を押したら、その送信の応答が返るまで待つようにしました。
await Promise.all([
page.waitForResponse((response) => response.request().method() === 'POST'),
page.getByRole('button', { name: '保存' }).click()
]);
ほかのテストは、保存のあとに「一覧に移ったこと」などを確かめてから次の操作をしていたので、問題ありませんでした。この補助関数だけ、確かめる前に次へ進んでいたのです。
原因3:SQLITE_BUSY(アプリ側のDBのロック)
ここが一番分かりにくい原因でした。
最初は、テストからD1を直接読む処理(wrangler d1 execute --local)が、たまに失敗していました。並行で動くテスト同士が同じDBファイルを使っているので、ロックにぶつかっているのだろうと考えました。そこで、失敗したら少し待って読み直すようにしました。
手元ではこれで安定したのですが、CIでまた落ちました。ログを見ると、今度はアプリのサーバー側でエラーが出ていました。
database is locked: SQLITE_BUSY
Error: Failed query: select distinct "maker" from "gears" ...
起きていたのは、次のようなことです。
- あるテストが、
wrangler d1 execute --localでDBを直接読み書きする(別のプロセスが同じDBファイルを開く) - その間に、並行で動く別のテストが画面を開く
- アプリがDBを読もうとして、ロックにぶつかって失敗する。画面が出ずに、テストが時間切れになる
テスト側の読み直しは、アプリ側のエラーには効きません。直す場所を間違えていました。
直し方:E2Eを1つずつ順番に動かす
テストがDBを直接触っている間に、ほかのテストが画面を開かなければ、ぶつかりません。Playwrightの並行数を1にしました。
// playwright.config.ts
export default defineConfig({
// 1つずつ順番に動かす。テストが DB を直接読み書きしている間に別のテストが画面を開くと、
// アプリの DB 読み取りが SQLITE_BUSY(ロック)で失敗して時間切れになることがあった
workers: 1
// ...
});
代わりに、E2Eの時間は手元で約20秒から約33秒に延びました。今のテストの数なら許容できます。
この先テストが増えて遅さが気になったら、テストからDBを触る方法(毎回 wrangler を起動せず、SQLiteのファイルを直接開くなど)を見直すつもりです。
原因4:マシンの負荷で時間切れになる
原因3を直す前の段階では、ページを開くこと自体が30秒で時間切れになることもありました。
テストがDBを直接読むたびに wrangler のプロセスを起動していて、それが並行数の分だけ重なり、マシンの負荷が上がっていたためです。
これは、並行数を減らしたことで一緒に解消しました。
「たまたま通った」を見分ける
不安定なテストを直すときに気をつけたのは、1回通っただけで直ったと思わないことです。
直すたびに、E2Eを4〜6回続けて流しました。実際、「5回通って6回目で落ちる」ということがありました。原因3と4は、何回か続けて流してようやく見えたものです。
この教訓は、/implement のスキルにも「E2Eが通ったり落ちたりするときは、何回か続けて流して確かめる」と書き足しました。
ついでに:Markdownだけの変更では、CIを動かさない
不安定さを直している間に、もう1つ気になっていたことがありました。
スキル(.claude/ の下)や CLAUDE.md だけを変えたPRでも、毎回テストとデプロイが全部動いていたことです。アプリの動きは変わらないので、テストを流しても何も確かめられません。
GitHub Actionsの paths-ignore で、アプリに関係しないファイルだけの変更では動かないようにしました。
on:
pull_request:
branches: [develop, main]
paths-ignore:
- '.claude/**'
- '**/*.md'
- '.github/ISSUE_TEMPLATE/**'
変わったファイルがすべてこの一覧に当てはまるときだけ動きません。アプリのファイルが1つでも混ざれば、今までどおり動きます。 ワークフローの設定ファイル自体は、あえて一覧に入れていません。CIの設定を変えたときこそ、動かして確かめたいからです。
デプロイのワークフローにも、同じ一覧を付けています。
なお、main のブランチ保護を使って「テストが通らないとマージできない」ようにしている場合は、動かなかったPRの扱いに注意が必要です。ハコヅミは今、無料プランの非公開リポジトリでブランチ保護が使えないため(#2)、この点は問題になっていません。
おわりに
4つの原因を並べると、次のとおりです。
| 原因 | 直し方 |
|---|---|
| ハイドレーション前の入力が消える | ハイドレーションの印を待ってから操作する |
| 送信の途中で次のページへ移る | 送信の応答を待つ |
| アプリ側の SQLITE_BUSY | E2Eを1つずつ動かす |
| マシンの負荷で時間切れ | (同上) |
どれも、テストが少ないうちは表に出なかったものです。テストを増やしていくと、こうした「タイミング」の問題が出てくることを実感しました。
これで、MVPのうち「ギア」と「コンテナ」がそろいました。次は、Notionからのデータの取り込みか、プランの機能に進む予定です。