0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

テストが417件すべて通ったのに、実機で3つバグが出た

0
Posted at

結論

個人開発の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秒の手動確認でした。

  1. 一覧画面を開いて、保存された値が想定通りか目視する → 正しかった
  2. 設定画面を開いて、トグルの状態を確認する → OFFに戻っていた
    トグルは保存ボタンを押さないと永続化されない作りでした。押していなかっただけです。そのあと保存して再起動したら、カードが出ました。

静的解析は「コードが正しいこと」しか言えません。 実際に何が保存され、何が読まれているかは、動かさないと分かりません。


見つかった別の問題

副産物として、テストが通ることの意味も揺らぎました。

同じプロジェクトで、祝日テーブルから1日分のデータが抜けているバグも見つかっています。そのときに追加した回帰テストは、祝日データを削除しても通ってしまうものでした。テスト名には守りたい条件を書いていたのに、その条件を通過しない入力でテストしていたためです。

「テストがある」と「テストが守れている」の間には距離があります。


どう直すか

3つのバグ自体は、更新契機を足すだけで直りました。

  • 画面がフォーカスされたとき
  • アプリがバックグラウンドから復帰したとき
    この2つで today、レコード、設定値をまとめて読み直します。両方が同時に発火しうるので、useRef のフラグで多重読み込みを防ぎます。

そのうえで、今回足りなかったテストはこれです。

- 設定値が変わった状態で再フォーカスすると、表示が変わる
- today が変わった状態で再フォーカスすると、表示が更新される
- AppState が active になったときも再読み込みされる
- 同時発火しても多重読み込みされない
- アンマウント時にリスナーが解除される
- 再読み込みが失敗しても直前の表示が維持される

いずれも「関数が正しいか」ではなく「正しい値がいつ渡されるか」を見るテストです。


教訓

1. カバレッジの数字は経路の網羅を意味しない

417件という数字は、それだけ見れば十分に思えます。しかし全部が同じ層のテストだったので、層の間は1本も通っていませんでした。

「何件書いたか」ではなく「どの経路を通したか」で見る必要があります。

2. 純粋関数への分離は、境界を作ることでもある

ロジックをUIから切り離す設計自体は正しく、実際にドメイン層のバグは実機でも出ませんでした。

ただし分離は境界を生みます。境界の両側をテストしても、境界そのものはテストされません。3つのバグは全部その境界にありました。

3. 実機で触る工程は省略できない

3つとも、実機に入れて数分触ったら出ました。逆に言えば、実機で触らなければリリースまで気づけませんでした。

特にバグ2は「夜に開いて、翌朝に通知から開く」という導線でしか踏みません。これは実機で日付を操作しないと再現できない種類のものです。

4. まず動かす。読むのはそのあと

原因調査でコードを読み込みましたが、答えは画面を2つ開くだけで出ました。

静的解析が有効なのは「コードが仕様通りか」を確かめるときです。「実際に何が起きているか」を知りたいときは、動かすのが速いです。


まとめ

  • テストが全部通っても、テストしていない層があれば実機で落ちる
  • 純粋関数のテストは「関数が正しいか」しか見ない。「正しい値が渡っているか」は別の話
  • 層への分離は境界を生む。境界そのものを通すテストが要る
  • 原因調査は、まず動かして事実を1つ確定させてから読む
    テストを書くこと自体は間違っていませんでした。書いた層が偏っていただけです。次に増やすべきは、ドメイン層の419件目ではなく、層をまたぐ1件目でした。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?