テスト
レッスン23:テスト
生活での例え
あなたがレストランを開いたと想像してください。すべての料理が提供される前に、シェフがまず味見をします — 味付けが正しいか、調理が適切かを確認するためです。ソフトウェアテストはこの「味見」プロセスのようなものです。コードがユーザーに届けられる前に、期待通りに動作するかが自動的に検証されます。テストなしで公開することは、味見なしで料理を出すようなもので、問題が発生するのは時間の問題です。
コアコンセプト
Goには完全なテストツールチェーンが組み込まれており、サードパーティのフレームワークは不要です。コアコンセプトは以下の通りです:
| コンセプト | 説明 |
|---|---|
testing.T |
単体テストコンテキストオブジェクト。テスト失敗の報告に使用 |
testing.B |
ベンチマークテストコンテキストオブジェクト。パフォーマンスの測定に使用 |
testing.M |
テストエントリーオブジェクト。TestMainのグローバルセットアップに使用 |
*_test.go |
テストファイルの命名規則。テスト時のみコンパイルされる |
go test |
テストを実行するコマンドラインツール |
| テーブル駆動テスト | データテーブルで複数のテストケースを駆動するGo流のパターン |
httptest |
HTTPリクエストのテストヘルパーパッケージ |
基本構文と使い方
テストファイルの規則
テストファイルは_test.goで終わる名前で、テスト対象のコードと同じパッケージに配置する必要があります:
myapp/
├── math.go
└── math_test.go
テスト関数のシグネチャ
func TestXxx(t *testing.T) {
// テストロジック
}
関数名はTestで始まる必要があり、パラメータは*testing.Tです。
アサーションメソッド
Goには組み込みのassert関数がないため、手動でチェックする必要があります:
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d, 期待値 5", result)
}
}
一般的なメソッド:
| メソッド | 用途 |
|---|---|
t.Error() / t.Errorf() |
エラーを報告し、実行を継続 |
t.Fatal() / t.Fatalf() |
エラーを報告し、現在のテストを即座に終了 |
t.Skip() / t.Skipf() |
現在のテストをスキップ |
t.Log() / t.Logf() |
ログを出力(-v時のみ表示) |
t.Run(name, func) |
サブテストを実行 |
t.Helper() |
ヘルパー関数としてマークし、エラー報告を呼び出し元に向ける |
💡 ヒント2: t.Helper()でアサーションヘルパー関数をマークすると、失敗時にヘルパー関数内部ではなく呼び出し元の位置が報告されます。
💡 ヒント3: go test -vで詳細出力、go test -run=regexでマッチするテストのみ実行、-count=1でキャッシュを無効化できます。
テストの実行
# 現在のパッケージのすべてのテストを実行
go test
# 詳細出力
go test -v
# 名前にマッチするテストのみ実行
go test -run TestAdd
# 現在のディレクトリとサブディレクトリで実行
go test ./...
# カバレッジを表示
go test -cover
例題
例:基本的な単体テスト(難易度⭐)
ファイル構造:
calculator/
├── calc.go
└── calc_test.go
calc.go:
package calculator
// Add 2つの数値の合計を返します
func Add(a, b int) int {
return a + b
}
// Divide 商とエラーがあるかどうかを返します
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("除数はゼロにできません")
}
return a / b, nil
}
calc_test.go:
package calculator
import (
"testing"
)
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d, 期待値 %d", got, want)
}
}
func TestDivide(t *testing.T) {
got, err := Divide(10, 3)
if err != nil {
t.Fatalf("予期しないエラー: %v", err)
}
want := 3.3333333333333335
if got != want {
t.Errorf("Divide(10, 3) = %f, 期待値 %f", got, want)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("ゼロ除算はエラーを返すべきです")
}
}
出力:
$ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestDivide
--- PASS: TestDivide (0.00s)
=== RUN TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok calculator 0.003s
例:テーブル駆動テストとサブテスト(難易度⭐⭐)
テーブル駆動テストはGoコミュニティで最も評価されているテストパターンです。入力と期待出力をスライスに入れ、ループで検証します。
stringutil.go:
package stringutil
import "unicode"
// Reverse 文字列を反転します
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// IsPalindrome 文字列が回文かどうかをチェックします
func IsPalindrome(s string) bool {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
if unicode.ToLower(runes[i]) != unicode.ToLower(runes[j]) {
return false
}
}
return true
}
stringutil_test.go:
package stringutil
import "testing"
func TestReverse(t *testing.T) {
// テストケーステーブルを定義
tests := []struct {
name string // ケース名
input string
want string
}{
{"空文字列", "", ""},
{"単一文字", "a", "a"},
{"通常の文字列", "hello", "olleh"},
{"中国語", "你好世界", "界世好你"},
{"回文", "racecar", "racecar"},
}
for _, tt := range tests {
// t.Runはサブテストを作成し、各ケースは独立して実行される
t.Run(tt.name, func(t *testing.T) {
got := Reverse(tt.input)
if got != tt.want {
t.Errorf("Reverse(%q) = %q, 期待値 %q", tt.input, got, tt.want)
}
})
}
}
func TestIsPalindrome(t *testing.T) {
tests := []struct {
name string
input string
want bool
}{
{"英語の回文", "racecar", true},
{"中国語の回文", "上海自来水来自海上", true},
{"回文ではない", "hello", false},
{"大文字小文字を区別しない", "RaceCar", true},
{"空文字列", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsPalindrome(tt.input)
if got != tt.want {
t.Errorf("IsPalindrome(%q) = %v, 期待値 %v", tt.input, got, tt.want)
}
})
}
}
特定のサブテストを実行:
# 中国語の回文ケースのみ実行
$ go test -v -run=TestIsPalindrome/中国語の回文
=== RUN TestIsPalindrome/中国語の回文
--- PASS: TestIsPalindrome/中国語の回文 (0.00s)
PASS
例:ベンチマーク、TestMain、httptest(難易度⭐⭐⭐)
handler.go:
package handler
import (
"encoding/json"
"net/http"
)
type Response struct {
Message string `json:"message"`
Code int `json:"code"`
}
// HelloHandler /helloリクエストを処理します
func HelloHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "World"
}
resp := Response{
Message: "Hello, " + name,
Code: 200,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
handler_test.go:
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
)
// TestMain すべてのテストの前に実行される。グローバルなセットアップとティアダウンに使用可能
func TestMain(m *testing.M) {
// ここでデータベース接続のセットアップ、設定の初期化などが可能
setup()
// すべてのテストを実行
code := m.Run()
// リソースをクリーンアップ
teardown()
// テスト結果を終了コードとして使用
os.Exit(code)
}
func setup() {
// 初期化コード(例:テスト設定の読み込み)
}
func teardown() {
// クリーンアップコード(例:データベース接続のクローズ)
}
func TestHelloHandler(t *testing.T) {
tests := []struct {
name string
queryParam string
wantMsg string
wantCode int
}{
{"デフォルトの名前", "", "Hello, World", 200},
{"カスタム名前", "Go", "Hello, Go", 200},
{"中国語の名前", "小明", "Hello, 小明", 200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// リクエストを構築
url := "/hello"
if tt.queryParam != "" {
url += "?name=" + tt.queryParam
}
req := httptest.NewRequest(http.MethodGet, url, nil)
// レスポンスレコーダーを作成
rr := httptest.NewRecorder()
// ハンドラーを呼び出し
HelloHandler(rr, req)
// ステータスコードを検証
if rr.Code != tt.wantCode {
t.Errorf("ステータスコード = %d, 期待値 %d", rr.Code, tt.wantCode)
}
// レスポンスボディを検証
var resp Response
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("レスポンスの解析に失敗しました: %v", err)
}
if resp.Message != tt.wantMsg {
t.Errorf("メッセージ = %q, 期待値 %q", resp.Message, tt.wantMsg)
}
})
}
}
// BenchmarkHelloHandler ベンチマークテスト。ハンドラーのパフォーマンスを測定
func BenchmarkHelloHandler(b *testing.B) {
req := httptest.NewRequest(http.MethodGet, "/hello?name=Go", nil)
// b.Nはテストフレームワークによって自動調整され、安定した結果を保証
for i := 0; i < b.N; i++ {
rr := httptest.NewRecorder()
HelloHandler(rr, req)
}
}
// BenchmarkReverse 文字列反転のベンチマーク
func BenchmarkReverse(b *testing.B) {
s := "Hello, World! This is a test string"
for i := 0; i < b.N; i++ {
_ = Reverse(s) // Reverseが同じパッケージにあると仮定
}
}
ベンチマークの実行:
$ go test -bench=. -benchmem -count=3
goos: linux
goarch: amd64
pkg: handler
BenchmarkHelloHandler-8 200000 7523 ns/op 1248 B/op 18 allocs/op
BenchmarkHelloHandler-8 200000 7401 ns/op 1248 B/op 18 allocs/op
BenchmarkHelloHandler-8 200000 7350 ns/op 1248 B/op 18 allocs/op
PASS
ok handler 4.832s
カバレッジの確認:
# カバレッジレポートを生成
$ go test -coverprofile=coverage.out
# ターミナルで関数ごとのカバレッジを表示
$ go tool cover -func=coverage.out
total: (statements) 85.7%
# HTMLレポートを生成(ブラウザで開く)
$ go tool cover -html=coverage.out -o coverage.html
シナリオ応用
シナリオ1:ミドルウェアのテスト
Web開発では、認証やログなどのミドルウェアを独立してテストする必要があります:
package middleware
import (
"net/http"
"strings"
)
// AuthMiddleware リクエストヘッダーのTokenをチェックします
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if !strings.HasPrefix(token, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
テスト:
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAuthMiddleware(t *testing.T) {
// シンプルなダウンストリームハンドラーを作成
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Passed"))
})
middleware := AuthMiddleware(nextHandler)
t.Run("トークンなしは401を返すべき", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("ステータスコード = %d, 期待値 %d", rr.Code, http.StatusUnauthorized)
}
})
t.Run("有効なトークンは通過すべき", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
req.Header.Set("Authorization", "Bearer my-secret-token")
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("ステータスコード = %d, 期待値 %d", rr.Code, http.StatusOK)
}
})
t.Run("無効なプレフィックスは401を返すべき", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
req.Header.Set("Authorization", "Basic abc123")
rr := httptest.NewRecorder()
middleware.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("ステータスコード = %d, 期待値 %d", rr.Code, http.StatusUnauthorized)
}
})
}
シナリオ2:データベース操作のテスト(インターフェース分離)
インターフェースを通じてデータベース依存を分離し、テスト時にモックを注入します:
package user
import "context"
// UserRepository ユーザーデータアクセスインターフェースを定義
type UserRepository interface {
GetByID(ctx context.Context, id int) (*User, error)
Create(ctx context.Context, user *User) error
}
// User モデル
type User struct {
ID int
Name string
}
// Service ユーザービジネスロジック
type Service struct {
repo UserRepository
}
// NewService ユーザーサービスを作成
func NewService(repo UserRepository) *Service {
return &Service{repo: repo}
}
// GetUser ユーザーを取得。見つからない場合はエラーを返す
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
if id <= 0 {
return nil, fmt.Errorf("無効なユーザーID: %d", id)
}
return s.repo.GetByID(ctx, id)
}
モックとテスト:
package user
import (
"context"
"testing"
)
// mockRepo テスト用のUserRepositoryインターフェース実装
type mockRepo struct {
users map[int]*User
}
func (m *mockRepo) GetByID(_ context.Context, id int) (*User, error) {
if u, ok := m.users[id]; ok {
return u, nil
}
return nil, fmt.Errorf("ユーザー %d は存在しません", id)
}
func (m *mockRepo) Create(_ context.Context, user *User) error {
m.users[user.ID] = user
return nil
}
func TestGetUser(t *testing.T) {
mock := &mockRepo{
users: map[int]*User{
1: {ID: 1, Name: "Alice"},
2: {ID: 2, Name: "Bob"},
},
}
svc := NewService(mock)
t.Run("既存のユーザー", func(t *testing.T) {
user, err := svc.GetUser(context.Background(), 1)
if err != nil {
t.Fatalf("予期しないエラー: %v", err)
}
if user.Name != "Alice" {
t.Errorf("名前 = %q, 期待値 %q", user.Name, "Alice")
}
})
t.Run("存在しないユーザー", func(t *testing.T) {
_, err := svc.GetUser(context.Background(), 99)
if err == nil {
t.Error("エラーが返されることを期待しました")
}
})
t.Run("無効なID", func(t *testing.T) {
_, err := svc.GetUser(context.Background(), -1)
if err == nil {
t.Error("エラーが返されることを期待しました")
}
})
}
❓ よくある質問
質問1:テストファイルが認識されない場合はどうすればいいですか?
ファイル名が_test.goで終わり、パッケージ名が正しいことを確認してください。テストファイルはgo build時には無視され、go test時にのみコンパイルされます。テストファイルが_testパッケージ(つまりpackage foo_test)にある場合、エクスポートされた識別子のみをテストできます。
質問2:失敗したテストのみを実行するにはどうすればいいですか?
-rcパラメータと正規表現を使用します:
# 名前で特定のテストを実行
go test -run TestDivideByZero -v
# go test -vを使用している場合、失敗したテスト名が出力に表示されます
-count=1と組み合わせてキャッシュを無効化し、テストが実際に再実行されることも確認できます:
go test -run TestSomething -count=1 -v
質問3:t.Fatalとt.Errorの違いは何ですか?
t.Error()/t.Errorf():エラーを報告し、現在のテスト関数の残りのコードの実行を継続します。t.Fatal()/t.Fatalf():エラーを報告し、現在のテスト関数を即座に終了します。
一般的な原則:後続のアサーションが前の結果に依存する場合(例:errをチェックしてからポインタをデリファレンスする場合)はFatalを使用し、独立した複数のアサーションにはErrorを使用します。
質問4:時間のかかるテストをスキップするにはどうすればいいですか?
func TestSlowOperation(t *testing.T) {
if testing.Short() {
t.Skip("時間のかかるテストをスキップ(-shortフラグを使用)")
}
// 時間のかかる操作...
}
-shortフラグで実行してスキップ:
go test -short
📖 まとめ
このレッスンではGoのテストのコア内容をカバーしました:
- テスト関数:
Testで始まり、パラメータは*testing.T、Error/Fatalで失敗を報告 - テーブル駆動テスト:スライスでケースを定義、ループ +
t.Runで実行 — Goコミュニティ流のパターン - サブテスト:
t.Run(name, func)でケースを独立実行、問題の特定が容易 - ベンチマークテスト:
Benchmarkで始まり、パラメータは*testing.B、b.N回ループしてパフォーマンスを測定 - TestMain:セットアップ/ティアダウンのグローバルエントリーポイント、
m.Run()で全テストを実行 - httptest:
httptest.NewRequest+httptest.NewRecorderでHTTPハンドラーをテスト - カバレッジ:
go test -coverで表示、-coverprofileで詳細レポートを生成
📝 演習
演習1:文字列処理関数のテストを書く
以下の関数のテストを書き、少なくとも5つのテーブル駆動ケースを含めてください:
// Truncate 文字列を指定された長さに切り詰め、超過分を"..."で置換します
func Truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen-3]) + "..."
}
演習2:HTTPハンドラーのベンチマークを書く
JSON配列を返すAPIのベンチマークを書き、json.Marshalとjson.Encoderのパフォーマンス差を比較してください:
// ListHandler ユーザーリストを返します
func ListHandler(w http.ResponseWriter, r *http.Request) {
users := []User{{1, "Alice"}, {2, "Bob"}, {3, "Charlie"}}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
要件:
- 2つのベンチマークを書く。1つは
json.Marshalを使用、もう1つはjson.NewEncoderを使用 -benchmemでメモリ割り当てを比較
演習3:TestMainでテストデータベースを実装する
TestMainを使用したテストソリューションを設計してください:
TestMainで一時的なSQLiteデータベースを作成- マイグレーションを実行してテーブルを作成
- すべてのテストを実行
- テスト完了後に一時データベースを削除
ヒント:m.Run()の戻り値をos.Exit()のパラメータとして使用します。



