プログラマのための IT 教科書

デバッグの技術

この部の 3 / 9 章 ・ 全体で 15 / 76 章 ・ 読了目安 50 分

この章を読むとできるようになること
  • 症状から原因までを手順で詰められる
  • 再現手順を作ってから直すという順序を守れる
  • 「なぜか動かない」で止まらない

前章で、既存コードを読めるようになりました。次は読んだコードが期待通りに動かない時の話です。

実務でコードを書いている時間より、動かない理由を探している時間の方が長い日はよくあります。 それなのに、デバッグのやり方を体系的に教わる機会はほとんどありません。

この章は精神論ではなく、手順と道具の話をします。

デバッグは推理ではなく手順

「なんとなく直った」が最悪の結果

デバッグの結果は、良い順に3つあります。

結果状態
✅ 最良原因を特定して直した。なぜ壊れ、なぜ直ったかを説明できる
⚠️ 次善原因は特定したが、根本対処はできず暫定回避した。それを記録してある
❌ 最悪なんとなく直った。なぜ直ったか分からない

3番目が最悪なのは、直っていない可能性が高いからです。

  • たまたま条件が揃わなくなっただけで、本番で再発します
  • 別の場所に同じ原因のバグが残っていても気づけません
  • 「触ったら直った」コードは、次の人が触ると再び壊れます

なぜ直ったか説明できないなら、それは直っていません。

これがこの章で一番大事な一文です。 「とりあえず色々変えたら通った」で終わらせないために、以下の手順があります。

手順の全体像

① 再現手順を作る       確実に再現する最小の手順を書き出す
      ↓
② エラーを読む         メッセージとスタックトレースを最後まで読む
      ↓
③ 範囲を半分にする     どこまでは正しいかを確かめ、二分探索する
      ↓
④ 仮説を1つ検証する    1回に1つだけ変える。違ったら戻す
      ↓
⑤ 直す                原因を説明できる状態で直す
      ↓
⑥ テストを1本書く      同じバグが二度と通らないようにする

この順番を飛ばすほど時間がかかります。 新人がやりがちなのは ① と ② を飛ばして、いきなり④の「なんとなく直してみる」に入ることです。

先輩が一瞬で原因を当てるのは、勘が鋭いからではありません。 過去に同じ壊れ方を見たことがあるか、この手順を高速で回しているかのどちらかです。 前者は経験なので今日は真似できませんが、後者は今日から真似できます。 そして手順を持っている人は、初めて見るバグでも必ず原因に到達します。

ステップ1: まず再現手順を作る

再現できないバグは直せない

デバッグで最初にやるのは、コードを読むことでも直すことでもありません。 手元で確実に再現させることです。

再現できないと、こうなります。

  • 原因を調べる手段がない(実際に動かして確かめられない)
  • 直したつもりでも、直ったことを確認できない
  • 「最近見ないので直ったと思います」という報告になる

逆に言うと、再現手順ができた時点で半分終わっています。 残りは範囲を絞る作業で、これは機械的にできます。

再現手順に必要な情報

「ログインするとエラーになる」では再現できません。次を埋めます。

項目例
前提データ注文が0件のユーザー、クーポン適用済みの注文
操作の順番一覧 → 詳細 → 「戻る」→ 再度詳細
入力値数量に 0 を入れる、名前に絵文字を含める
環境ローカル / staging、ブランチ、Node のバージョン
頻度毎回か、5回に1回か(ここは必ず書く)
期待と実際期待: 一覧が表示される / 実際: 500 が返る

「5回に1回」は、それ自体が強いヒントです。 毎回でないなら、時刻・順序・並行実行・キャッシュ・乱数・外部依存のどれかが絡んでいます。

再現条件を削っていく(最小再現)

再現できたら、次は条件を1つずつ削って、なくても再現するかを確かめます。

最初:  ログイン → 商品検索 → カートに3つ入れる → クーポン適用 → 決済 → 落ちる
  ↓ ログインを消す           → 落ちない  → ログインは必要
  ↓ 検索をやめて直接URLへ    → 落ちる    → 検索は不要(削除)
  ↓ カートを1つにする        → 落ちる    → 3つは不要(削除)
  ↓ クーポンを外す           → 落ちない  → クーポンは必要
