Goで自動テストをしよう
この章では、Goの標準 testing パッケージを使った自動テストについて学び、ハンズオン形式で学習します。これにより、テーブル駆動テスト・サブテスト・カバレッジ計測・httptest など、自動テストの基本が身につきます。
1. 本章の概要
1.1 本章の目的
Goは標準の testing パッケージだけで、単体テストからHTTPハンドラのテストまでカバーできる設計になっています。外部フレームワークを選ぶ前に、標準機能でどこまで書けるか・書きやすいかを押さえておくと、テスト設計の判断がしやすくなります。本章では、testing パッケージによる単体テストの基本、テーブル駆動テストとサブテストに加えて、カバレッジ計測、httptest を使ったHTTPハンドラのテスト、テストヘルパの書き方も扱います。
1.2 ハンズオンの流れ
小さな関数の単体テストを書くところから始めて、テーブル駆動テストとサブテストで整理します。仕上げに net/http で書いた小さなハンドラに対して httptest でテストを追加し、Goでのテスト設計に慣れます。
1.3 事前準備
前提となる講座
この章では、以下の知識を前提としています。自信がない場合は先に関連講座を実施してみましょう。
| 講座名 | 必要な知識 |
|---|---|
| テストの考え方 | 単体テスト・カバレッジなど、テストの基本概念 |
必要なツール
この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。
| ツール名 | 関連箇所 | 理由 |
|---|---|---|
| Visual Studio Code | Visual Studio Codeのインストール | テスト対象コードとテストコードを記述するエディタとして使用する |
| Go | Goのインストール | go test コマンドの実行環境として使用する |
2. Goのテスト設計の考え方
Goは標準の testing パッケージで自動テストが完結する言語です。外部のテストフレームワークをインストールしなくても、_test.go という命名のファイルにテスト関数を書き、go test コマンドを実行するだけで動きます。実際の現場でもこの標準機能をベースに使うのが基本で、モックが必要な場面で testify を組み合わせるのが一般的な構成です。
flowchart LR
Code[プロダクトコード<br>calculator.go] --> Test[テストコード<br>calculator_test.go]
Test --> Run[go test]
Run --> Result[✅ PASS / ❌ FAIL]
Run --> Cover[カバレッジ計測]
3. プロジェクトの準備
テストを書くためのGoモジュールを用意します。任意の場所に go-test-hands-on フォルダを作成し、Visual Studio Codeの「ファイル」→「フォルダーを開く」から作成した go-test-hands-on フォルダを開きます。以降の操作は、Visual Studio Codeのターミナルから行います。
go-test-hands-on/ ← このフォルダを作成
以下のコマンドで、Go がインストールされていることを確認します。
go version
以下のように Go のバージョンが表示されれば、インストールは確認できています。
go version go1.x.x darwin/arm64
バージョンが表示されない場合は、Goのインストール を先に実施してください。
インストールが確認できたら、モジュールを初期化します。
go mod init testdemo
以下のような実行結果が表示されます。
go: creating new go.mod: module testdemo
go.mod が生成されていれば準備完了です。
4. 単体テストの書き方
単体テストの基本を、少しずつ書き方を洗練させながら扱います。まずは testing パッケージのもっとも基本の形で1つ書いてみて、そのあとテーブル駆動テスト、t.Run によるサブテスト分割、エラーを返す関数のテストへと段階的に発展させます。
4.1 基本の testing パッケージ
testing パッケージのもっとも基本の形として、テスト対象の関数を用意し、それに対する1つのテストを書いて go test コマンドで実行するまでを体験します。以降の応用(テーブル駆動テスト・サブテスト・カバレッジ・httptest)の土台になります。
対象コードの用意
Visual Studio Code のエクスプローラーで go-test-hands-on フォルダを右クリックし、「新しいファイル」を選択して calculator.go を作成します。
go-test-hands-on/
├── go.mod
└── calculator.go ← このファイルを作成
作成したファイルに以下の内容を記述して保存します。テスト対象となる2つの関数(AddとDivide)と、0除算用のエラー変数を定義しています。
package testdemo
import "errors"
var ErrDivideByZero = errors.New("0で割ることはできません")
func Add(a, b int) int {
return a + b
}
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
コードを解説します。
var ErrDivideByZero = errors.New("0で割ることはできません")
パッケージレベルで定義した公開エラー変数です。呼び出し側は errors.Is(err, ErrDivideByZero) でこのエラーかどうかを判定できます。
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}
Goではエラーを戻り値で返すのが基本です。第2戻り値に error 型を返し、正常時は nil を、異常時は具体的なエラー値を返します。
はじめてのテスト
calculator_test.go を作成します。テストファイルは対象ファイルと同じディレクトリ・同じパッケージ名で、末尾を _test.go にするのが原則です。
go-test-hands-on/
├── go.mod
├── calculator.go
└── calculator_test.go ← このファイルを作成
テスト関数は Test で始まる名前で、引数に *testing.T を取ります。書き方は以下のとおりです。
func Test対象関数名(t *testing.T) {
got := 対象関数呼び出し
want := 期待値
if got != want {
t.Errorf("失敗時のメッセージ", got, want)
}
}
以下を保存します。
package testdemo
import "testing"
func TestAdd(t *testing.T) {
got := Add(3, 5)
want := 8
if got != want {
t.Errorf("Add(3, 5) = %d, want %d", got, want)
}
}
コードを解説します。
func TestAdd(t *testing.T) {
Test で始まる関数がテストとして認識されます。引数は *testing.T 型です。
t.Errorf("Add(3, 5) = %d, want %d", got, want)
テストが失敗したら t.Errorf でメッセージを残します。Go のテストは Assert 系関数がなく、if got != want で自分で判定して t.Errorf を呼ぶスタイルが基本です。
実行
ターミナルで go test を実行します。
go test
以下のような実行結果が表示されます。
PASS
ok testdemo 0.001s
PASS と表示されれば、テストが通っています。詳しい出力を見たいときは -v オプションを付けます。
go test -v
以下のような実行結果が表示されます。
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok testdemo 0.001s
=== RUN と --- PASSの行が加わり、どのテスト関数が実行されたかが確認できます。
4.2 テーブル駆動テスト
テーブル駆動テストの利点
同じロジックを複数の入力・期待値パターンで検証したい場合、それぞれ別々に if を書いていくと冗長になり、追加も面倒になります。Go では入力と期待値のテーブルを作ってループで回すテーブル駆動テストが慣用パターンです。実際のGoコードで最もよく見るテストの書き方です。
書き方
入力と期待値をスライスに並べ、range で1件ずつ回して同じ検証を繰り返します。書き方は以下のとおりです。
cases := []struct {
name string // ケース識別用
入力 型 // 入力
want 型 // 期待値
}{
{"ケース名1", 入力1, 期待値1},
{"ケース名2", 入力2, 期待値2},
}
for _, c := range cases {
got := 対象関数(c.入力)
if got != c.want {
t.Errorf("失敗時のメッセージ", ...)
}
}
calculator_test.go に以下を追記します。テストケースをスライスで並べ、ループで回して同じ検証を繰り返す形です。
func TestAddTableDriven(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"正+正", 3, 5, 8},
{"負+正", -2, 5, 3},
{"ゼロ+ゼロ", 0, 0, 0},
{"負+負", -3, -4, -7},
}
for _, c := range cases {
got := Add(c.a, c.b)
if got != c.want {
t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
コードを解説します。
cases := []struct {
name string
a, b int
want int
}{
{"正+正", 3, 5, 8},
...
}
cases はテストケースのスライスで、匿名 struct として書きます。各ケースには識別用の name を付けておくと、失敗時にどのケースが失敗したかが分かりやすくなります。
for _, c := range cases {
got := Add(c.a, c.b)
if got != c.want {
t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
}
}
rangeですべてのケースを順に取り出し、それぞれで Add を呼び出して期待値と比較します。ケースを増やしたければテーブルに1行足すだけで済みます。
実行結果
続いて、追記したテストを含めて実行します。
go test -v
以下のような実行結果が表示されます。
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestAddTableDriven
--- PASS: TestAddTableDriven (0.00s)
PASS
TestAddとTestAddTableDrivenの両方が実行され、PASS になっていることが分かります。
4.3 t.Run によるサブテストへの分割
サブテストの利点
先ほどの書き方だと、複数ケースが全て「1つのテスト関数の実行結果」として扱われるため、どのケースが失敗したのかがログを追わないと分かりません。t.Run を使うと、各ケースをサブテストとして個別に実行でき、実行結果もケースごとに PASS / FAIL が表示されるようになります。
書き方
各ケースを t.Run の中で実行することで、独立したサブテストとして扱えます。書き方は以下のとおりです。
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
// このサブテストの検証(外側と別の *testing.T が渡される)
})
}
calculator_test.go にサブテスト形式のテストを追記します。t.Run でケースごとにサブテストとして実行できます。
func TestAddSub(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"正+正", 3, 5, 8},
{"負+正", -2, 5, 3},
{"ゼロ+ゼロ", 0, 0, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := Add(c.a, c.b)
if got != c.want {
t.Errorf("Add(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
}
})
}
}
コードを解説します。
t.Run(c.name, func(t *testing.T) {
...
})
t.Run は第1引数にサブテスト名、第2引数にサブテストとして実行する関数を渡します。関数の中では、外側と別の *testing.T が渡されるため、ケースごとの成否が独立して記録されます。
実行結果
続いて、追加したテストを絞って実行します。-run で対象のテストを絞れます。
go test -v -run TestAddSub
以下のような実行結果が表示されます。
=== RUN TestAddSub
=== RUN TestAddSub/正+正
=== RUN TestAddSub/負+正
=== RUN TestAddSub/ゼロ+ゼロ
--- PASS: TestAddSub (0.00s)
--- PASS: TestAddSub/正+正 (0.00s)
--- PASS: TestAddSub/負+正 (0.00s)
--- PASS: TestAddSub/ゼロ+ゼロ (0.00s)
PASS
サブテストごとに個別の PASS が表示されます。失敗した場合も、どのケースで失敗したかがログから即座に分かります。
失敗したケースだけを再実行することもできます。
go test -v -run 'TestAddSub/負\+正'
-run に テスト関数名/サブテスト名 のパターンを渡すと、対象のサブテストだけを実行できます。
4.4 エラーを返す関数のテスト
期待するエラーの検証
Divide は 0 で割ろうとするとエラーを返します。エラーが期待通りかも検証します。書き方は以下のとおりです。エラー比較は errors.Is、致命的な不一致は t.Fatalf で以降をスキップするのが一般的です。
if !errors.Is(err, 期待するエラー) {
t.Fatalf("エラー不一致: got=%v, want=%v", err, 期待するエラー)
}
// t.Fatalf は t.Errorf と違い、そのケースの以降の処理をスキップする
calculator_test.go の import を修正し、テスト関数を追記します。
import (
"errors"
"testing"
)
func TestDivide(t *testing.T) {
cases := []struct {
name string
a, b int
want int
wantErr error
}{
{"通常", 10, 2, 5, nil},
{"割り切れない", 10, 3, 3, nil},
{"ゼロ除算", 10, 0, 0, ErrDivideByZero},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := Divide(c.a, c.b)
if !errors.Is(err, c.wantErr) {
t.Fatalf("エラー不一致: got=%v, want=%v", err, c.wantErr)
}
if err == nil && got != c.want {
t.Errorf("Divide(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
}
})
}
}
コードを解説します。
{"ゼロ除算", 10, 0, 0, ErrDivideByZero},
wantErr に期待するエラー値を渡します。正常系は nil、異常系はパッケージレベルで定義したエラー変数(ErrDivideByZero)を指定します。
if !errors.Is(err, c.wantErr) {
t.Fatalf("エラー不一致: got=%v, want=%v", err, c.wantErr)
}
errors.Is は Goエラーハンドリング で学んだ通り、nil 同士や、ラップされたエラー同士も正しく比較できる関数です。t.Fatalf は t.Errorf と違い、そのケースの以降の処理をスキップします。「エラーが違ったので、値の検証には進めない」場面で使います。
if err == nil && got != c.want {
t.Errorf("Divide(%d, %d) = %d, want %d", c.a, c.b, got, c.want)
}
err が nil(正常終了)だったときだけ、戻り値が期待値と一致するかを検証します。エラーが返っている場合の戻り値は保証されないため、比較の対象から外します。
5. カバレッジの計測
5.1 カバレッジ取得の意義
「どこまでテストが到達しているか」を数値で見えるようにする指標がカバレッジです。テストが薄い領域を特定するのに役立ちます。
5.2 実行
-coverオプション付きでgo testを実行します。
go test -cover
以下のような実行結果が表示されます(数値はテストの充実度に依存します)。
PASS
coverage: 100.0% of statements
ok testdemo 0.002s
coverage: の行にステートメントベースのカバレッジ率が表示されます。ここでは 100% になっています。
5.3 HTMLでの詳細確認
カバレッジ情報をファイルに書き出し、HTMLで可視化できます。まずカバレッジプロファイルをファイルに保存します。
go test -coverprofile=coverage.out
以下のような実行結果が表示されます。
PASS
ok testdemo 0.002s
コマンド自体の出力は通常のテスト実行と同じですが、実行と同時にカレントディレクトリに coverage.out が生成されています。次に、そのファイルをHTMLで表示します。
go tool cover -html=coverage.out
ブラウザが自動的に開き、「テストが通ったコード」が緑、「通っていないコード」が赤で表示されます。どの分岐がテストに含まれていないかが視覚的に分かります。
6. httptestによるHTTPハンドラのテスト
6.1 httptestの用途
Web API のハンドラは、実サーバを起動しなくても httptest パッケージを使ってメモリ上でテストできます。テスト対象のハンドラに擬似的なリクエストを送り、レスポンスを検証する形です。
6.2 対象コードの用意
handler.go を作成し、以下を保存します。ヘルスチェック用の単純な HTTP ハンドラです。
package testdemo
import (
"encoding/json"
"net/http"
)
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
コードを解説します。
func HealthHandler(w http.ResponseWriter, r *http.Request) {
net/http のハンドラ関数の標準シグネチャです。第1引数の w にレスポンスを書き込み、第2引数の r からリクエストを読み取ります。
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
Content-Type ヘッダをJSONに設定し、ステータス 200 を返した上で、{"status": "ok"} を JSON でエンコードして書き込んでいます。
6.3 テストの記述
httptest を使うと、実サーバを起動せずにハンドラを呼び出してレスポンスを検証できます。書き方は以下のとおりです。
req := httptest.NewRequest(HTTPメソッド, "パス", ボディ) // 擬似リクエストを作る
rec := httptest.NewRecorder() // レスポンスを記録する ResponseWriter
ハンドラ関数(rec, req) // ハンドラを直接呼び出す
// 検証は rec.Code(ステータス)や rec.Body(ボディ)で行う
handler_test.go を作成し、以下を保存します。
package testdemo
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.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]string
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("JSONデコード失敗: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status field = %q, want %q", body["status"], "ok")
}
}
コードを解説します。
req := httptest.NewRequest(http.MethodGet, "/health", nil)
httptest.NewRequest は擬似リクエストを作るヘルパです。GET でパス /health、ボディなし、を意味します。
rec := httptest.NewRecorder()
HealthHandler(rec, req)
httptest.NewRecorder はレスポンスを記録する http.ResponseWriter の実装です。ハンドラをそのまま呼び出せば、rec にステータス・ヘッダ・ボディが記録されます。
if rec.Code != http.StatusOK { ... }
rec.Code にステータスコードが入ります。ボディは rec.Body(*bytes.Buffer)から読めます。
実行してテストが通ることを確認します。
go test -v -run TestHealthHandler
以下のような実行結果が表示されます。
=== RUN TestHealthHandler
--- PASS: TestHealthHandler (0.00s)
PASS
7. まとめ
この章では、Goの自動テストの書き方を学びつつ、実際に単体テスト・テーブル駆動テスト・httptest を体験しました。
- Goは、標準の
testingパッケージだけで自動テストが完結する - テストファイルは
_test.go命名で、Testで始まる関数がテストとして認識される - テーブル駆動テストは、複数入力を効率的に検証するGoの慣用パターンである
t.Runでサブテストに分割することで、ケースごとの成否がログで判別できる- エラーの比較は
errors.Is、致命的な失敗はt.Fatalfで以降をスキップする go test -coverでカバレッジ、go tool cover -htmlでHTML可視化ができるhttptestを使うと、Webハンドラを実サーバなしにテストできる
これでGo講座は一通り終了です。ここまでで、Goの基本文法から並行処理・net/http・GORM・REST API・CORS対応・JWT認証・静的解析・自動テストまで、実際の開発で必要になるGoの基礎から応用までが一通り身につきました。