Goのテスト設計、ちゃんと考えていますか?
Goを書き始めて最初に感動したのが、テストの仕組みが言語に組み込まれていることでした。go testコマンドひとつでテストが走る手軽さは、他の言語からGoに移ってきたエンジニアなら誰でも実感すると思います。
ただ、プロジェクトが大きくなってくると「この部分はどうやってテストすればいいんだろう」という場面が増えてきます。HTTPハンドラのテスト、外部APIに依存する処理のモック、データベースとの結合テスト……。Goの標準パッケージにはtestingやhttptestが用意されていますが、それぞれの使いどころを正しく理解しないと、メンテナンスしづらいテストコードが量産されがちです。
この記事では、Go 1.24時点でのtesting・httptest・モック手法を体系的に整理して、どの場面でどのアプローチを選ぶべきかを解説します。
この記事で扱う内容
testingパッケージの基本とテーブル駆動テストhttptestでHTTPハンドラをテストする方法- インターフェースを活用したモック設計
- テストカバレッジとベンチマーク
- テストしやすいコード設計のコツ
testingパッケージの基本
テスト関数の基本構造
Goのテストは_test.goファイルにTestプレフィックスの関数を書くだけで動きます。フレームワークのインストールもDSLの習得も不要です。
// calc.go
package calc
func Add(a, b int) int {
return a + b
}
// calc_test.go
package calc
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, want %d", got, want)
}
}
$ go test ./...
ok example/calc 0.002s
シンプルですが、テストケースが増えてくると同じパターンのコードがずらっと並んでしまいます。そこで登場するのがテーブル駆動テストです。
テーブル駆動テストで重複を減らす
テーブル駆動テスト(Table-Driven Test)はGo公式でも推奨されているパターンで、テストケースをスライスにまとめてループで回します。
まず、やりがちなアンチパターンから見てみましょう。
// アンチパターン: 同じ構造のテストをコピペで量産
func TestAddBad(t *testing.T) {
if Add(1, 2) != 3 {
t.Error("1+2 should be 3")
}
if Add(0, 0) != 0 {
t.Error("0+0 should be 0")
}
if Add(-1, 1) != 0 {
t.Error("-1+1 should be 0")
}
// ケースが増えるたびにコピペが増える...
}
このアプローチだと、最初のifでt.Fatalを使っていた場合に以降の検証が実行されません。テストケース同士が暗黙的に依存してしまう問題もあります。テーブル駆動テストなら各ケースが独立して実行されます。
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"正の数同士", 2, 3, 5},
{"ゼロとゼロ", 0, 0, 0},
{"負の数と正の数", -1, 1, 0},
{"大きな数", 100000, 200000, 300000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
$ go test -v ./...
=== RUN TestAdd
=== RUN TestAdd/正の数同士
=== RUN TestAdd/ゼロとゼロ
=== RUN TestAdd/負の数と正の数
=== RUN TestAdd/大きな数
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/正の数同士 (0.00s)
--- PASS: TestAdd/ゼロとゼロ (0.00s)
--- PASS: TestAdd/負の数と正の数 (0.00s)
--- PASS: TestAdd/大きな数 (0.00s)
PASS
t.Run()でサブテストとして実行することで、どのケースが失敗したかが一目でわかります。新しいケースを追加するときもスライスに1行足すだけです。
t.Helper()でエラー出力を見やすくする
テストのアサーション処理をヘルパー関数に切り出す場面があります。このときt.Helper()を呼んでおくと、エラー発生時にヘルパー関数ではなく呼び出し元の行番号が表示されます。
func assertEqual(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestAddWithHelper(t *testing.T) {
assertEqual(t, Add(2, 3), 5) // 失敗時、この行が報告される
assertEqual(t, Add(0, 0), 0)
}
地味な機能ですが、テスト失敗時のデバッグ効率がかなり変わります。調べてみたら、公式のtestingパッケージドキュメントでもヘルパー関数には必ずt.Helper()を付けるよう推奨されていました。
httptestでHTTPハンドラをテストする
httptest.NewRecorderでレスポンスを検証する
Go標準のnet/http/httptestパッケージを使うと、実際にサーバーを起動せずにHTTPハンドラの動作を検証できます。
package api
import (
"encoding/json"
"net/http"
)
type HealthResponse struct {
Status string `json:"status"`
}
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(HealthResponse{Status: "ok"})
}
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status code = %d, want %d", rec.Code, http.StatusOK)
}
var resp HealthResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("レスポンスのデコードに失敗: %v", err)
}
if resp.Status != "ok" {
t.Errorf("status = %q, want %q", resp.Status, "ok")
}
}
$ go test -v -run TestHealthHandler
=== RUN TestHealthHandler
--- PASS: TestHealthHandler (0.00s)
PASS
httptest.NewRecorder()はhttp.ResponseWriterを実装した構造体を返すので、ハンドラに直接渡せます。ステータスコード、ヘッダー、レスポンスボディをすべて検証可能です。
httptest.NewServerでエンドツーエンドのテストを書く
ルーティングやミドルウェアも含めたテストをしたい場合は、httptest.NewServer()でテスト用サーバーを立ち上げます。
func TestHealthEndpoint(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", HealthHandler)
srv := httptest.NewServer(mux)
defer srv.Close()
resp, err := http.Get(srv.URL + "/health")
if err != nil {
t.Fatalf("リクエスト失敗: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusOK)
}
}
$ go test -v -run TestHealthEndpoint
=== RUN TestHealthEndpoint
--- PASS: TestHealthEndpoint (0.00s)
PASS
NewRecorderとNewServerのどちらを使うかは、テストの目的によって変わります。
| 手法 | 特徴 | 向いている場面 |
|---|---|---|
| NewRecorder | サーバー不要で高速 | ハンドラ単体のロジック検証 |
| NewServer | 実際のHTTP通信を再現 | ルーティング・ミドルウェア込みの結合テスト |
基本的にはまずNewRecorderでハンドラ単体のテストを書き、ルーティングやミドルウェアの挙動を確認したい場合にNewServerを使う、という段階的なアプローチが効率的です。
モックの設計パターン
インターフェースを使ったモックの基本
外部サービスやデータベースに依存する処理をテストする際、モックは有効な手段です。Goではインターフェースを定義しておくことで、テスト時にモック実装を差し替えられます。
// repository.go
package user
type User struct {
ID int
Name string
}
type UserRepository interface {
FindByID(id int) (*User, error)
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUserName(id int) (string, error) {
u, err := s.repo.FindByID(id)
if err != nil {
return "", err
}
return u.Name, nil
}
// repository_test.go
package user
import (
"errors"
"testing"
)
type mockUserRepo struct {
user *User
err error
}
func (m *mockUserRepo) FindByID(id int) (*User, error) {
return m.user, m.err
}
func TestGetUserName(t *testing.T) {
tests := []struct {
name string
repo *mockUserRepo
id int
wantName string
wantErr bool
}{
{
name: "正常系",
repo: &mockUserRepo{user: &User{ID: 1, Name: "Alice"}},
id: 1,
wantName: "Alice",
},
{
name: "ユーザーが見つからない",
repo: &mockUserRepo{err: errors.New("not found")},
id: 999,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewUserService(tt.repo)
got, err := svc.GetUserName(tt.id)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr = %v", err, tt.wantErr)
}
if got != tt.wantName {
t.Errorf("name = %q, want %q", got, tt.wantName)
}
})
}
}
$ go test -v -run TestGetUserName
=== RUN TestGetUserName
=== RUN TestGetUserName/正常系
=== RUN TestGetUserName/ユーザーが見つからない
--- PASS: TestGetUserName (0.00s)
--- PASS: TestGetUserName/正常系 (0.00s)
--- PASS: TestGetUserName/ユーザーが見つからない (0.00s)
PASS
ポイントは、UserServiceがインターフェースUserRepositoryに依存している点です。具象型ではなくインターフェースに依存させることで、テスト時にモック実装を注入できます。
testifyでアサーションを簡潔に書く
標準のtestingパッケージだけでもテストは書けますが、github.com/stretchr/testifyを使うとアサーションがぐっと読みやすくなります。
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAddWithTestify(t *testing.T) {
assert.Equal(t, 5, Add(2, 3))
assert.Equal(t, 0, Add(-1, 1))
assert.NotEqual(t, 1, Add(2, 3))
}
$ go test -v -run TestAddWithTestify
=== RUN TestAddWithTestify
--- PASS: TestAddWithTestify (0.00s)
PASS
標準パッケージとtestifyのどちらを使うかは、チームの方針やプロジェクトの規模によって判断するのが良いと思います。
| 項目 | 標準testing | testify |
|---|---|---|
| 依存 | なし | 外部パッケージが必要 |
| アサーション | if文で手動比較 | assert.Equal等で簡潔 |
| モック生成 | 手動で構造体を定義 | mock.Mockで自動化 |
| エラー出力 | 自分でメッセージを組み立てる | 期待値と実際値を自動表示 |
| 向いている規模 | 小〜中規模 | 中〜大規模 |
モックを使いすぎるアンチパターン
モックは便利ですが、やりすぎると「テストは通るけど本番で壊れる」状態に陥ります。
// アンチパターン: すべての依存をモック化
type mockDB struct{}
type mockCache struct{}
type mockExternalAPI struct{}
type mockLogger struct{}
// DBもキャッシュも外部APIもロガーも全部モック...
// テストが実装の内部構造に密結合してしまい、
// リファクタリングするたびに大量のテストが壊れる
各種カンファレンスでも繰り返し指摘されていますが、モックは「制御できない外部依存」に対して使うのが基本方針です。自分のコード内のロジックはそのまま実行し、外部API呼び出しやメール送信など、テスト環境で再現しにくい部分だけをモックにするのが適切な使い方です。データベースについては、テスト用のDBインスタンスを用意して実際にクエリを流す結合テストの方が、モックよりも信頼性の高いテストになるケースも多いです。
テストカバレッジとベンチマーク
go test -coverでカバレッジを計測する
Goにはカバレッジ計測の仕組みも標準で備わっています。
$ go test -cover ./...
ok example/calc 0.002s coverage: 85.7% of statements
$ go test -coverprofile=coverage.out ./...
$ go tool cover -html=coverage.out -o coverage.html
-coverprofileで出力したファイルをgo tool cover -htmlに渡すと、ブラウザでどの行が実行されたかを視覚的に確認できます。カバーされていない行が赤くハイライトされるので、テスト漏れが一目でわかります。
ただし、カバレッジの数値だけを追いかけるのはあまり意味がありません。80%前後を目安にしつつ、ビジネスクリティカルなロジックが確実にテストされているかどうかの方が大切です。
ベンチマークテストで性能を測定する
Benchmarkプレフィックスの関数を書くと、go test -benchで性能を計測できます。
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}
$ go test -bench=. -benchmem ./...
BenchmarkAdd-8 1000000000 0.2900 ns/op 0 B/op 0 allocs/op
-benchmemフラグを付けると、1回の操作あたりのメモリアロケーション数とバイト数も表示されます。文字列結合で+演算子とstrings.Builderのどちらが速いか、といった疑問も自分で計測して検証できるのは心強いです。
ベンチマークテストはパフォーマンス改善のPRを出すときにも役立ちます。改善前後の数値をbenchstatで比較すれば、改善幅を客観的に示せます。
テストしやすいコードの設計
依存性の注入を意識する
テストを書きにくいと感じたら、それはコード設計に改善の余地があるサインです。特にありがちなのが、関数内でグローバル変数や外部パッケージを直接呼び出しているケースです。
// アンチパターン: 外部依存がハードコード
func GetPrice(itemID string) (int, error) {
resp, err := http.Get("https://api.example.com/items/" + itemID)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var result struct{ Price int }
json.NewDecoder(resp.Body).Decode(&result)
return result.Price, nil
}
この関数をテストしようとすると、外部APIが稼働している必要があります。テスト実行のたびにネットワークを叩くのは遅いし不安定です。インターフェースで依存を切り出しましょう。
// 改善版: インターフェースで依存を注入
type ItemClient interface {
GetItemPrice(itemID string) (int, error)
}
type PriceService struct {
client ItemClient
}
func NewPriceService(c ItemClient) *PriceService {
return &PriceService{client: c}
}
func (s *PriceService) GetPrice(itemID string) (int, error) {
return s.client.GetItemPrice(itemID)
}
こうしておくと、テスト時にはItemClientインターフェースを実装したモックを渡すだけで済みます。Goのインターフェースは暗黙的に満たされるため、モック側でわざわざ「このインターフェースを実装します」と宣言する必要がないのも楽な点です。関連記事として「GoのインターフェースとDI設計」のトピックもあわせて読むと理解が深まると思います。
テストデータの管理にtestdataディレクトリを使う
テストで使うファイル(JSONフィクスチャなど)は、testdataディレクトリに置くのがGoの慣習です。go buildはこのディレクトリを無視するため、テスト用ファイルがバイナリに含まれる心配がありません。
mypackage/
+-- handler.go
+-- handler_test.go
+-- testdata/
+-- valid_request.json
+-- invalid_request.json
func TestParseRequest(t *testing.T) {
data, err := os.ReadFile("testdata/valid_request.json")
if err != nil {
t.Fatalf("テストデータの読み込みに失敗: %v", err)
}
var req Request
if err := json.Unmarshal(data, &req); err != nil {
t.Fatalf("JSONパースに失敗: %v", err)
}
if req.Name == "" {
t.Error("Name should not be empty")
}
}
testdataはgo toolが認識する特別なディレクトリ名です。Go 1.16以降ではembedパッケージでテストデータを埋め込む方法もありますが、テスト専用のデータであればtestdataで管理する方がシンプルです。
TestMainでセットアップと後片付けをまとめる
パッケージ全体のテストの前後に共通のセットアップ・ティアダウン処理を入れたい場合は、TestMain関数を使います。テスト用データベースの初期化や、一時ディレクトリの作成などに便利です。
func TestMain(m *testing.M) {
// セットアップ
setup()
// テスト実行
code := m.Run()
// 後片付け
teardown()
os.Exit(code)
}
TestMainはパッケージごとに1つだけ定義できます。個々のテストのセットアップにはt.Cleanup()を使う方が柔軟性が高く、テストケースのスコープで後片付けを管理できるのでおすすめです。
まとめ
Goのテスト手法について、標準パッケージからモック設計まで整理しました。
- testingパッケージとテーブル駆動テストが基本。
t.Run()でサブテストを分割すると可読性が上がる - httptest.NewRecorderはハンドラ単体テスト、httptest.NewServerはルーティング込みの結合テストに使う
- モックはインターフェースを活用して実装する。外部依存のみをモック化し、使いすぎに注意する
- testifyはアサーションを簡潔にしたい場合に有効だが、標準パッケージでも十分にテストは書ける
- カバレッジは
go test -coverで計測。数値にこだわるより重要なロジックのカバーを意識する - テストしやすいコード設計(依存性の注入、
testdataディレクトリ)を心がけることで、テスト全体の品質が上がる
よくある質問(FAQ)
Q. テーブル駆動テストとサブテストの違いは何ですか?
テーブル駆動テストはテストケースをデータ(スライス)としてまとめる設計パターンで、サブテスト(t.Run())はテスト関数内で独立したテストを実行するGoの仕組みです。テーブル駆動テストの中でt.Run()を使って各ケースをサブテストとして実行するのが一般的な組み合わせ方です。サブテストはgo test -run TestAdd/正の数同士のように特定のケースだけを実行できるメリットもあります。
Q. 標準のtestingパッケージとtestifyのどちらを使うべきですか?
プロジェクトの規模とチームの好みによります。小規模なプロジェクトや外部依存を最小限にしたい場合は標準のtestingパッケージで十分です。テストの数が増えてアサーションの可読性を上げたいと感じたら、testifyのassertパッケージだけ追加で導入するアプローチが現実的です。両者は排他的ではなく、標準パッケージをベースにしつつ部分的にtestifyを使うことも可能です。
Q. テストカバレッジは何%を目指すべきですか?
一般的には80%前後が目安とされています。100%を目指すとテストのメンテナンスコストが急激に上がるため、ビジネスクリティカルな部分やエラーハンドリングのカバレッジを優先するのが現実的です。カバレッジの数値自体よりも、テストが「仕様のドキュメント」として機能しているかどうかを意識する方が、長期的にはプロジェクトの品質向上につながります。