最小: ログイン → 商品1つ → クーポン適用 → 決済 → 落ちる

削ったあとに残った条件が、そのまま原因の候補です。 上の例なら「クーポン適用時の金額計算」に一気に絞れました。

最小再現ができたら、それをそのままテストコードに書き写せます。

it('クーポン適用済みの注文で合計が計算できる', () => {
  const order = makeOrder({ items: [item(1000)], coupon: coupon(500) });
  expect(calcTotal(order)).toBe(500);
});

このテストは今は落ちます。落ちるテストがあるということは、 直った瞬間に緑になるということで、「直った」の判定が自動化されます。 この章の最後(再発防止)とここは繋がっています。

再現しないまま「直しました」と言わない

新人が最も信頼を失うのは、これです。

  • 「再現しなかったので、たぶんこれが原因だと思って直しました」
  • 「エラーが出なくなったので直ったと思います」

どちらも直っていない可能性が半分以上あります。 再現できないなら、正直にそう言ってください。

「手元で再現できていません。再現条件を教えてもらえますか。 発生時刻とユーザーIDが分かればログから追えます」

これは無能な発言ではなく、正しい進め方です。 再現できないバグを推測で直すと、原因が残ったまま「対応済み」になり、 数週間後にもっと悪い形で再発します。

ステップ2: エラーメッセージを読む

読まずに検索しない

エラーが出た瞬間に、メッセージをコピーして検索していませんか。 その前に、まず日本語に訳して読んでください。答えが書いてあることが本当に多いです。

Error: connect ECONNREFUSED 127.0.0.1:5432

これは「127.0.0.1 の 5432 番ポートに繋ごうとしたが、接続を拒否された」です。 5432 は PostgreSQL の標準ポートなので、「DB が起動していない」か「別のポートで動いている」。 検索する前に docker ps を見れば10秒で終わります。

頻出メッセージの意味

現場で毎週見るものだけ並べます。意味を覚えると、検索する回数が激減します。

メッセージ意味まず疑うこと
undefined is not a function / x is not a function関数だと思って呼んだものが関数ではないimport 名の綴り、default / named の取り違え、this の消失
Cannot read properties of undefined (reading 'name')undefined.name を読もうとした前の行の戻り値が undefined。API のレスポンス形が想定と違う
nil pointer dereference(Go)nil のポインタを参照したエラーを無視して戻り値を使った、初期化していないフィールド
index out of range [3] with length 3(Go)配列の範囲外アクセスループ条件の <=、空スライス
connection refused相手はいるが誰も待ち受けていないプロセス未起動、ポート違い、コンテナ内から localhost を見ている
connection timeout応答が返ってこない相手に届いていない。ホスト名違い、ファイアウォール、Security Group
no such host / ENOTFOUND名前解決に失敗ホスト名の綴り、DNS、サービス名の間違い
EADDRINUSEそのポートは既に使われている前のプロセスが残っている(後述のチェックリスト)
401 Unauthorized認証されていない(あなたが誰か分からない)トークン未送信・期限切れ
403 Forbidden認証はできたが権限がないロール、スコープ、他人のリソース
404 Not Foundパスが違うURL の綴り、ルーティング、/api の付け忘れ
500 Internal Server Errorサーバー側で例外が出たクライアントを見ても無駄。サーバーのログを見る
ENOENT: no such file or directoryファイルが無い相対パスの基準ディレクトリ違い
Permission denied権限不足実行権限、所有者、ボリュームのマウント権限
401 と 403 を混同しない

この2つを取り違えると、調査の方向が丸ごと変わります。

  • 401: 「あなたが誰か分からない」。トークンが無い・壊れている・期限切れ
  • 403: 「あなたが誰かは分かった。でもそれをする権限が無い」

401 なのに権限設定を1時間見直す、というのは実際によくある時間の溶かし方です。 まず認証まで通っているのかを切り分けてください。

最初と最後を両方読む

エラーが数十行あると、つい一番上だけ見て閉じてしまいます。 実際には、本当の原因は末尾に書かれていることが多いです。

