Goのテストとは、xxx_test.go というファイルに func TestXxx(t *testing.T) を書き、go test で実行する標準の仕組みで、外部ライブラリなしでテーブル駆動テスト・HTTPのテスト・ベンチマーク・ファズテストまで書けます。 標準パッケージの testing と net/http/httptest だけで、ほとんどのテストがまかなえます。

この記事では、在庫(備品)を扱う架空の小さなパッケージを題材に、Go 1.27.1 で実際に go test -v・-cover・-run・-bench・-fuzz を実行した出力をすべて載せています。テーブル駆動テストと t.Parallel(Go 1.22 以降はループ変数のコピーが不要)、httptest.NewRecorder と httptest.NewServer の使い分け、Go 1.24 以降の b.Loop と t.Context、カバレッジHTMLの作り方、testify などの外部ライブラリを使うかの判断までを1本にまとめました。

Goのテストの早見表

ファイル・関数の決まりごと

種類 関数の形 実行コマンド 用途
テストファイル xxx_test.go go test 通常のビルドには含まれない
テスト func TestXxx(t *testing.T) go test -v 正しさの確認
サブテスト t.Run("名前", func(t *testing.T){...}) go test -run 'TestXxx/名前' ケースごとに名前を付ける
ベンチマーク func BenchmarkXxx(b *testing.B) go test -bench . 速度・メモリ割り当ての計測
Example func ExampleXxx() + // Output: go test ドキュメント兼テスト
ファズテスト func FuzzXxx(f *testing.F) go test -fuzz FuzzXxx ランダム入力で性質を確認
テスト用データ testdata/ ディレクトリ ― go ツールがパッケージとして扱わない

testing.T のよく使うメソッド

メソッド 意味 使いどころ
t.Error / t.Errorf 失敗を記録して続行 複数の値を比べたいとき
t.Fatal / t.Fatalf 失敗を記録してそのテストを終了 前提が崩れて先に進めないとき(エラーが nil でない等)
t.Log / t.Logf ログ出力(-v か失敗時に表示) 途中経過の確認
t.Run サブテストを作る テーブル駆動テスト
t.Parallel 並列実行の対象にする 独立したテストを速くする
t.Helper 失敗行を呼び出し元の行にする 自作の assert 関数
t.Cleanup テスト終了時の後片付けを登録 サーバー停止、ファイル削除
t.TempDir 自動で消える一時ディレクトリ ファイル入出力のテスト
t.Setenv 環境変数を設定し、終了時に戻す 設定値のテスト(t.Parallel とは併用不可)
t.Context テスト終了時にキャンセルされる Context(Go 1.24〜) context.Background() の代わり
t.Chdir カレントディレクトリを変更し、終了時に戻す(Go 1.24〜) 相対パスを使う処理
t.Skip テストをスキップ 環境が整っていないとき
t.ArtifactDir 出力ファイル用ディレクトリ(Go 1.26〜) -artifacts と組み合わせて成果物を残す

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Go テストの書き方|テーブル駆動・httptestのコード例」

動作確認した環境

項目 内容
Go go1.27.1 linux/amd64
外部モジュール なし(標準ライブラリのみ)
モジュール名 example.com/stock(架空)

以下の実行結果は、すべてこの環境で実際に出力したものです。

ファイル構成は次のとおりです。

stock/
├── go.mod
├── stock.go               テスト対象のコード
├── stock_test.go          テスト本体(テーブル駆動・httptest・testdata など)
├── example_test.go        Example テスト
├── bench_fuzz_test.go     ベンチマークとファズテスト
└── testdata/
    └── item_c1.golden.json

テスト対象のコード

まずテストされる側のコードです。数量の文字列を数値にする関数、商品コードを整える関数、GET /items/{id} を返す HTTP ハンドラ、その API を呼ぶクライアントを用意しました。

// go.mod
module example.com/stock

go 1.27
// stock.go
// Package stock は在庫(備品)を扱う小さなサンプルです。
package stock

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "net/http"
    "strconv"
    "strings"
)

// ErrInvalidQuantity は数量が不正なときのエラーです。
var ErrInvalidQuantity = errors.New("invalid quantity")

// ParseQuantity は "12" や " 3個 " のような文字列を数量に変換します。
func ParseQuantity(s string) (int, error) {
    s = strings.TrimSpace(s)
    s = strings.TrimSuffix(s, "個")
    n, err := strconv.Atoi(s)
    if err != nil {
        return 0, fmt.Errorf("%w: %q", ErrInvalidQuantity, s)
    }
    if n < 0 {
        return 0, fmt.Errorf("%w: negative %d", ErrInvalidQuantity, n)
    }
    return n, nil
}

