1. golden files はなぜ必要か
テストが CLI の出力を検証するとき、すぐに罠にはまりがちです。最初は "todo-cli dev\n" という 1 行だけで、すべて順調に見えます。ところが list コマンドが登場してタスク表を出力し、さらにフィルタ、ソート、列、改行の折り返しが追加され……気づけばテストの 40 行が wantOut の文字列リテラルになっています。 もはやこれはテストではなく、悲しい結末の短編小説です。
Golden files(「正解出力ファイル」とも呼ばれます)は、この問題をそのまま解決します。大きな期待値をテストコードの中に直接持たず、テストの隣にあるファイルへ保存するのです。 テストは got を生成し、ファイルから want を読み込み、比較します。 出力が意図的に変わったなら、go test -update で基準値を更新します。 偶然変わったなら、テストが回帰を検出してくれます。
重要な考え方があります。golden は「怠惰なテスト」ではありません。検証対象が本質的にテキストで大きい場合(表、help、複数行レポート)に、テストを読みやすくするための方法です。 Go では特に自然です。なぜなら、テストはもともとテキスト(stdout)を扱うことが多く、テストデータを個別ファイルで管理するのも簡単だからです。
2. golden をどこに置くか: testdata/
コードを書く前に、「プロジェクトの地理」を決めましょう。Go には非常に定着した慣習があります。 テスト専用で、公開 API やビルド成果物の一部にしたくないファイルはすべて testdata/ ディレクトリに置きます。
なぜ便利なのか:
- testdata/ という名前だけで、「これはテスト用データです。理由なく触らないでください」と分かる。
- ツールや開発者は、テストフィクスチャがそこにあることを期待します。
- testdata という名前は事実上の標準です。1 年後に見返したとき、自分に感謝したくなります。
同じ考え方は、Go エコシステムの実際のリポジトリでも見られます。テストデータの中では「input」と「want」(期待結果)を明確に分け、テストが読みにくい構造にならないよう、別ファイルで管理します。
ミニ図解: golden test の仕組み
コードの前に、流れをパイプラインとして思い浮かべると理解しやすくなります。別プロセスとして CLI を起動するのではなく、以前にやったように Run(...) を関数として呼び出します。そこへ「正解ファイル」を追加する、というイメージです。
flowchart TD
A["Test: prepare args + buffers out/errOut"] --> B["Run cli.Run(...)"]
B --> C["Get got = out.String()"]
C --> D["Read want from testdata/*.golden"]
D --> E{"got == want?"}
E -->|yes| F["PASS"]
E -->|no| G{"-update enabled?"}
G -->|yes| H["Overwrite golden file with new got"]
G -->|no| I["FAIL: print readable diagnostics"]
考え方は単純です。デフォルトでは比較し、明示的な要求があったときだけ基準値を上書きします。
3. golden ファイル用 helper: readGolden と writeGolden
ここでは実践的にいきます。golden ファイルの読み書きを小さな helper 関数に切り出しましょう。 そうすれば、テストは会計処理ではなくシナリオとして読めるようになります。os.ReadFile の細かい処理を追いかける必要はありません。
golden ファイルを読む
見出しは地味ですが、ここで人生の何時間も節約できます。 良い helper は 2 つのことをします。1 つ目はファイル操作の「雑音」を隠すこと。2 つ目は t.Fatalf を使って、テストが分かりやすく素早く失敗するようにすることです。
package cli
import (
"os"
"path/filepath"
"testing"
)
func readGolden(t *testing.T, name string) string {
t.Helper()
path := filepath.Join("testdata", name)
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden %s: %v", path, err)
}
return string(b)
}
t.Helper() に注目してください。これのおかげでテストが落ちたとき、Go は readGolden の内部ではなく、テスト側の行を指してくれます。小さな違いですが、デバッグがかなり楽になります。特に helper が複数ある場合には効果的です。
golden ファイルを書く
書き込みが必要なのは更新モードだけです。ここで「テストが常にファイルを書き換える」状態に落ちないことが重要です。そうしないと、回帰に気づけなくなります。 「まあテストは緑だし」と思った瞬間に、それは危険です。
package cli
import (
"os"
"path/filepath"
"testing"
)
func writeGolden(t *testing.T, name string, data string) {
t.Helper()
path := filepath.Join("testdata", name)
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatalf("write golden %s: %v", path, err)
}
}
0o644 の権限はテキストファイルの標準的なデフォルトです。Windows ではこれは普段の Unix 的な「権限管理」そのものではありませんが、このコースではコードがクロスプラットフォームで動けば十分です。
4. -update フラグと基準値の更新
ここが核心です。更新をどうやって管理し、安全にするか。
やりたいことは次の 2 つです:
- デフォルトでは、got を基準値 want と比較する。
- go test -update で実行した場合、出力が変わっていれば golden ファイルを上書きする。
_test.go でフラグを宣言する
フラグはテストファイル(たとえば golden_test.go)に、しかもパッケージレベルで宣言します。そうすればテストランナーが拾ってくれます。
package cli
import "flag"
var update = flag.Bool("update", false, "update golden files")
ここで大事なのは、通常、テストで flag.Parse() を手動で呼ばない という点です。 これはテストランナー go test が呼びます。 自分で flag.Parse() を呼ぶと、go test 自身のフラグとの衝突や、並列パッケージでの予想外の動作など、奇妙な問題を招くことがあります。要するに、「なぜ CI でだけ壊れて、ローカルでは動くのか?」という古典的な状況です。
_test.go のフラグがテストバイナリ全体のフラグ集合の一部になるのは、Go が特別なテスト構造をどう扱うかという点と論理的に似ています。 それらは _test.go に置かれ、テスト基盤によって自動的に拾われます。 たとえば example テストも _test.go に配置され、go test によってパッケージの一部として実行されます。
更新の実行方法
たとえば list の出力形式を変えたとします(列を 1 つ追加した、など)。その場合は次のように実行します。
go test ./... -run TestRun_List_Golden -update
または、単に:
go test ./... -update
(どれだけ変更が意図的だと確信しているかによって使い分けます)。
-update は「snapshot を上書きするモード」だと頭の中で捉えてください。これは、出力形式を意図的に変えるときだけ有効にすべきです。
5. CLI の例: list コマンド
では、実際のシナリオとしてまとめましょう。学習用アプリ todo-cli を続けます。 ここでは list コマンドがタスクを表示するとします。 このコマンドの stdout を golden でテストし、stderr と exit code は、普通の CLI 契約として別に明示的に確認します。
タスクのミニモデルと一覧レンダリング
例を自己完結かつ短くするため、レンダリングを別関数にします。これはテストの観点でも設計の観点でも良いことです。出力の整形は独立した責務だからです。
package cli
import (
"fmt"
"io"
)
type Task struct {
ID int
Title string
Done bool
}
func renderTasks(out io.Writer, tasks []Task) {
fmt.Fprintln(out, "ID TITLE DONE")
for _, t := range tasks {
fmt.Fprintf(out, "%-3d %-10s %v\n", t.ID, t.Title, t.Done)
}
}
これは本番アプリで見るような理想的な表ではありませんが(後で text/tabwriter を学びます)、golden の考え方を理解するにはむしろ役立ちます。魔法は最小限、見通しは最大です。
Run の中の list コマンド
ここでは Run がすでにコマンドを解析して結果を表示するものだと仮定します。最小限で書くと次のようになります。
package cli
import (
"fmt"
"io"
)
func Run(args []string, in io.Reader, out, errOut io.Writer) int {
_ = in
if len(args) == 0 {
fmt.Fprintln(errOut, "missing command")
return exitUsage
}
switch args[0] {
case "list":
tasks := []Task{
{ID: 1, Title: "milk", Done: false},
{ID: 2, Title: "code", Done: true},
}
renderTasks(out, tasks)
return exitOK
default:
fmt.Fprintf(errOut, "unknown command: %s\n", args[0])
return exitUsage
}
}
実際のアプリではタスク一覧はもちろん保存先から取得します。しかし今日重要なのは別のことです。どうやって出力をテストするかです。ファイル分離や t.TempDir() については後で別途扱います。今は golden に集中するため、例を「メモリ上」に置いています。
6. list の golden テスト
ではテストを書きます。ここで重要なのは 3 点です。exit code を検証し、stderr を別に確認し(良いシナリオでは空です)、最後に stdout を golden で比較します。
package cli
import (
"bytes"
"strings"
"testing"
)
func TestRun_List_Golden(t *testing.T) {
var out bytes.Buffer
var errOut bytes.Buffer
code := Run([]string{"list"}, strings.NewReader(""), &out, &errOut)
if code != exitOK {
t.Fatalf("code=%d, want %d", code, exitOK)
}
if gotErr := errOut.String(); gotErr != "" {
t.Fatalf("stderr=%q, want empty", gotErr)
}
got := out.String()
if *update {
writeGolden(t, "list.golden", got)
}
want := readGolden(t, "list.golden")
if got != want {
t.Fatalf("stdout mismatch:\n--- got ---\n%s--- want ---\n%s", got, want)
}
}
メッセージはできるだけ「人間に優しく」しています。両方のテキストを区切り付きで表示します。 もちろん「賢い diff」ではありませんが、目で差分を見つけるには十分役立ちます。
testdata/list.golden と末尾の改行
cli/testdata/list.golden ファイルを作成し(パスはプロジェクト構造によって異なります)、期待する出力を入れてください。
ID TITLE DONE
1 milk false
2 code true
そして重要な細部がもう 1 つあります。末尾の改行です。 この renderTasks では、各行の終わりに \n が入ります。 したがって golden ファイルも通常は改行で終わるべきです。そうでないと、いつまでも消えない「微妙な差分」が発生します。これは本当のバグよりもイライラします。
7. 役立つポイントと golden を選ぶ基準
何でもかんでも golden テストしたくなるものです。これは自然な感覚で、多くの人がそうなります。しかし、現実的に考えることが大切です。golden は道具であって、宗教ではありません。
golden とテスト内期待値の比較
小さな表で比較してみましょう:
| 状況 | 何が良いか | 理由 |
|---|---|---|
| 期待する文字列が短い ("missing command\n") | テスト内で want := "..." | テストが一瞬で読める。ファイルはむしろ邪魔になる |
| 大きな複数行の出力(表、help、レポート) | golden ファイル | そうしないとテストが「長い紙」のようになる |
| 出力が不安定さのせいで頻繁に変わる(時刻、map のランダムな順序) | まず出力を決定的にする | Golden は非決定性を治しません。隠してしまい、かえって厄介になります |
出力が map の順序や現在時刻、絶対パスに依存していると、golden は「揺れ」始めます。 その場合は、出力を安定化させるか、その箇所には golden を使わないようにします。
なぜ go test がこれを支えるのか
Go のテスト基盤は、テキスト出力の検証をよく行います。最も単純な例は example テストです。見た目はドキュメントのようですが、実際には実行され、go test は実際の stdout を // Output: ブロックと比較します。
Golden files はその考え方と同じで、コメント // Output: の代わりに、期待するテキストを別ファイルに置く形です。
結論として、stdout を比較するのは Go では普通のやり方です。ただし大きなテキストでは、期待値をコードの中ではなくファイルに置く方が便利です。
8. golden files を扱うときの典型的な失敗
失敗 1: golden が常に更新される。「その方が楽だから」。
テストのたびにファイルを書き換えると、テストは飾りになります。回帰を検出しなくなるからです。何か壊れてもそれがそのまま基準値に書き込まれ、テストは緑のままになります。 正しい運用は、デフォルトでは比較し、-update のときだけ書き換えることです。
失敗 2: テストで手動で flag.Parse() を呼ぶ。
通常のプログラムに慣れていると自然に見えますが、テストでは問題を引き起こしがちです。 テストバイナリのフラグはすでに go test が管理しており、_test.go に宣言されたフラグはテストランナーが自動で拾います。 多くのプロジェクトでは、テスト内の手動 flag.Parse() は利点よりも害の方が大きいです。
失敗 3: 非決定的な出力に golden を使う。
コマンドが time.Now() で時刻を出したり、map の要素をランダム順で出力したりすると、ロジックが変わっていなくても golden は「壊れます」。 その結果、「じゃあ -update で更新して忘れよう」という習慣になり、誰も信じないテストへの道に入ります。 まず出力を安定化させ、それから golden を使いましょう。
失敗 4: stdout と stderr を 1 つの golden に混ぜる。
CLI には 2 つのストリームがあり、意味が異なります。stdout はユーザーやスクリプト向けの結果、stderr はエラー/usage/診断情報です。 それを 1 つのファイルにまとめると、契約の重要な部分を失い、テストの精度も落ちます。 stdout は golden 化し、stderr は短い文字列や Contains で別に検証する方がよいです。
失敗 5: 末尾の改行を忘れて「見えない」差分に悩まされる。
よくある話です。golden ファイルに最後の \n がない一方で、プログラムはそれを出力している(または逆)というケースです。 見た目は「ほとんど同じ」でも、テストは落ちます。 対策は規律です。CLI の出力は必ず改行で終わる、と決めて、それを基準値にも反映させましょう。
GO TO FULL VERSION