Error: Failed to compile
  ...
  Caused by: Cannot find module '@/domain/order'

Caused by、原因:、the above exception was the direct cause of といった語の後ろが本体です。 長いログは「最初の10行」と「最後の10行」を必ず読んでください。

ステップ3: スタックトレースを読む

スタックトレースは、エラーが起きるまでに通った関数の呼び出し履歴です。 読み方さえ知っていれば、原因の場所がほぼ書いてあります。

どこから読むか

原則は3つだけです。

  1. 一番上が、実際に落ちた場所(最も内側の呼び出し)
  2. 下に向かうほど、呼び出し元をさかのぼる
  3. node_modules や標準ライブラリ・フレームワークの行は飛ばす

そして実務で一番使うのはこれです。

上から読んで、最初に出てくる「自分のコードの行」を見る。

フレームワークの中で落ちていても、原因の9割はあなたが渡した値にあります。

TypeScript のスタックトレース

TypeError: Cannot read properties of undefined (reading 'displayName')
    at formatUser (/app/src/user/format.ts:12:22)
    at /app/src/user/list.ts:31:26
    at Array.map (<anonymous>)
    at buildUserList (/app/src/user/list.ts:30:20)
    at getUsersHandler (/app/src/routes/users.ts:18:18)
    at Layer.handle (/app/node_modules/express/lib/router/layer.js:95:5)
    at next (/app/node_modules/express/lib/router/route.js:144:13)
    at Function.process_params (/app/node_modules/express/lib/router/index.js:346:12)

読む順番はこうです。

行見方
1行目何が起きたか。undefined の displayName を読もうとした
format.ts:12:22落ちた場所。ここを開く。12 行目の 22 文字目
list.ts:31 / list.ts:30呼び出し元。map の中で formatUser を呼んでいる
routes/users.ts:18さらに呼び出し元。ここがハンドラの入口
node_modules/express/...読まなくてよい。express の内部

ここから分かるのは「formatUser に渡された値のどれかが undefined だった」ことです。 なので次にやるのは list.ts:31 で何を渡しているかを実際に出して確かめることであって、 format.ts を睨むことではありません。

async のスタックトレースは途切れる

await を挟むと、スタックトレースが呼び出し元まで繋がらないことがあります。

Error: connect ECONNREFUSED 127.0.0.1:6379
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)

自分のコードが1行も出てきません。こうなったら、

  • Error.captureStackTrace を持つエラーを投げ直す(throw new Error('...', { cause: err }))
  • 呼び出し側で try / catch して、どの処理中だったかをログに足す
  • Node なら --async-stack-traces(既定で有効)や、フレームワークのエラーハンドラを確認する

「どこで落ちたか分からないエラー」は、まず場所が分かる形に加工してから追います。

Go のスタックトレース

Go の panic は、より多くの情報が出ます。

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x104a2c8]
 
goroutine 34 [running]:
main.(*OrderService).Total(0x0, {0x14000112000, 0x2, 0x2})
	/app/order/service.go:48 +0x28
main.(*Handler).CreateOrder(0x14000104018, {0x1050e40, 0x14000138000}, 0x1400013a000)
	/app/handler/order.go:73 +0x114
net/http.HandlerFunc.ServeHTTP(0x14000106020, {0x1050e40, 0x14000138000}, 0x1400013a000)
	/usr/local/go/src/net/http/server.go:2220 +0x38
net/http.(*ServeMux).ServeHTTP(0x14000110000, {0x1050e40, 0x14000138000}, 0x1400013a000)
	/usr/local/go/src/net/http/server.go:2747 +0x1a4

見る場所は3つです。

見る場所分かること
nil pointer dereferencenil を参照した。多くは戻り値の err を無視した結果
main.(*OrderService).Total(0x0, ...)落ちた関数。第1引数の 0x0 はレシーバが nil という決定的な証拠
/app/order/service.go:48関数名の次の行にファイルと行番号が出る
/usr/local/go/src/net/http/...標準ライブラリ。飛ばす

