結論
個人開発のiOSアプリで、テストを417件書いていました。Prettier、ESLint、tsc --noEmit、Jestまで全部緑でした。
その状態で実機に入れて触ったところ、3つのバグが出ました。
原因は単純で、417件が全部「純粋関数のテスト」だったからです。画面をまたぐ経路を、一度も動かしていませんでした。
何をテストしていたか
アプリの設計方針として、ロジックをUIから分離していました。
src/
├── domain/ # 日付計算、ステータス遷移、重複判定
├── services/ # 通知の選定、DBアクセス
├── screens/ # 画面
└── components/ # UIコンポーネント
domain/ と services/ の関数はすべて副作用のない純粋関数として書き、境界値テストを網羅していました。ここは実際よく守れていて、日付計算のバグは実機でも1件も出ませんでした。
問題は、テストがそこで止まっていたことです。
-
screens/のコンポーネントテストは1画面分しかなかった - 「入力 → DB保存 → 読み込み → 表示」という経路を通すテストがゼロだった
つまりドメインロジックは鉄壁で、その周りをつなぐ配線が無防備でした。
実機で出た3つのバグ
出たバグは、結果的にすべて同じ根を持っていました。
1. 設定を変えてもホームに反映されない
設定画面である機能をONにしても、ホーム画面にカードが出ませんでした。アプリを完全終了して起動し直すと出ます。
ホーム画面が設定値をマウント時に1回しか読んでいませんでした。タブを行き来しても、コンポーネントは再マウントされません。
2. 日付が変わっても「今日やること」が更新されない
today を useMemo(() => getTodayJst(), []) で保持していました。依存配列が空なので、マウント時に固定されます。
iOSはアプリを何日でもメモリに残すため、夜に開いて閉じ、翌朝に通知から開くと前日の内容が表示されます。通知を中心機能に据えたアプリで、これは致命的でした。
3. 月が変わると、どのチップも選択されなくなる
今月 / 来月 を切り替えるチップがあります。日付を翌月に飛ばすと、ラベルは新しい月に更新されるのに、選択中の月を保持する state が古いまま残り、どのチップもハイライトされない状態になりました。表示されるのも古い月のデータです。
共通していたこと
3つとも「マウント時に読んだきり更新されない」という同じ形をしています。
そして、この形は純粋関数のテストでは原理的に検出できません。
判定ロジックは正しいのです。設定値が true でステータスが該当すればカードを出す、という関数は完璧に動きます。テストも通ります。
壊れていたのは、その関数に渡す値が古かったという一点でした。値の鮮度は関数の外側の話なので、関数のテストでは見えません。
静的解析では原因を特定できなかった
バグ1を見つけたとき、まずコードを読んで原因を調べました。調査結果はこうでした。
- 保存される値と、それを参照する関数の型は一致している
- 判定の4条件は、報告された状況ではすべて
trueになるはず - 設定のキーは保存側と読み込み側で同一
- 経路の配線は正しい
そして「コード上は正しく動く想定になっている」という結論で止まりました。
そのうえで挙がった原因候補は、実際には無関係な別の箇所でした。フォームの初期値を非同期で上書きする処理にレース条件があり、それが犯人かもしれない、という見立てです。理屈としては成立しますが、症状の説明にはなっていませんでした。
結局、原因の特定に効いたのは60秒の手動確認でした。
- 一覧画面を開いて、保存された値が想定通りか目視する → 正しかった
- 設定画面を開いて、トグルの状態を確認する → OFFに戻っていた
トグルは保存ボタンを押さないと永続化されない作りでした。押していなかっただけです。そのあと保存して再起動したら、カードが出ました。
静的解析は「コードが正しいこと」しか言えません。 実際に何が保存され、何が読まれているかは、動かさないと分かりません。
見つかった別の問題
副産物として、テストが通ることの意味も揺らぎました。
同じプロジェクトで、祝日テーブルから1日分のデータが抜けているバグも見つかっています。そのときに追加した回帰テストは、祝日データを削除しても通ってしまうものでした。テスト名には守りたい条件を書いていたのに、その条件を通過しない入力でテストしていたためです。
「テストがある」と「テストが守れている」の間には距離があります。
どう直すか
3つのバグ自体は、更新契機を足すだけで直りました。
- 画面がフォーカスされたとき
- アプリがバックグラウンドから復帰したとき
この2つでtoday、レコード、設定値をまとめて読み直します。両方が同時に発火しうるので、useRefのフラグで多重読み込みを防ぎます。
そのうえで、今回足りなかったテストはこれです。
- 設定値が変わった状態で再フォーカスすると、表示が変わる
- today が変わった状態で再フォーカスすると、表示が更新される
- AppState が active になったときも再読み込みされる
- 同時発火しても多重読み込みされない
- アンマウント時にリスナーが解除される
- 再読み込みが失敗しても直前の表示が維持される
いずれも「関数が正しいか」ではなく「正しい値がいつ渡されるか」を見るテストです。
教訓
1. カバレッジの数字は経路の網羅を意味しない
417件という数字は、それだけ見れば十分に思えます。しかし全部が同じ層のテストだったので、層の間は1本も通っていませんでした。
「何件書いたか」ではなく「どの経路を通したか」で見る必要があります。
2. 純粋関数への分離は、境界を作ることでもある
ロジックをUIから切り離す設計自体は正しく、実際にドメイン層のバグは実機でも出ませんでした。
ただし分離は境界を生みます。境界の両側をテストしても、境界そのものはテストされません。3つのバグは全部その境界にありました。
3. 実機で触る工程は省略できない
3つとも、実機に入れて数分触ったら出ました。逆に言えば、実機で触らなければリリースまで気づけませんでした。
特にバグ2は「夜に開いて、翌朝に通知から開く」という導線でしか踏みません。これは実機で日付を操作しないと再現できない種類のものです。
4. まず動かす。読むのはそのあと
原因調査でコードを読み込みましたが、答えは画面を2つ開くだけで出ました。
静的解析が有効なのは「コードが仕様通りか」を確かめるときです。「実際に何が起きているか」を知りたいときは、動かすのが速いです。
まとめ
- テストが全部通っても、テストしていない層があれば実機で落ちる
- 純粋関数のテストは「関数が正しいか」しか見ない。「正しい値が渡っているか」は別の話
- 層への分離は境界を生む。境界そのものを通すテストが要る
- 原因調査は、まず動かして事実を1つ確定させてから読む
テストを書くこと自体は間違っていませんでした。書いた層が偏っていただけです。次に増やすべきは、ドメイン層の419件目ではなく、層をまたぐ1件目でした。