// NormalizeSKU は商品コードを大文字・ハイフン区切りに整えます。
func NormalizeSKU(s string) string {
    s = strings.ToUpper(strings.TrimSpace(s))
    s = strings.ReplaceAll(s, "_", "-")
    return s
}

// Item は備品1件です。
type Item struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Stock int    `json:"stock"`
}

// Store は在庫データの置き場所です。
type Store struct{ items map[string]Item }

// NewStore は Store を作ります。
func NewStore(items ...Item) *Store {
    m := map[string]Item{}
    for _, it := range items {
        m[it.ID] = it
    }
    return &Store{items: m}
}

// Handler は GET /items/{id} を処理する http.Handler を返します。
func (s *Store) Handler() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
        it, ok := s.items[r.PathValue("id")]
        if !ok {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(it)
    })
    return mux
}

// Client は在庫 API のクライアントです。
type Client struct {
    BaseURL    string
    HTTPClient *http.Client
}

// GetItem は baseURL/items/{id} を呼び出して Item を返します。
func (c *Client) GetItem(ctx context.Context, id string) (Item, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/items/"+id, nil)
    if err != nil {
        return Item{}, err
    }
    res, err := c.HTTPClient.Do(req)
    if err != nil {
        return Item{}, err
    }
    defer res.Body.Close()
    if res.StatusCode != http.StatusOK {
        return Item{}, fmt.Errorf("unexpected status: %d", res.StatusCode)
    }
    var it Item
    if err := json.NewDecoder(res.Body).Decode(&it); err != nil {
        return Item{}, err
    }
    return it, nil
}

テスト本体(コピペで動く完成形)

stock_test.go です。いちばん小さいテスト、テーブル駆動テスト、t.Helper、httptest の2種類、testdata、TempDir と Setenv を1ファイルにまとめています。

// stock_test.go
package stock

import (
    "encoding/json"
    "errors"
    "net/http"
    "net/http/httptest"
    "os"
    "path/filepath"
    "strings"
    "testing"
)

// 1. いちばん小さいテスト
func TestNormalizeSKU(t *testing.T) {
    got := NormalizeSKU(" pen_01 ")
    want := "PEN-01"
    if got != want {
        t.Errorf("NormalizeSKU() = %q, want %q", got, want)
    }
}

// 2. テーブル駆動テスト + t.Run + t.Parallel
func TestParseQuantity(t *testing.T) {
    t.Parallel()
    tests := []struct {
        name    string
        in      string
        want    int
        wantErr error
    }{
        {name: "数字のみ", in: "12", want: 12},
        {name: "前後の空白", in: "  7 ", want: 7},
        {name: "個つき", in: "3個", want: 3},
        {name: "ゼロ", in: "0", want: 0},
        {name: "負の数", in: "-1", wantErr: ErrInvalidQuantity},
        {name: "文字", in: "abc", wantErr: ErrInvalidQuantity},
        {name: "空文字", in: "", wantErr: ErrInvalidQuantity},
    }
    for _, tt := range tests { // Go 1.22 以降は tt := tt が不要
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            got, err := ParseQuantity(tt.in)
            if !errors.Is(err, tt.wantErr) {
                t.Fatalf("ParseQuantity(%q) error = %v, want %v", tt.in, err, tt.wantErr)
            }
            if got != tt.want {
                t.Errorf("ParseQuantity(%q) = %d, want %d", tt.in, got, tt.want)
            }
        })
    }
}

// 3. t.Helper:失敗行を呼び出し元にする
func assertStatus(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("status = %d, want %d", got, want)
    }
}

// 4. httptest.NewRecorder:ハンドラ単体テスト
func TestHandler(t *testing.T) {
    store := NewStore(Item{ID: "p1", Name: "ペン", Stock: 30})
    h := store.Handler()

    tests := []struct {
        name       string
        path       string
        wantStatus int
        wantBody   string
    }{
        {"存在するID", "/items/p1", http.StatusOK, `"name":"ペン"`},
        {"存在しないID", "/items/zz", http.StatusNotFound, "not found"},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(http.MethodGet, tt.path, nil)
            rec := httptest.NewRecorder()
            h.ServeHTTP(rec, req)
            assertStatus(t, rec.Code, tt.wantStatus)
            if !strings.Contains(rec.Body.String(), tt.wantBody) {
                t.Errorf("body = %q, want contains %q", rec.Body.String(), tt.wantBody)
            }
        })
    }
}