(0x0, ...) に気づけるかどうかで、調査時間が10分と2時間に分かれます。 Total の中を読む前に、OrderService が nil のまま呼ばれていることが確定しているからです。 つまり見るべきは handler/order.go:73 の側、サービスの初期化漏れです。

なお、Go の panic は実行中の全 goroutine を出力することがあり、数百行になります。 読むのは goroutine ... [running]: と書かれたブロックだけです。 [chan receive] や [select] の goroutine は待機中であって犯人ではありません。 ただしデッドロック(all goroutines are asleep - deadlock!)の時だけは逆で、 待っている goroutine 全部が手がかりになります。

ステップ4: 二分探索で範囲を半分にする

再現でき、落ちた場所も分かった。それでも原因が分からない時に効く、最も機械的な武器です。

考え方は「どこが悪いか」ではなく「どこまでは正しいか」です。

コード上の二分探索

処理の流れの真ん中で、値を確かめます。

[入力] → A → B → C → D → E → [出力がおかしい]
                ↑
            まずここで値を見る
  • C の時点で値が正しい → 原因は D か E
  • C の時点で既におかしい → 原因は A か B

これを繰り返せば、10段の処理でも3〜4回で場所が確定します。 「入力から順に全部追う」より圧倒的に速く、しかも当てずっぽうが入りません。

確かめるポイントは、境界を選ぶと効率が良いです。

境界確かめること
関数の入口渡ってきた引数は想定通りか
API を呼ぶ直前送っているリクエストの中身
API のレスポンス直後返ってきた生のボディ(パース前)
DB クエリの直前実際に発行される SQL とパラメータ
画面に出す直前描画用に整形したあとの値

「送っているもの」と「返ってきたもの」を実際に見るだけで、 自分のバグか相手のバグかが切り分けられます。これが一番大きい分岐です。

git bisect で履歴を二分探索する

「先週は動いていたのに、今日は動かない」——このタイプはコードではなく履歴を二分探索します。

git bisect は、good なコミットと bad なコミットを指定すると、 その間を二分探索して壊したコミットを特定してくれる機能です。

# 1. 開始
git bisect start
 
# 2. 今(HEAD)は壊れている
git bisect bad
 
# 3. 動いていたことが分かっている地点を good と伝える
git bisect good v1.4.0
 
# → git が中間のコミットを checkout する
#   Bisecting: 87 revisions left to test after this (roughly 7 steps)
 
# 4. 毎回、再現手順を実行して判定する
git bisect good     # 再現しなかった
git bisect bad      # 再現した
#   ... これを7回ほど繰り返すと
 
#   a1b2c3d is the first bad commit
 
# 5. 必ず後片付けする(元のブランチに戻る)
git bisect reset

1000 コミットあっても10回で終わります(毎回半分になるため)。 手で1つずつ戻すのとは比較になりません。

判定コマンドがある場合は、全自動にできます。

# テストで判定できるなら run に渡すだけ
git bisect start HEAD v1.4.0
git bisect run npm test -- tests/order.test.ts
 
# 任意のスクリプトでもよい(終了コードで判定する)
git bisect run ./scripts/repro.sh

git bisect run の判定は終了コードで行われます。

終了コード意味
0good(再現しない)
1〜124, 126, 127bad(再現する)
125判定できない(ビルドが通らない等)→ そのコミットをスキップ
bisect が効くのは「再現手順」があるから

git bisect は「そのコミットで再現するか」を毎回判定できることが前提です。 つまり、ステップ1の再現手順が無いと使えません。

ここでも「再現手順ができた時点で半分終わっている」が効いてきます。 逆に、bisect のためだけに簡単な再現スクリプトを1本書く価値は十分にあります。

「先週のリリースまでは動いていた機能が、今日は動きません」。差分は 200 コミットあります。最も速い進め方はどれですか。

ステップ5: 仮説を1つずつ検証する

同時に複数変えない

範囲が絞れたら、いよいよ原因の仮説を立てて検証します。ここでのルールはひとつです。

1回の試行で変えるのは1つだけ。

3箇所同時に変えて動いたとき、あなたは何も学んでいません。

  • どれが効いたのか分からない(=なぜ直ったか説明できない)
  • 残りの2つは、無関係な変更として本番に混入します
  • そのうち1つが、別のバグを新しく作っているかもしれません

