1. テストはバグ再現に最適なモード
バグが「たまに」しか出ないとき、人はつい健全な人間らしい行動をしたくなります。まず少し泣いて、それから fmt.Printf を10個追加し、そのうち9個を削除し(邪魔で生きづらくなるので)、そのあと……うっかり別のものを直してしまう。テストが助けになるのは、問題を 繰り返し再現できるシナリオ に変えてくれるからです。固定された入力 → 固定されたコード → 予測可能なクラッシュまたは誤った結果、という形にできます。
デバッグ手段としてのテストは、人間にとってとても扱いやすいものです。第一に、実行が速い。メニューをマウスでたどる必要も、パラメータを手で入れ直す必要も、「前回は 10 を入れたんだっけ、それとも 100 だっけ?」と思い出そうとする必要もありません。第二に、テストはほとんどバグのドキュメントです。明日になって何が壊れていたか忘れても、テストが思い出させてくれます。ときにはかなり大きな声で。第三に、テストは実験のための 狭い通路 を与えてくれます。1〜2行変えて、1つのテストだけ再実行し、すぐに効果を確認できます。
そして何より重要なのは、テストがすでに持っているスキルとうまく組み合わさることです。テストが panic で落ちれば、スタックトレースが手に入り、これまでどおり「自分のファイル/自分の行を探す」というスキルをそのまま使えます。テストが got/want の比較で落ちるなら、t.Logf や一時的な fmt.Printf をピンポイントで追加できます。どうしても厳しいなら、テストはデバッガの便利な入口にもなります。
2. go test の実行を制御する: -run、-v、-count=1
テスト経由のデバッグを快適にするには、実行方法を少し「手なずける」必要があります。デフォルトの go test は、プロジェクト全体に対してすぐ役立つように設計されています。パッケージをビルドし、テストを実行し、キャッシュを使うこともあり、出力も比較的コンパクトです。普段使いには最高です。ですがデバッグ中には別のことをしたくなる場合があります。たとえば、シナリオを1つだけ実行したい、もっと詳しい情報を見たい、そしてそのテストが本当に再実行されたのであってキャッシュから返ってきたのではないと確信したい、ということです。
デバッグ中に意識して制御したいものは3つあります。実行範囲(全テストか1つだけか)、出力の量(静かか詳細か)、そして 再現性(本当に毎回テストが新しく実行されているか)です。ここを自分でコントロールし始めると、デバッグは「魔法」ではなく、きちんとした作業になります。
-run: 「直した → 確認した」のサイクルを速くする
プロジェクトが少しでも生きたものになってくると、テストはどんどん増えます。これは良いことですが、デバッグ中に全部を毎回回すのは時間的にも精神的にも高くつきます。そこで登場するのが -run フラグです。
形式的には、-run は正規表現を受け取ります。人間向けに言えば、-run はテスト名に対するフィルタです。「これだけ実行して」と伝えると、go test は一致したものだけを実行します。
覚えておきたいポイントは2つあります。
1つ目。-run は 正規表現 であり、「完全一致の名前」ではありません。なので -run Test とすると、名前の中に Test を含むすべてのテストが実行されます。一方で -run ^TestFoo$ は、^ が文字列の先頭、$ が文字列の末尾を意味するので、ちょうど TestFoo だけを実行します。
2つ目。サブテスト(t.Run)が出てくると、その名前もフィルタに含まれ、/ 区切りで書かれます。つまり、単にテストを指定するだけでなく、その中の特定のシナリオまで狙えます。
ちょっとした早見表:
| やりたいこと | コマンド(考え方) | なぜそうするのか |
|---|---|---|
| 1つのテストだけを実行する | |
で regex を「締めて」、余計な一致を防ぐため |
| 似たテストのグループを実行する | |
を含むものすべてに一致するため |
| 特定のサブテストを実行する | |
サブテスト名は の後ろに続くため |
正規表現が別種の魔法のように感じられても(少しはその通りです)、心配はいりません。実務ではたいてい3つのモードで足ります。「単語」、「^完全一致$」、「テスト/サブテスト」です。
-v: 実際に何が実行されたかを見る
-v フラグは、テストやサブテストの実行状況を詳細に表示します。何が、どの順番で実行されたのかが見えます。これは特に、あなたの -run が思っている通りに一致していないときに役立ちます。
さらに、t.Logf は通常、テストが失敗したときか verbose モードでしか見えません。ですから -v は、ログに出しているものを「目で確認する」助けになります。
-count=1: デバッグ中だけキャッシュを無効にする
go test は成功した結果をキャッシュできます。これは単に「全部グリーンかどうかを素早く確かめたい」ときには素晴らしい機能です。ですがデバッグ中は、確実にテストを再実行したいことがあります。そんなときは -count=1 で「キャッシュを使わない」と伝えます。
ピンポイントデバッグのための実戦用コマンド
どのシナリオが落ちるのか分かっているなら、フラグを1つのコマンドにまとめるのが便利です:
go test -run TestFindByID/not_found -v -count=1 ./...
3. 顕微鏡としてのサブテスト: 10個のうち1ケースだけ
table-driven テストを書くときは、通常1つの TestXxx の中で多くのケースを回します。これは良いやり方です。構造はコンパクトで、シナリオ同士も近くに置けます。ただ、デバッグ中には、落ちるケース だけを1つ 実行したいことがあります。表全体を回したいわけではありません。
だからこそ、サブテストにはちゃんとした名前を付けることが重要です。case1 でも case2 でも、まして aaa でもありません。not_found、empty_title、bad_id、unicode のような名前にしておけば、1週間後の自分が「ありがとう、過去の自分」と言うことになります。
例として、学習用のミニアプリ Tasker(架空のタスク管理アプリ)を見てみましょう。タスクのスライスがあり、ID でタスクを探したい状況です。
バグのあるコード: ID とインデックスを取り違える
たとえば、誰かが(名前は出しませんが)こんなふうに書いたとします:
package task
type Task struct {
ID int
Title string
}
func FindByID(tasks []Task, id int) (Task, bool) {
t := tasks[id] // BUG: id is not an index!
return t, true
}
このコードは、id がたまたまスライス内のインデックスと一致している間は、それらしく見えます。ですがそのうち現実がやってきて、テストが叫び始めます。
サブテスト付きのテスト: found と not_found
package task
import "testing"
func TestFindByID(t *testing.T) {
tasks := []Task{{ID: 10, Title: "buy milk"}, {ID: 20, Title: "learn Go"}}
t.Run("found", func(t *testing.T) {
_, ok := FindByID(tasks, 10)
if !ok {
t.Fatalf("expected ok=true")
}
})
t.Run("not_found", func(t *testing.T) {
_, ok := FindByID(tasks, 999)
if ok {
t.Fatalf("expected ok=false")
}
})
}
今の時点で、found のテストも妙だと気づいたとしても(id=10 を探しているのに、スライスにインデックス 10 は存在しません)、それこそがデバッグの意味です。テストは、期待と現実の食い違いをはっきり照らし出してくれます。
もし落ちるのが not_found だけなら、それだけを実行できます:
go test -run TestFindByID/not_found ./...
これだけで作業は一気に速くなります。プロジェクト内の他のテストが終わるのを待つ必要はありません。痛い場所だけを正確に照らすレーザーポインタのように作業できます。
4. 最小再現例
はまりやすい罠があります。バグが大きなシナリオの中でだけ現れると、その大きなシナリオ全体をそのままデバッグしようとしてしまうのです。これはボンネットを開けずに車を修理するようなものです。できなくはないですが、たいてい長引きますし、少し演劇的でもあります。
最小再現例(よく MRE, minimal reproducible example と言います)とは、バグの再現に必要なものだけを残し、それ以外を全部捨てる という考え方です。理想は、コードが10行だけになって、7行目で確実に壊れる状態に持っていくことです。そこまでいくと、驚くほど世界が単純になります。
うれしいことに、最小例はたいていテストの中でそのまま作れます。ファイルからの複雑な入力があったなら、文字列定数に置き換える。大量のタスクを読み込んでいたなら、2つだけ残す。関数呼び出しの長い鎖があったなら、本当に壊れている関数だけを呼び、最小限の入力だけを渡す。
これは「世界を圧縮する」プロセスだと考えると分かりやすいです。流れはだいたいこんな感じです:
flowchart TD
A[大きなシナリオでバグが再現する] --> B[テストで再現を固定する]
B --> C[不要なものを削る: 入力、データ、手順]
C --> D{まだバグはあるか?}
D -- はい --> E[さらに不要なものを削る]
D -- いいえ --> F[最後に削った部分を戻す]
E --> D
F --> G[最小再現例の完成]
ここに「秘密の手順」はありません。ただの規律です。削る → 確認する → 削る → 確認する。
そして、はい、聞こえ方としては地味です。でも 500 行の代わりに 12 行だけが見えて、それでもなおバグが再現する瞬間には、ほとんど身体的なくらいの安堵を覚えます。頭の中の常時鳴っていたノイズを、誰かが突然消してくれたような感覚です。
5. 実践シナリオ: 1つのサブテストで、クラッシュから修正まで
ここからは、これまでの話を1つの連続した流れにまとめます。実際の開発で起きる形にできるだけ近づけます。大事なのはコマンドを知ることだけではなく、「実行範囲を狭める → シグナルを得る → 入力を絞る → 原因を見つける」というリズムに慣れることです。
全体を実行して最初のシグナルを得る
まずはいつも通りテストを実行します:
go test ./...
テストは落ちます。落ち方が panic ならスタックトレースが出て、「自分のコードに属する最初の行を探す」というスキルを使います。落ち方が t.Fatalf なら、メッセージが見えます。
この例では、tasks[id] が id=999 のとき範囲外アクセスになるので、panic: runtime error: index out of range になる可能性が非常に高いでしょう。
1つのテストまで絞る
全部を回さないために:
go test -run ^TestFindByID$ -v -count=1 ./...
これで、どのサブテストが開始され、どこで落ちたのかが見えるようになります。
さらに絞る: サブテストを1つだけ
落ちているのが not_found なら、それだけを相手にします:
go test -run TestFindByID/not_found -v -count=1 ./...
この段階では、ほとんど理想的な「サンドボックス」が手に入っています。コードを変え、再実行し、数秒でフィードバックを受け取れます。
入力を最小まで圧縮する
もしタスクが何十件もあったなら、2件だけ残すでしょう(テストのように)。複雑な生成処理があったなら、リテラルに置き換えるでしょう。私たちはすでに、ほぼ最小形まで来ています。
そしてここで見えてきます。私たちは id をインデックスとして使ってしまっています。ということは、実装は ID フィールドで検索するものにしなければなりません。
実装を修正する
package task
func FindByID(tasks []Task, id int) (Task, bool) {
for _, t := range tasks {
if t.ID == id {
return t, true
}
}
return Task{}, false
}
その後でもう一度:
go test -run TestFindByID -v -count=1 ./...
テストがグリーンなら、「たぶん直ったはず」と信じたのではなく、自分で確かめたことになります。
おまけ: t.Helper でエラーが「正しい行」を指すようにする
テストが複雑になると、繰り返し書く検証を helper 関数へ切り出し始めます。すると少し苛立つ場面が出てきます。テストが失敗したとき、表示されるのが helper の中の行であって、helper を呼び出した場所ではないのです。
Go にはその解決策があります: t.Helper() です。
例:
package task
import "testing"
func mustFind(t *testing.T, tasks []Task, id int) Task {
t.Helper()
got, ok := FindByID(tasks, id)
if !ok {
t.Fatalf("task id=%d not found", id)
}
return got
}
これで mustFind が落ちたとき、go test は helper の内部ではなく、mustFind を呼んだ テスト側の行 を指してくれます。デバッグでは、これはかなり体感が良くなります。
6. テストのデバッグでよくある間違い
間違い1: 1つのテストだけ落ちているのに、プロジェクト全体を実行する。
一見すると無害です(「全部回しておけばいいでしょ」)。でも実際には、時間と注意力を支払っています。全体の実行を待っている間に、何を確かめたかったのかを忘れ、脳が勝手に別のことを始めます。たとえば不安になる、とか。デバッグ中は、-run で1つのテストまで絞り、変更のサイクルを素早く回すほうがよいです。
間違い2: テスト名を「締めず」に、余計なものまで実行してしまう。
-run TestFind は、思っているより広く一致することがあります。たとえば TestFindByID と TestFindByTitle の両方です。すると失敗を見て「これは自分のテストだ」と思い込むのに、実際には隣のテストだった、ということが起きます。正確に1つだけ欲しいなら、^...$ の習慣が神経をかなり節約してくれます。
間違い3: キャッシュの存在を知らずに、キャッシュと戦う。
コードを変えたのに、出力が前と同じに見えることがあります。よくある原因は、成功結果のキャッシュです。デバッグでは -count=1 を付けて、毎回の実行が「ちゃんと今のもの」であるようにしましょう。メモリの中から返ってきたものではなく。特に、出力、実行時間、環境依存の挙動をデバッグしているときに重要です。
間違い4: テストを最小再現ではなく「大きな物語」にしてしまう。
「今から本番のシナリオを丸ごと再現するテストを書くぞ」と考えたくなることがあります。結果として、テスト自体のほうがバグより複雑になります。正しい考え方は別です。デバッグ中のテストは、あなたの実験室です。まずは壊れる最小の入力と最小の呼び出しだけを残しましょう。大きなシナリオは、あとでいくらでも書けます。
間違い5: 診断用出力を大量に追加して、消し忘れる。
fmt.Printf は優秀なメスですが、生き方としてはよくありません。診断用の print を永久に残すと、テストは騒がしくなり、次の人(多くの場合、1週間後の自分)が何が本当に重要なのか分からなくなります。デバッグ中だけなら問題ありません。その後は、削除するか、本当に役立つ場所だけ t.Logf に置き換えましょう。
GO TO FULL VERSION