Dev Study
← 解説「原因の切り分け — 範囲を半分にしていく型」に戻る

サンプルコードで身につける: 原因の切り分け — 範囲を半分にしていく型

解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。

1二分探索 — テンプレートを半分ずつ消す

ページを開くと落ちるがどの行かは不明、という場面ではテンプレートの半分をコメントアウトして試します。症状の有無で原因の側が分かるので、数回の繰り返しで1行まで絞り込めます。

<!-- バグ: このページを開くと ERROR TypeError で真っ白になる -->

<!-- 試行1: 後半をコメントアウト → エラー消えた → 原因は後半 -->
<section>
  <h2>{{ user().name }}</h2>
  <p>{{ user().email }}</p>
</section>
<!--
<section>
  <h2>注文履歴</h2>
  @for (o of orders(); track o.id) {
    <p>{{ o.items[0].name }} ほか</p>
  }
</section>
-->

<!-- 試行2: 後半の中をさらに半分に… を繰り返して1行に絞る -->
<!-- 犯人: o.items[0].name → items が空の注文があると落ちる -->

<!-- 直し方: 空配列に備える -->
<!-- @if (o.items.length > 0) { <p>{{ o.items[0].name }} ほか</p> } -->

2git diff — 動いていた状態との差分を見る

「さっきまで動いていた」バグの原因は、最後のコミット以降の変更の中にほぼ必ずあります。闇雲にコードを読むのではなく、まず差分の一覧に容疑者を限定するのが近道です。

# バグ: 昼までは動いていたのに、夕方になったら一覧が壊れた

# 1. 最後のコミットからの変更点を一覧する(容疑者リスト)
git diff

# 出力例(抜粋):
# --- a/src/app/user-list/user-list.ts
# +++ b/src/app/user-list/user-list.ts
# -  users = computed(() => this.all().filter((u) => u.active));
# +  users = computed(() => this.all().filter((u) => u.isActive));
#                                                    ^^^^^^^^
# リネームしたつもりが、データ側のプロパティ名は active のまま

# 2. どのファイルを触ったかだけ知りたいとき
git diff --stat

# 3. 「本当にこの変更が原因か」を確かめたいとき:
#    変更を一時退避して動いていた状態に戻す
git stash        # → 動いた! やはり退避した変更の中に原因がある
git stash pop    # 変更を戻して直しにかかる

3最小再現 — バグだけを小部屋に移す

本物の画面は要素が多すぎて原因が埋もれます。バグに関係しそうな部分だけを小さなコンポーネントに移して再現できれば、原因はその数行の中にあると確定できます。再現しなければ消した要素の中です。

// 本物: 500行のダッシュボードで「選択が勝手に解除される」バグ
// → 通信・ルーティング・他の部品を全部捨てて、疑いの核だけ残す

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-repro',
  template:
    '<button (click)="reload()">再読込</button>' +
    '@for (t of tags(); track $index) {' +
    '  <label><input type="checkbox" /> {{ t }}</label>' +
    '}',
})
export class Repro {
  tags = signal(['red', 'blue']);

  reload() {
    // 数行でもバグが再現した! → 原因はこの範囲内と確定
    // 犯人: track $index + 配列の作り直しで DOM が作り直され、
    //       チェック状態(DOMだけが持つ状態)が失われる
    this.tags.set(['red', 'blue']);
  }
}

// 直し方: track t のように値そのものを追跡キーにして
// 同じ項目のDOMを使い回させる(チェック状態が保たれる)

4データ→ロジック→表示の順に疑う

壊れる場所は大きく3層に分かれます。上流のデータから順に値を確認し、正しいと確かめられた層を容疑者から外していきます。この例ではロジック層のフィルタ条件に原因が見つかります。

// バグ: 「在庫あり」フィルタを付けると商品が1件も出ない

// [1] データ層: Network タブで /api/items の Response を見る
//     [{ "id": 1, "name": "ペン", "stock": 12 }, ...]
//     → データは正しい。データ層は容疑者から外す

// [2] ロジック層: ブレークポイントで加工後の値を見る
inStockItems = computed(() =>
  this.items().filter((item) => item.stock > 100),
  //                                        ^^^
  // 犯人発見: 「在庫あり(> 0)」のつもりが 100 と書いていた
  // → 停止中に this.inStockItems() を評価すると [] で確定
);

// [3] 表示層: 今回はここまで来る前に原因が見つかった
//     (もしロジックまで正しければ @for や @if を疑う)

// 直し方: item.stock > 0 に修正
//
// この「上流から順に確認して容疑者を外す」型を持っていると、
// どんな不具合でも調べる場所に迷わなくなる

5一度に一箇所 — 同時に変えると迷宮入りする

焦って複数箇所を同時に直すと、直っても壊れてもどの変更が効いたのか分からなくなります。仮説を1つ立てて1箇所だけ変えて確かめる、を繰り返すのが結局いちばん速い進め方です。

// バグ: 合計金額が表示されない

// ── 悪い進め方(同時に3箇所変更)──
// ・URLを '/api/cart' から '/api/carts' に変えた
// ・computed の計算式も書き換えた
// ・テンプレートの @if の条件も変えた
// → 直った! …が、どれが効いたのか分からない
// → 不要な2つの変更がコードに残り、次のバグの種になる

// ── 良い進め方(仮説→1箇所→確認)──
// 仮説1: URLが違う?
//   → Network タブを見る → 200 で返っている → 仮説棄却。URLは戻す
// 仮説2: 計算式が違う?
//   → ブレークポイントで total() を見る → 正しい値 → 仮説棄却
// 仮説3: @if の条件が違う?
//   → @if (total() > 0) だが total() は 0 円もありうる
//   → ここだけ @if (total() !== null) に変更 → 直った!
// → 効いた変更は1つだけ。原因の説明もでき、余計な変更も残らない

// 鉄則: 変更は一度に一箇所。効かなかった変更は必ず元に戻す