変えたら戻す

効かなかった変更は必ず戻してください。 これを怠ると、 デバッグの終わりに「何をどう変えたのか分からないコード」が残ります。

# 今の作業を一時退避してから実験する
git stash
 
# 実験して外れたら、まとめて捨てる
git checkout -- src/order/service.ts
 
# 何を変えたか分からなくなったら、まず全部見る
git diff

デバッグ中は git diff を頻繁に見る癖をつけてください。 「デバッグ用の print が本番に混入する」事故の大半はこれで防げます。

検証した内容を書き留める

30分以上かかりそうなら、メモを取りながら進めます。手元のテキストファイルで十分です。

症状: クーポン適用時だけ合計が 0 になる
再現: ログイン → 商品1つ → クーポン500円 → 決済 (毎回)
 
仮説1: クーポン額が商品額を超えている       → 商品1000円なので違う ❌
仮説2: calcTotal が nil を受け取っている     → ログ出力: coupon は入っている ❌
仮説3: 割引後に再度割引が適用されている       → discount が2回呼ばれていた ✅

これを書くと、3つの効果があります。

  • 同じ仮説を2回検証してしまう無駄が消える
  • 人に相談する時、そのまま貼れる(30分ルールで聞く時に強い)
  • 直したあと、PR の説明文とテストの根拠になる

道具: デバッガと print の使い分け

デバッガでできること

print を仕込んで再実行する代わりに、動いているプログラムを止めて中を見る道具です。

機能できること
ブレークポイント指定した行で実行を止める
ステップ実行1行ずつ進める
変数の観察その時点の全変数の値を見る。式を評価もできる
コールスタックどこから呼ばれてここに来たかを遡る
条件付きブレークポイント条件を満たす時だけ止める

print と決定的に違うのは、止めた時点で「全部」見られることです。 print は「出すと決めた変数しか見えません」。 「あ、あの変数も見たかった」で再実行する回数がゼロになります。

ステップ実行の3種類

ここだけは用語を覚えてください。IDE のボタンの意味が分かります。

操作動き使う場面
ステップオーバー(over)次の行へ進む。関数呼び出しは中に入らず実行する既に正しいと分かっている関数を飛ばす。基本はこれ
ステップイン(into)呼び出している関数の中に入るその関数が怪しい時
ステップアウト(out)今の関数を最後まで実行して、呼び出し元に戻る間違って into して深く入りすぎた時

初心者がやりがちなのは、全部 into で入ってフレームワークの奥深くに迷い込むことです。 基本は over、怪しい関数だけ into、深入りしたら out と覚えてください。

条件付きブレークポイント

デバッガが print に圧勝するのがここです。 1000件のループの中で、特定の1件だけ止められます。

VS Code なら、ブレークポイントを右クリックして条件を書きます。

orderId === 'o_1042'
items.length === 0
i > 500

Go の delve なら、コマンドで同じことができます。

dlv debug ./cmd/api
 
(dlv) break order/service.go:48        # ブレークポイント1 を設定
(dlv) condition 1 o.ID == "o_1042"     # 条件を付ける
(dlv) continue                          # 条件に合うまで走らせる
(dlv) locals                            # ローカル変数を全部見る
(dlv) print o.Items                     # 式を評価する
(dlv) next                              # ステップオーバー
(dlv) step                              # ステップイン
(dlv) stepout                           # ステップアウト

Node(TypeScript)は、VS Code の実行ボタンが最も簡単ですが、CLI からも繋げます。

# 1行目で止めた状態で起動し、デバッガの接続を待つ
node --inspect-brk dist/index.js
 
# tsx / ts-node なら
node --inspect-brk -r tsx/cjs src/index.ts

接続先は Chrome の chrome://inspect か、VS Code の Attach 構成です。

デバッガが常に最善ではありません。むしろ print の方が速い場面があります。