// 5. httptest.NewServer:HTTP クライアント側のテスト
func TestClientGetItem(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/items/n1" {
            http.NotFound(w, r)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"id":"n1","name":"ノート","stock":5}`))
    }))
    t.Cleanup(srv.Close) // テスト終了時にサーバーを止める

    c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()}
    got, err := c.GetItem(t.Context(), "n1") // Go 1.24 以降:テスト終了でキャンセルされる Context
    if err != nil {
        t.Fatalf("GetItem() error = %v", err)
    }
    want := Item{ID: "n1", Name: "ノート", Stock: 5}
    if got != want {
        t.Errorf("GetItem() = %+v, want %+v", got, want)
    }

    if _, err := c.GetItem(t.Context(), "xx"); err == nil {
        t.Error("GetItem(xx) error = nil, want error")
    }
}

// 6. testdata のゴールデンファイルと比較
func TestItemJSONGolden(t *testing.T) {
    it := Item{ID: "c1", Name: "クリップ", Stock: 120}
    got, err := json.MarshalIndent(it, "", "  ")
    if err != nil {
        t.Fatal(err)
    }
    want, err := os.ReadFile(filepath.Join("testdata", "item_c1.golden.json"))
    if err != nil {
        t.Fatal(err)
    }
    if strings.TrimSpace(string(got)) != strings.TrimSpace(string(want)) {
        t.Errorf("JSON mismatch\n got: %s\nwant: %s", got, want)
    }
}

// 7. TempDir と Setenv
func TestTempDirAndSetenv(t *testing.T) {
    dir := t.TempDir() // テスト終了時に自動で削除される
    path := filepath.Join(dir, "stock.txt")
    if err := os.WriteFile(path, []byte("12個"), 0o644); err != nil {
        t.Fatal(err)
    }
    b, _ := os.ReadFile(path)
    n, err := ParseQuantity(string(b))
    if err != nil || n != 12 {
        t.Fatalf("got %d, %v", n, err)
    }

    t.Setenv("STOCK_MODE", "test") // テスト終了時に元の値へ戻る
    if os.Getenv("STOCK_MODE") != "test" {
        t.Error("Setenv が反映されていません")
    }
    t.Logf("TempDir=%s", filepath.Base(dir))
}

testdata/item_c1.golden.json の中身です。

{
  "id": "c1",
  "name": "クリップ",
  "stock": 120
}

go test -v の実行結果

go test -v ./...

実行結果(go1.27.1。後述の Example・ファズのシードも含む):

=== RUN   TestNormalizeSKU
--- PASS: TestNormalizeSKU (0.00s)
=== RUN   TestParseQuantity
=== PAUSE TestParseQuantity
=== RUN   TestHandler
=== RUN   TestHandler/存在するID
=== RUN   TestHandler/存在しないID
--- PASS: TestHandler (0.00s)
    --- PASS: TestHandler/存在するID (0.00s)
    --- PASS: TestHandler/存在しないID (0.00s)
=== RUN   TestClientGetItem
--- PASS: TestClientGetItem (0.00s)
=== RUN   TestItemJSONGolden
--- PASS: TestItemJSONGolden (0.00s)
=== RUN   TestTempDirAndSetenv
    stock_test.go:149: TempDir=001
--- PASS: TestTempDirAndSetenv (0.00s)
=== CONT  TestParseQuantity
=== RUN   TestParseQuantity/数字のみ
=== PAUSE TestParseQuantity/数字のみ
=== RUN   TestParseQuantity/前後の空白
=== PAUSE TestParseQuantity/前後の空白
=== RUN   TestParseQuantity/個つき
=== PAUSE TestParseQuantity/個つき
=== RUN   TestParseQuantity/ゼロ
=== PAUSE TestParseQuantity/ゼロ
=== RUN   TestParseQuantity/負の数
=== PAUSE TestParseQuantity/負の数
=== RUN   TestParseQuantity/文字
=== PAUSE TestParseQuantity/文字
=== RUN   TestParseQuantity/空文字
=== PAUSE TestParseQuantity/空文字
=== CONT  TestParseQuantity/数字のみ
=== CONT  TestParseQuantity/空文字
=== CONT  TestParseQuantity/文字
=== CONT  TestParseQuantity/負の数
=== CONT  TestParseQuantity/ゼロ
=== CONT  TestParseQuantity/個つき
=== CONT  TestParseQuantity/前後の空白
--- PASS: TestParseQuantity (0.00s)
    --- PASS: TestParseQuantity/数字のみ (0.00s)
    --- PASS: TestParseQuantity/空文字 (0.00s)
    --- PASS: TestParseQuantity/文字 (0.00s)
    --- PASS: TestParseQuantity/負の数 (0.00s)
    --- PASS: TestParseQuantity/ゼロ (0.00s)
    --- PASS: TestParseQuantity/個つき (0.00s)
    --- PASS: TestParseQuantity/前後の空白 (0.00s)
=== RUN   FuzzNormalizeSKU
=== RUN   FuzzNormalizeSKU/seed#0
=== RUN   FuzzNormalizeSKU/seed#1
--- PASS: FuzzNormalizeSKU (0.00s)
    --- PASS: FuzzNormalizeSKU/seed#0 (0.00s)
    --- PASS: FuzzNormalizeSKU/seed#1 (0.00s)
=== RUN   ExampleNormalizeSKU
--- PASS: ExampleNormalizeSKU (0.00s)
=== RUN   ExampleParseQuantity
--- PASS: ExampleParseQuantity (0.00s)
PASS
ok      example.com/stock   0.008s

出力の読み方:

  • === PAUSE → === CONT:t.Parallel() を呼んだテストはいったん止まり、並列でないテストがすべて終わってから再開(CONT)します。
  • サブテストの順番がバラバラ:並列実行なので、完了した順に表示されます。
  • stock_test.go:149: TempDir=001:t.Logf の出力は -v を付けたときか、失敗したときに表示されます。
  • ファズテストもシード入力(f.Add)だけは通常の go test で毎回実行されます。

テーブル駆動テストの書き方

テーブル駆動テストとは、入力と期待値の組を構造体のスライス(表)に並べ、ループで同じ検証を回す書き方です。 Go の標準ライブラリでも広く使われています。

書き方のポイント

ポイント 理由
name フィールドを持たせ t.Run(tt.name, ...) で回す 失敗したケースが名前でわかり、-run で1件だけ実行できる
期待するエラーは wantErr error にして errors.Is で比べる エラーの種類まで確認できる
前提が崩れたら t.Fatalf、値の比較は t.Errorf Fatal はそのサブテストだけを終了し、他のケースは続く
メッセージは 関数(入力) = 実際, want 期待 の形 Go の慣習で、ログを見ただけで原因がわかる

失敗したときの出力

わざと期待値を間違えたケースを入れて実行すると、次のように表示されます(実際の出力)。

{"正しい期待値", "5", 5},
{"わざと間違えた期待値", "8個", 80},
--- FAIL: TestFailDemo (0.00s)
    --- FAIL: TestFailDemo/わざと間違えた期待値 (0.00s)
        fail_test.go:18: ParseQuantity("8個") = 8, want 80
--- FAIL: TestHelperDemo (0.00s)
    fail_test.go:25: status = 404, want 200
FAIL
FAIL    example.com/stock   0.004s
FAIL

失敗したサブテストの名前・入力・実際の値・期待値が1行でわかります。 下の TestHelperDemo は assertStatus(t, 404, 200) を呼んだだけのテストですが、t.Helper() のおかげで、assert 関数の中ではなく呼び出し元(fail_test.go:25)の行が表示されています。

t.Parallel とループ変数(Go 1.22 以降)

for _, tt := range tests {
    tt := tt // Go 1.21 までは必要だった
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

Go 1.22 でループ変数がイテレーションごとに新しく作られるようになったため、tt := tt は不要になりました。 この挙動は go.mod の go ディレクティブが 1.22 以上のモジュールで有効です。本記事のコードは go 1.27 なので、コピーなしで並列実行しても各ケースが正しい値を使っていることを上の実行結果で確認できます。

t.Parallel を付けるかの判断

状況 t.Parallel 理由
純粋な関数(入力→出力のみ) 付ける 互いに影響しないので速くなる
t.Setenv や t.Chdir を使う 付けない(併用するとパニック) プロセス全体の状態を変えるため
共有の変数・ファイルを書き換える 付けない/分離してから付ける 結果が実行順に左右される
1件あたりが一瞬で終わる小さなテスト どちらでもよい 効果は小さいが害もない

httptest:NewRecorder と NewServer の使い分け

項目 httptest.NewRecorder httptest.NewServer
テストする対象 サーバー側のハンドラ HTTP クライアント側のコード
ネットワーク 使わない(関数呼び出し) ローカルの実ポートで待ち受け
リクエストの作り方 httptest.NewRequest(...) 本物の http.Client で送る
結果の取り方 rec.Code・rec.Body・rec.Header() クライアントの戻り値
後片付け 不要 srv.Close()(t.Cleanup で登録)
速さ とても速い 速い(実通信のぶん少し重い)
TLS ― httptest.NewTLSServer と srv.Client() で可

NewRecorder:ハンドラ単体のテスト

req := httptest.NewRequest(http.MethodGet, "/items/p1", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
// rec.Code == 200, rec.Body に JSON

ServeHTTP を直接呼ぶので、サーバーを立てずにステータスコード・本文・ヘッダーを確認できます。 Go 1.22 のパターン付きルーティング("GET /items/{id}" と r.PathValue("id"))も、ServeMux 経由で呼べばそのまま動きます(上の TestHandler で確認)。

NewServer:クライアント側のテスト

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte(`{"id":"n1","name":"ノート","stock":5}`))
}))
t.Cleanup(srv.Close)
c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()}

外部 API を呼ぶコードは、本物の API の代わりに httptest.NewServer で偽のサーバーを立て、その URL(srv.URL)をクライアントに渡します。 ベース URL と *http.Client を外から差し替えられる設計にしておくと、テストが書きやすくなります。

Example テスト

// example_test.go
package stock_test

import (
    "fmt"

    "example.com/stock"
)

func ExampleNormalizeSKU() {
    fmt.Println(stock.NormalizeSKU(" pen_01 "))
    // Output: PEN-01
}

func ExampleParseQuantity() {
    n, err := stock.ParseQuantity("3個")
    fmt.Println(n, err)
    _, err = stock.ParseQuantity("abc")
    fmt.Println(err)
    // Output:
    // 3 <nil>
    // invalid quantity: "abc"
}

// Output: コメントと標準出力が一致すれば PASS になり、同じコードが go doc やpkg.go.dev の使用例として表示されます。 パッケージ名を stock_test にすると、外部の利用者と同じく公開 API だけを使ったテストになります。順序が決まらない出力(map のループなど)には // Unordered output: を使います。

ベンチマーク(b.Loop)

// bench_fuzz_test.go(前半)
package stock

import (
    "strings"
    "testing"
)

// ベンチマーク(Go 1.24 以降の b.Loop)
func BenchmarkParseQuantity(b *testing.B) {
    for b.Loop() {
        ParseQuantity(" 123個 ")
    }
}

func BenchmarkNormalizeSKU(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        NormalizeSKU(" pen_01 ")
    }
}
go test -run '^$' -bench . -benchmem

実行結果(go1.27.1。数値は環境によって変わります):

goos: linux
goarch: amd64
pkg: example.com/stock
cpu: Intel(R) Xeon(R) Processor @ 2.10GHz
BenchmarkParseQuantity-2    34258078            37.72 ns/op        0 B/op          0 allocs/op
BenchmarkNormalizeSKU-2     10145930           130.6 ns/op        16 B/op          2 allocs/op
PASS
ok      example.com/stock   2.634s
列 意味
-2 使った CPU 数(GOMAXPROCS)
34258078 実行回数
37.72 ns/op 1回あたりの時間
0 B/op / 0 allocs/op 1回あたりのメモリ割り当て量と回数(-benchmem か b.ReportAllocs())

for b.Loop() は Go 1.24 で追加された書き方で、従来の for i := 0; i < b.N; i++ と違い、準備処理の時間が自動で計測から外れ、ループ内の結果が最適化で消されにくくなっています。 Go 1.26 では、ループ本体のインライン化を妨げないよう改善されました。-run '^$' は「通常のテストを1つも実行しない」ための指定です。

ファズテスト

// bench_fuzz_test.go(後半)
// ファズテスト:どんな入力でも守られる性質を確かめる
func FuzzNormalizeSKU(f *testing.F) {
    f.Add("pen_01")
    f.Add(" note-a5 ")
    f.Fuzz(func(t *testing.T, s string) {
        got := NormalizeSKU(s)
        if strings.Contains(got, "_") {
            t.Errorf("NormalizeSKU(%q) = %q に _ が残っています", s, got)
        }
        if NormalizeSKU(got) != got {
            t.Errorf("2回目で結果が変わりました: %q -> %q", got, NormalizeSKU(got))
        }
    })
}
go test -run '^$' -fuzz FuzzNormalizeSKU -fuzztime 10s -parallel 2 .

実行結果(go1.27.1):

fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
fuzz: elapsed: 0s, gathering baseline coverage: 2/2 completed, now fuzzing with 2 workers
fuzz: elapsed: 3s, execs: 16796 (5598/sec), new interesting: 44 (total: 46)
fuzz: elapsed: 6s, execs: 61481 (14895/sec), new interesting: 71 (total: 73)
fuzz: elapsed: 9s, execs: 83127 (7199/sec), new interesting: 77 (total: 79)
fuzz: elapsed: 11s, execs: 83127 (0/sec), new interesting: 77 (total: 79)
PASS
ok      example.com/stock   11.019s

ファズテストは「期待値」ではなく「どんな入力でも成り立つ性質」を書くのがコツです。 ここでは「_ が残らない」「2回整えても結果が変わらない(冪等)」を確認しました。約8万回の入力で性質が保たれています。失敗する入力が見つかると testdata/fuzz/FuzzNormalizeSKU/ に保存され、以後は通常の go test で毎回その入力が再実行されます。-fuzz は1回に1つのパッケージ・1つのファズ関数だけ指定できます。

testdata ディレクトリの使い方

使い方 例
ゴールデンファイル(期待する出力)を置く testdata/item_c1.golden.json
入力ファイルを置く testdata/input.csv
ファズで見つかった入力が自動保存される testdata/fuzz/FuzzXxx/

testdata という名前のディレクトリは go ツールがパッケージとして扱わないため、テスト専用のファイルを自由に置けます。 go test はパッケージのディレクトリをカレントディレクトリにして実行するので、filepath.Join("testdata", "...") の相対パスで読めます。書き込みが必要なファイルは testdata ではなく t.TempDir() に作ると、終了時に自動で消えます。

go test のオプション一覧

オプション 意味 例
-v テストごとの結果とログを表示 go test -v ./...
-run 正規表現 名前が一致するテストだけ実行(/ でサブテスト) go test -run 'TestHandler/存在しない'
-skip 正規表現 一致するテストを除外 go test -skip TestClient
-count n n 回実行(-count=1 でキャッシュを使わない) go test -count=1 ./...
-cover カバレッジ率を表示 go test -cover ./...
-coverprofile=file カバレッジの詳細をファイルに出力 go test -coverprofile=cover.out
-bench 正規表現 ベンチマークを実行 go test -bench . -run '^$'
-benchmem メモリ割り当ても表示 go test -bench . -benchmem
-benchtime ベンチマークの時間や回数 -benchtime=3s、-benchtime=1000x
-fuzz 正規表現 ファズテストを実行 go test -fuzz FuzzNormalizeSKU
-fuzztime ファズの実行時間 -fuzztime 30s
-race データ競合を検出 go test -race ./...
-parallel n 並列実行するテストの最大数 -parallel 4
-timeout 全体の制限時間(既定 10 分) -timeout 30s
-short testing.Short() が true になる 重いテストを飛ばす
-failfast 最初の失敗で止める go test -failfast
-shuffle on テストの実行順をランダムにする 順序依存を見つける
-json 結果を JSON で出力 CI のレポート用
-artifacts t.ArtifactDir() の出力を残す(Go 1.26〜) -outputdir と併用

-run で1件だけ実行する

go test -v -run 'TestHandler/存在しない' .
=== RUN   TestHandler
=== RUN   TestHandler/存在しないID
--- PASS: TestHandler (0.00s)
    --- PASS: TestHandler/存在しないID (0.00s)
PASS
ok      example.com/stock   0.006s

-run は正規表現で、/ の前がトップレベルのテスト名、後ろがサブテスト名に対応します。 日本語のサブテスト名でも絞り込めます。

カバレッジの確認と HTML レポートの作り方

go test -cover .
ok      example.com/stock   0.011s  coverage: 91.9% of statements

関数ごとの内訳と HTML レポートは次の3コマンドで作れます。

go test -coverprofile=cover.out .
go tool cover -func=cover.out
go tool cover -html=cover.out -o cover.html

実行結果(go1.27.1):

ok      example.com/stock   0.007s  coverage: 91.9% of statements
example.com/stock/stock.go:18:  ParseQuantity   100.0%
example.com/stock/stock.go:32:  NormalizeSKU    100.0%
example.com/stock/stock.go:49:  NewStore    100.0%
example.com/stock/stock.go:58:  Handler     100.0%
example.com/stock/stock.go:79:  GetItem     76.9%
total:              (statements)    91.9%

cover.html をブラウザで開くと、実行された行が緑、実行されなかった行が赤で表示されます。 -o を付けずに go tool cover -html=cover.out とすると、ブラウザが直接開きます。今回は GetItem が 76.9% で、リクエスト作成の失敗や JSON の解析エラーの分岐が未通過だとわかります。カバレッジは「テストが通っていない場所を探す道具」として使い、数値そのものを目標にしすぎないのがおすすめです。

testify などの外部ライブラリを使うか

この検証環境では外部モジュールを取得できないため、以下の testify のコードは構文の紹介のみで、未実行です。

// 未実行:github.com/stretchr/testify を使う場合の書き方
import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestParseQuantityWithTestify(t *testing.T) {
    got, err := ParseQuantity("12")
    require.NoError(t, err)    // 失敗したらここで終了(t.Fatal 相当)
    assert.Equal(t, 12, got)   // 失敗しても続行(t.Error 相当)
}
観点 標準の testing だけ testify などを使う
依存 なし モジュールの追加が必要
書く量 if got != want { t.Errorf(...) } と少し長い assert.Equal(t, want, got) と短い
失敗メッセージ 自分で書く(自由度が高い) 差分つきで自動表示
構造体・スライスの比較 reflect.DeepEqual や slices.Equal / maps.Equal assert.Equal で差分表示
チームの慣れ Go の公式スタイルに沿う 他言語の assert に慣れた人が読みやすい

判断の目安: 標準ライブラリのみで始め、構造体の差分表示が欲しくなったら比較用ライブラリ(go-cmp の cmp.Diff など)、チームで assert スタイルに統一したいなら testify、と必要に応じて足すのがスムーズです。どちらを選んでも、テーブル駆動テスト・t.Run・httptest の考え方はそのまま使えます。

よくある質問

Q. t.Error と t.Fatal はどう使い分けますか? A. 後続の確認ができなくなる場合(エラーが nil でない、ファイルが読めないなど)は t.Fatal、複数の値をまとめて比べたい場合は t.Error です。t.Fatal は呼んだテスト(またはサブテスト)だけを終了し、他のサブテストは続けて実行されます。

Q. go test の結果が (cached) になって再実行されません。 A. ソースやテストが変わっていないと結果がキャッシュされます。外部の状態に依存するテストなどで毎回実行したいときは go test -count=1 を付けます。

Q. テストファイルのパッケージ名は stock と stock_test のどちらにしますか? A. 非公開の関数や変数もテストしたいなら同じ stock、公開 API だけを利用者の目線で確かめたいなら stock_test(外部テストパッケージ)です。1つのディレクトリに両方を置けます。本記事では Example を stock_test にしています。

Q. Go 1.23 以前の Go でも t.Context や b.Loop は使えますか? A. 使えません。t.Context・t.Chdir・b.Loop は Go 1.24、t.ArtifactDir は Go 1.26 からです。それより前の版では context.Background() と t.Cleanup、for i := 0; i < b.N; i++ で同じことを書きます。

まとめ

  • Goのテストは xxx_test.go に TestXxx(t *testing.T) を書いて go test で実行し、標準ライブラリだけで十分書けます
  • テーブル駆動テストは name 付きの表+t.Run、失敗時は名前・入力・期待値が1行でわかります
  • Go 1.22 以降は tt := tt 不要で、t.Parallel() をそのまま使えます(t.Setenv との併用は不可)
  • ハンドラ単体は httptest.NewRecorder、クライアント側は httptest.NewServer と使い分けます
  • ベンチマークは Go 1.24 以降 for b.Loop()、ファズは「どんな入力でも成り立つ性質」を書きます
  • go test -coverprofile と go tool cover -html でカバレッジを色分け表示し、未通過の分岐を探せます
  • 外部ライブラリは必要になってから足すのがスムーズで、考え方は標準の書き方と共通です

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Go テストの書き方|テーブル駆動・httptestのコード例」

株式会社RJCでは、Goのテストの書き方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?