場面向いている道具理由
1箇所の値を見たいprint起動より速い
複雑なオブジェクトの中身を追いたいデバッガ全部見える
ループ1000回のうち1回を見たい条件付きブレークポイント手で1000回進めない
時間に依存する不具合(並行処理、タイムアウト)ログ止めると再現しなくなる
本番・staging で起きているログデバッガは繋げない
テストが落ちる理由を知りたいデバッガテストにブレークポイントを置ける
止めると消えるバグがある

並行処理・レースコンディション・タイムアウト絡みの不具合は、 ブレークポイントで止めた瞬間にタイミングが変わり、再現しなくなります。

「デバッガを繋ぐと直る」バグは、ほぼ確実にタイミングの問題です。 この場合はログ(時刻・goroutine ID・スレッド ID 付き)で追ってください。 監視とオンコールの構造化ログが、ここでも同じように効きます。

print デバッグの後片付け

console.log('ここ通った') や fmt.Println("aaa") を消し忘れて レビューに出す事故は、新人が最もよくやるものの1つです。

  • コミット前に 必ず git diff を全部読む
  • 目印を付けておく(// DEBUG: と書いて、後で grep する)
  • lint で no-console を有効にしているリポジトリなら CI が止めてくれる

そして、本番に残す価値のあるものはログとして構造化して残します。 「消すか、正式なログにするか」の二択で、中間はありません。

思い込みを外す

「ここは絶対正しい」が一番怪しい

3時間溶かすバグは、ほぼ例外なくここに潜んでいます。

  • 「この設定ファイルは読まれているはず」
  • 「この関数は前から動いているから正しい」
  • 「保存したから最新のコードが動いているはず」

「はず」は検証されていない仮説です。 そして、あなたが確認を飛ばした場所こそが、探しても見つからない理由です。

対処はひとつだけです。

実際に値を出して確かめる。

// 「読めているはず」を確かめる。マスクして出す
console.log('DB_HOST =', process.env.DB_HOST);
console.log('API_KEY set?', Boolean(process.env.API_KEY));
// この関数に本当に来ているか、引数は何か
log.Printf("CreateOrder: userID=%q items=%d", userID, len(items))

そもそも自分のコードが動いていないことがある

もう1つの大物がこれです。直したコードが実行されていないパターン。

// 一番上に置いて、まず「このファイルが読まれているか」を確かめる
console.log('>>> service.ts loaded at', new Date().toISOString());

これが出なければ、コードのロジックをいくら読んでも無駄です。疑うのは次です。

  • ビルドし直していない(dist/ が古い)
  • ホットリロードが効いていない
  • 別のディレクトリ・別のブランチを編集している
  • Docker のイメージが古い(--build していない)
  • ブラウザがキャッシュした JS を使っている

環境・キャッシュ・ビルド成果物を疑う

「コードは正しいのに動かない」時の定番です。効く順に並べます。

# 1. 本当に最新が動いているか(ビルド成果物を消す)
rm -rf dist .next node_modules/.cache
npm run build
 
# 2. Go のビルドキャッシュ
go clean -cache && go build ./...
 
# 3. Docker が古いイメージを使っていないか
docker compose up --build
 
# 4. ブラウザのキャッシュ(DevTools を開いてハードリロード)
「クリーンビルドしたら直った」で終わらせない

これも「なんとなく直った」の一種です。なぜ古い成果物が使われていたのかを確認してください。

  • ビルドの出力先と、実行時に読むパスがずれている
  • .dockerignore の設定で、変更したファイルがイメージに入っていない
  • watch の対象ディレクトリから外れている

原因を放置すると、チーム全員が毎回クリーンビルドする文化が生まれます。 1人あたり1日10分の損失が、そのままチームの損失になります。

よくある原因のチェックリスト

原因が思いつかなくなったら、上から順に実際に確認してください。 「たぶん大丈夫」で飛ばさないこと。ここに挙げたものは全部、実際に起きます。

#疑うこと確認方法
1環境変数が読めていない実際に出力する。.env の場所、読み込みの順序、NEXT_PUBLIC_ などの接頭辞
2キャッシュが古いビルド成果物・ブラウザ・CDN・ORM のクエリキャッシュを消す
3別のプロセスが動いているlsof -i :3000 / ps aux | grep node。古いサーバーが応答している
4ポートが違う起動ログのポートと、叩いている URL を突き合わせる
5保存していない編集中のタブに未保存マークが無いか。本当にあります
6見ているログが別のインスタンスPod / コンテナが複数ある。ログにホスト名を入れる
7型は合っているが値が空[] / "" / {} は型検査を通る。空かどうかを実際に見る
8別のブランチ・別のディレクトリを見ているgit status と pwd
9依存が入っていない・古いnpm ci / go mod tidy してからもう一度
10相手のサービスが落ちている自分のコードを疑う前に、依存先に直接 curl する
11DB のデータが想定と違うコードではなく実データを SQL で見る
12タイムゾーン・文字コードUTC と JST、絵文字を含む文字列

特に効くのは7番です。TypeScript や Go の型検査を通っていると、 「値も正しいはず」と錯覚します。しかし型が保証するのは形であって、中身ではありません。

const users = await fetchUsers();  // 型は User[] で正しい
// 実際は [] が返っていて、画面に何も出ない

このとき壊れているのは表示側ではなく、取得側のクエリ条件です。 型エラーが出ないバグほど、値を出して確かめる価値があります。

詰まった時の脱出

時間を決める

この教科書の使い方の30分ルールは、デバッグでもそのまま使います。 30分進まなければ手を止めてください。 判断基準はシンプルです。

状態続けてよいか
範囲が狭まっている(さっきより候補が減った)続けてよい
同じ場所を2回目に見ている危険信号
何を試したか思い出せない止める。人に聞く

デバッグは、進捗が見えにくいので無限に時間を溶かせます。 「もう少しで分かりそう」という感覚は、たいてい当たりません。

聞く時は、この教科書の使い方の3点セットに、この章のメモを足すだけです。

「注文の合計が0になる件、手元で毎回再現しています。 calcTotal に渡る時点では正しくて、割引適用後に0になっているところまでは絞りました。 割引が2回かかっている可能性を見ていますが、なぜ2回呼ばれるかが分かりません」

ここまで書けると、相手は5分で答えられます。

人に説明すると自分で気づく

説明の途中で「あ、分かりました」となる現象は、冗談ではなく本当に頻繁に起きます。 ラバーダック・デバッグ(アヒルのおもちゃに説明する)と呼ばれる有名な手法です。

理由は単純で、説明するには前提を言語化しないといけないからです。 「ここは正しいはずで……いや、確かめてないな」と、自分の思い込みが露出します。

相手は人でなくても構いません。Slack の下書き、ノート、AI への説明でも同じ効果があります。 書き出す時点で半分解決しているので、送る前に自己解決したら、 そのまま「自己解決しました、原因は〜でした」と投げると、チームの知識になります。

寝る

真面目な話です。疲れているとデバッグの効率は劇的に落ちます。

  • 同じ場所を何度も読む(見ているのに見えていない)
  • 思い込みから抜けられなくなる
  • 焦って複数箇所を同時に変え始める

3時間詰まったものが、翌朝10分で解けることは珍しくありません。 深夜に本番のコードを触るのは、最も事故が起きやすい状態でもあります。

そして中断する時は、必ずメモに1行残してください。

次: discount が2回呼ばれる理由を調べる。applyCoupon の呼び出し元を grep する

翌朝の再開が5分早くなるだけでなく、別の人が引き継げる状態になります。 これは監視とオンコールの障害対応の記録と同じ考え方です。

直したら再発を防ぐ

必ずテストを1本書く

デバッグは、直した時点では終わっていません。最後に必ずテストを1本追加します。

手順はこうです。

1. バグを再現するテストを書く    → 落ちることを確認する(赤)
2. コードを直す                  → 通ることを確認する(緑)
3. わざとコードを元に戻してみる   → 再び落ちることを確認する

3番目が重要です。落ちないテストは、何も守っていません。 「テストを書いたのに、実は壊しても緑のまま」という事故はよくあります。

書くテストは、ステップ1で作った最小再現がそのまま使えます。 だから最小化しておく価値があるわけです。詳しい書き方はテストを書くを参照してください。

// バグ: クーポン適用時に割引が2回かかっていた
it('クーポンの割引は1回だけ適用される', () => {
  const order = makeOrder({ items: [item(1000)], coupon: coupon(300) });
  expect(calcTotal(order)).toBe(700);   // 400 ではない
});

記録を残す

もう1つ、なぜ壊れていたかを文章で残します。置き場所は次のどれかです。

場所書くこと
コミットメッセージ・PR の説明症状・原因・直し方。未来の git blame で読まれる
コード中のコメントなぜこうしたかが自明でない時だけ1行
チケット再現手順と、影響範囲
fix: クーポンの割引が2回適用される不具合を修正
 
applyDiscount がハンドラと calcTotal の両方から呼ばれていたため、
クーポン適用済みの注文で割引が二重にかかっていた。
calcTotal 側に集約し、ハンドラからの呼び出しを削除する。

同じ形のバグが他に無いか探す

これができると、一気に「新人ではない仕事」になります。 1箇所で起きたバグは、同じパターンが他にもあることが多いためです。

# 同じ関数の呼び出し箇所を全部見る
grep -rn "applyDiscount(" src/
 
# エラーを無視している箇所を探す(Go)
grep -rn "_ = .*err" .

監視とオンコールの再発防止と同じで、「その1箇所を直した」で止めないことが価値になります。

実務の落とし穴まとめ

  1. 再現手順を作らずに直し始める — 直ったかどうかを確認できない
  2. 「なんとなく直った」で完了にする — ほぼ再発する。説明できないなら直っていない
  3. エラーメッセージを読まずに検索する — 答えが本文に書いてある
  4. スタックトレースの1行目だけ見る — 自分のコードの最初の行と、末尾の Caused by を読む
  5. node_modules の中を読み込む — 原因の9割は自分が渡した値
  6. 一度に複数箇所を変える — どれが効いたか分からなくなる
  7. 効かなかった変更を戻さない — デバッグ用コードが本番に混入する
  8. 「ここは絶対正しい」を確認しない — そこが原因
  9. クリーンビルドで直って終わりにする — なぜ古い成果物が使われたかを潰す
  10. print の消し忘れ — コミット前に git diff を全部読む
  11. 3時間ひとりで粘る — 30分で止める。説明する過程で解けることも多い
  12. 直してテストを書かない — 同じバグが半年後に戻ってくる

まとめ

  • デバッグは推理ではなく手順。なぜ直ったか説明できないなら直っていない
  • 最初にやるのは再現手順を作ること。条件を削って最小化する。できた時点で半分終わり
  • エラーメッセージは読んでから検索する。401 と 403、connection refused と timeout の違いは覚える
  • スタックトレースは上から読んで、最初の自分のコードの行を見る。フレームワークの奥は飛ばす
  • Go の (*T).Method(0x0, ...) はレシーバが nil という決定的なヒント
  • 「どこが悪いか」ではなく 「どこまでは正しいか」 で範囲を半分にする
  • 履歴の二分探索は git bisect。1000コミットでも10回。git bisect run で全自動にできる
  • 仮説は1回に1つ。効かなかった変更は必ず戻す
  • デバッガは条件付きブレークポイントが本領。時間に依存する不具合とリモートはログ
  • 「絶対正しい」が一番怪しい。値を実際に出す。環境変数・キャッシュ・古いビルド成果物・別プロセスを疑う
  • 30分で止める。人に説明すると自分で気づく。寝ると解ける
  • 直したら必ずテストを1本。赤→緑を確認し、原因をコミットメッセージに残す

公式ドキュメント

迷ったら一次情報に戻ってください。

章末問題

API のレスポンスが空になる不具合を調べています。3箇所を同時に修正したところ、正常に動くようになりました。次にどうすべきですか。

デバッグができるようになると、知らない技術のコードでも触れるようになります。 仕組みを全部理解していなくても、再現して、範囲を絞って、値を確かめれば原因に辿り着けるからです。

次の章では、コードではなく文章を書きます。設計ドキュメント、手順書、相談の文面—— 新人が最初に評価されるのは、実はここです。

読み終わったら記録しておくと、目次で進み具合が分かります。