Go: Go Testing:`testing` パッケージ、テーブル駆動型テスト、ベンチマーク、および
Goの標準ライブラリには、組み込みのテストフレームワークが用意されています。サードパーティ製のテストライブラリやアサートライブラリは必要なく、基本的な
testingパッケージだけで高品質なテストを作成できます。
Goに組み込まれているテストツールチェーンは、業界でも類を見ないものです。go testがテスト関数を自動的に検出し、テーブル駆動型テストはGoコミュニティの代名詞であり、ベンチマークやコードカバレッジも最初から利用可能です。このレッスンでは、Goのテストに関するすべての核心的な要素を習得します。
1. 学習内容
testing.T: ユニットテストの基礎- テーブル駆動型テスト
- サブテスト (
t.Run) testing.BベンチマークTestMain: エントリポイントのテストおよびセットアップ/テアダウン- 報道
go test -cover httptestHTTPテスト- 包括的なケーススタディ:決済モジュールのテストスイート
2. リファクタリングエンジニアの実話
(1) 課題:1つの機能を変更しただけで、3つのモジュールが同時にクラッシュしてしまった
アリスは決済チームのバックエンドエンジニアです。彼女は、決済モジュール内の税額計算ロジックのリファクタリングを依頼されました:
「『大丈夫だろう』と思って、あるフィールドの名前を変更しただけだった。ところがリリース後、注文の30%で税額の計算に誤りが見つかり、プロジェクトマネージャーによると5,000ドルの損失が出たそうだ。テストを行っていなかったため、誰がどこを変更したのか、誰も分からなかった。」
彼女は決済モジュールのソースコードを開いてみると、プロジェクト全体にテストファイルが1つもなかったことが判明した:
// payment.go — no corresponding payment_test.go
func CalculateTax(amount float64, country string) float64 {
// No one knows if this function is correct—there are no tests
switch country {
case "US":
return amount * 0.08
case "CN":
return amount * 0.13
default:
return amount * 0.10
}
}
テストを実行した後、アリスはすぐに問題に気づきました。彼女は以前、「UK」の税率を、正しいVAT率である20%ではなく20%に設定していたのです(実際には20%でしたが、amount=0というエッジケースではNaNが返されていました)。
(2) Go Solution:組み込みのテストフレームワーク
// payment_test.go
package main
import "testing"
// Table-driven test
func TestCalculateTax(t *testing.T) {
tests := []struct {
name string
amount float64
country string
want float64
}{
{"US standard", 100.0, "US", 8.0},
{"CN standard", 100.0, "CN", 13.0},
{"UK default", 100.0, "UK", 10.0},
{"zero amount", 0.0, "US", 0.0},
{"negative amount", -50.0, "US", -4.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CalculateTax(tt.amount, tt.country)
if got != tt.want {
t.Errorf("CalculateTax(%v, %s) = %v, want %v",
tt.amount, tt.country, got, tt.want)
}
})
}
}
テストを実行:
$ go test -v
=== RUN TestCalculateTax/US_standard
=== RUN TestCalculateTax/CN_standard
=== RUN TestCalculateTax/UK_default
=== RUN TestCalculateTax/zero_amount
=== RUN TestCalculateTax/negative_amount
--- PASS: TestCalculateTax (0.00s)
--- PASS: TestCalculateTax/US_standard (0.00s)
--- PASS: TestCalculateTax/CN_standard (0.00s)
--- PASS: TestCalculateTax/UK_default (0.00s)
--- PASS: TestCalculateTax/zero_amount (0.00s)
--- PASS: TestCalculateTax/negative_amount (0.00s)
PASS
ok payment 0.123s
(3) 結果:テスト実施時と未実施時
| 次元 | 未試験 | 試験 |
|---|---|---|
| 自信を取り戻す | 一行でも変更するのが怖い | 変更後すぐに go test を実行する |
| 位置決めに関する問題 | 起動後のユーザーエラー報告 | 開発段階での不具合 |
| コードの品質 | 直感 | データ駆動型 |
| はじめに | 変更を加えるのが怖い | 変更を加えてテストを実行すれば、安心できます |
| 返品コスト | 手動による確認 | 自動化 |
3. testing.T: ユニットテストの基礎
(1) テスト関数の規則
// Rules:
// 1. File name must end with _test.go
// 2. Function signature must be func TestXxx(t *testing.T)
// 3. Xxx must start with an uppercase letter
// math_test.go
package main
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)
}
}
func TestSubtract(t *testing.T) {
got := Subtract(5, 3)
want := 2
if got != want {
t.Errorf("Subtract(5,3) = %d, want %d", got, want)
}
}
(2) テストにおける一般的な手法。T
| メソッド | アクション | 続行しますか? |
|---|---|---|
t.Log(args...) |
ログを出力する(-v が指定された場合にのみ表示される) | ✅ |
t.Error(args...) |
失敗としてマーク + 実行を続行 | ✅ |
t.Errorf(format, args...) |
書式エラー | ✅ |
t.Fatal(args...) |
失敗としてマーク + 現在のテストを停止 | ❌ |
t.Fatalf(format, args...) |
フォーマットエラー | ❌ |
t.Skip(args...) |
このテストをスキップ | ✅ |
▶ サンプル:テスト関数の4つの書き方
package main
import (
"fmt"
"testing"
)
// Function under test
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, ErrDivisionByZero
}
return a / b, nil
}
var ErrDivisionByZero = fmt.Errorf("division by zero")
// Style 1: Simple assertion
func TestDivideBasic(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != 5.0 {
t.Errorf("got %f, want %f", result, 5.0)
}
}
// Style 2: Error vs Fatal
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() != "division by zero" {
t.Errorf("wrong error message: %v", err)
}
}
// Style 3: Table-driven test
func TestDivideTable(t *testing.T) {
tests := []struct {
name string
a, b float64
want float64
wantErr bool
}{
{"10/2", 10, 2, 5, false},
{"0/5", 0, 5, 0, false},
{"-6/3", -6, 3, -2, false},
{"1/0", 1, 0, 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if tt.wantErr {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("got %f, want %f", got, tt.want)
}
})
}
}
4. テーブル駆動型テスト(Go言語ならではのスタイル)
(1) 標準テンプレート
package main
import "testing"
func TestMax(t *testing.T) {
// 1. Define test table
tests := []struct {
name string // Test name (for subtests)
a, b int // Input
want int // Expected output
}{
{name: "first larger", a: 10, b: 3, want: 10},
{name: "second larger", a: 3, b: 10, want: 10},
{name: "equal", a: 5, b: 5, want: 5},
{name: "negative", a: -3, b: -10, want: -3},
{name: "zero", a: 0, b: 5, want: 5},
}
// 2. Iterate through table
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 3. Execute + assert
got := Max(tt.a, tt.b)
if got != tt.want {
t.Errorf("Max(%d, %d) = %d, want %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
func Max(a, b int) int {
if a > b {
return a
}
return b
}
▶ サンプル:テーブル駆動型+複雑な入力
package main
import (
"testing"
)
// Function under test: validate password strength
type PasswordStrength int
const (
Weak PasswordStrength = iota
Medium
Strong
)
func CheckPassword(pwd string) PasswordStrength {
if len(pwd) < 6 {
return Weak
}
if len(pwd) >= 12 {
return Strong
}
return Medium
}
func TestCheckPassword(t *testing.T) {
tests := []struct {
name string
pwd string
want PasswordStrength
}{
{"short", "abc", Weak},
{"medium 6", "abcdef", Medium},
{"medium 8", "abcdefgh", Medium},
{"strong 12", "abcdefghijkl", Strong},
{"empty", "", Weak},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CheckPassword(tt.pwd)
if got != tt.want {
t.Errorf("CheckPassword(%q) = %v, want %v",
tt.pwd, got, tt.want)
}
})
}
}
5. testing.B ベンチマークテスト
(1) ベンチマークの基礎
package main
import (
"testing"
)
// Benchmark function: func BenchmarkXxx(b *testing.B)
func BenchmarkAdd(b *testing.B) {
a, c := 100, 200
for i := 0; i < b.N; i++ {
Add(a, c)
}
}
$ go test -bench=.
goos: darwin
goarch: amd64
pkg: example
BenchmarkAdd-8 1000000000 0.25 ns/op
PASS
ok example 0.3s
▶ サンプル:2つの文字列連結方法のパフォーマンス比較
// concat_test.go
package main
import (
"strings"
"testing"
)
func ConcatPlus(n int) string {
s := ""
for i := 0; i < n; i++ {
s += "a"
}
return s
}
func ConcatBuilder(n int) string {
var sb strings.Builder
sb.Grow(n)
for i := 0; i < n; i++ {
sb.WriteByte('a')
}
return sb.String()
}
func BenchmarkConcatPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatPlus(1000)
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatBuilder(1000)
}
}
$ go test -bench=. -benchmem
BenchmarkConcatPlus-8 13134 91238 ns/op 530296 allocs/op
BenchmarkConcatBuilder-8 283321 4221 ns/op 56 allocs/op
+ を使用して1,000回連結を行うと、Builderを使用する場合に比べて処理速度が20倍遅くなり、メモリ使用量は10,000倍になります。— -benchmem オプションを使用すると、メモリ使用量の差を確認できます。
(3) ベンチマーク結果の解釈
| 出力項目 | 意味 |
|---|---|
BenchmarkConcatBuilder-8 |
テスト名-8 (8 CPU) |
283321 |
b.N = 283321 反復 |
4221 ns/op |
1回の演算あたり4221ナノ秒 |
56 allocs/op |
1回の操作あたり56回のメモリ割り当て |
6. TestMain:テストのエントリポイント
(1) TestMain:セットアップ/テアダウン
// main_test.go
package main
import (
"fmt"
"os"
"testing"
)
func TestMain(m *testing.M) {
// Setup (runs once for the entire package)
fmt.Println("=== Setup: Initializing database connection ===")
// Run all tests
code := m.Run()
// Teardown
fmt.Println("=== Teardown: Closing database connection ===")
os.Exit(code)
}
func TestSomething(t *testing.T) {
t.Log("Test A")
}
func TestAnother(t *testing.T) {
t.Log("Test B")
}
$ go test -v
=== Setup: Initializing database connection ===
=== RUN TestSomething
main_test.go:16: Test A
--- PASS: TestSomething (0.00s)
=== RUN TestAnother
main_test.go:20: Test B
--- PASS: TestAnother (0.00s)
=== Teardown: Closing database connection ===
PASS
ok example 0.1s
▶ サンプル:testing.Helper ヘルパー関数
package main
import "testing"
func Add(a, b int) int { return a + b }
func assertEqual(t testing.TB, got, want interface{}) {
t.Helper()
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
func TestWithHelper(t *testing.T) {
assertEqual(t, Add(2, 3), 5)
assertEqual(t, Add(0, 0), 0)
assertEqual(t, Add(-1, 1), 0)
}
t.Helper() を使用すると、エラーメッセージがヘルパー関数内部ではなく、呼び出し元の行番号まで遡って追跡できるようになります。これは、テスト用ユーティリティ関数を記述する際の重要なベストプラクティスです。
7. 適用範囲
(1) 適用根拠
// math.go
package main
func IsEven(n int) bool {
return n%2 == 0
}
func IsPositive(n int) bool {
return n > 0
}
// math_test.go
package main
import "testing"
func TestIsEven(t *testing.T) {
tests := []struct {
n int
want bool
}{
{2, true},
{3, false},
}
for _, tt := range tests {
if got := IsEven(tt.n); got != tt.want {
t.Errorf("IsEven(%d) = %v", tt.n, got)
}
}
}
$ go test -coverprofile=coverage.out
ok example 0.1s coverage: 50.0% of statements
$ go tool cover -html=coverage.out # Open coverage report in browser
(2) go test -cover: 一般的なコマンド
| コマンド | 機能 |
|---|---|
go test -cover |
端末に表示される通信範囲 |
go test -coverprofile=c.out |
出力カバレッジプロファイルファイル |
go tool cover -html=c.out |
ブラウザでビジュアルレポートを表示 |
go test -covermode=count |
各行が実行された回数を記録する |
8. httptest による HTTP テスト
(1) httptest.Server + httptest.ResponseRecorder
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Handler under test
type UserHandler struct{}
func (h UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp := map[string]string{"status": "ok", "message": "hello"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// Test 1: httptest.ResponseRecorder (testing the handler itself)
func TestUserHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
handler := UserHandler{}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string]string
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["status"] != "ok" {
t.Errorf("got status %q, want %q", resp["status"], "ok")
}
}
// Test 2: httptest.Server (testing a complete HTTP service)
func TestUserHandlerWithServer(t *testing.T) {
server := httptest.NewServer(UserHandler{})
defer server.Close()
resp, err := http.Get(server.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got %d, want %d", resp.StatusCode, http.StatusOK)
}
}
▶ サンプル:httptest + テーブル駆動型
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// Handler under test
func greetingHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "World"
}
resp := map[string]string{"message": "Hello, " + name + "!"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func TestGreetingHandler(t *testing.T) {
tests := []struct {
name string
query string
want string
status int
}{
{"with name", "name=Alice", "Hello, Alice!", 200},
{"empty name", "", "Hello, World!", 200},
{"with special chars", "name=Go+Lang", "Hello, Go Lang!", 200},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/?"+tt.query, nil)
rec := httptest.NewRecorder()
greetingHandler(rec, req)
if rec.Code != tt.status {
t.Errorf("status = %d, want %d", rec.Code, tt.status)
}
var resp map[string]string
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp["message"] != tt.want {
t.Errorf("message = %q, want %q", resp["message"], tt.want)
}
})
}
}
(3) httptest の 2 つのモード
| モード | httptest.NewRecorder | httptest.NewServer |
|---|---|---|
| テスト対象 | シングルハンドラー | 完全なHTTPサービス |
| 起動時のオーバーヘッド | なし | あり(ランダムなポートでリスニング) |
| ユースケース | 単体テスト | 統合テスト |
| ミドルウェアはテスト可能か? | ✅ 手動で構築 | ✅ パイプライン全体を通じて自動的に |
9. 完全な例:決済モジュールのテストスイートのリファクタリング
// payment_test.go
package main
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ---------- Code under test ----------
type PaymentRequest struct {
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
Country string `json:"country"`
}
type PaymentResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Tax float64 `json:"tax,omitempty"`
Total float64 `json:"total,omitempty"`
}
func CalculateTax(amount float64, country string) (float64, error) {
if amount < 0 {
return 0, errors.New("negative amount")
}
switch country {
case "US":
return amount * 0.08, nil
case "CN":
return amount * 0.13, nil
case "DE":
return amount * 0.19, nil
default:
return amount * 0.10, nil
}
}
func paymentHandler(w http.ResponseWriter, r *http.Request) {
var req PaymentRequest
json.NewDecoder(r.Body).Decode(&req)
tax, err := CalculateTax(req.Amount, req.Country)
if err != nil {
json.NewEncoder(w).Encode(PaymentResponse{
Success: false,
Message: err.Error(),
})
return
}
json.NewEncoder(w).Encode(PaymentResponse{
Success: true,
Tax: tax,
Total: req.Amount + tax,
})
}
// ---------- Test code ----------
func jsonBody(s string) *strings.Reader {
return strings.NewReader(s)
}
// 1. Unit test: CalculateTax table-driven
func TestCalculateTax(t *testing.T) {
tests := []struct {
name string
amount float64
country string
want float64
wantErr bool
}{
{"US $100", 100, "US", 8.0, false},
{"CN $100", 100, "CN", 13.0, false},
{"DE $100", 100, "DE", 19.0, false},
{"UK default", 100, "UK", 10.0, false},
{"zero amount", 0, "US", 0, false},
{"negative amount", -100, "US", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CalculateTax(tt.amount, tt.country)
if tt.wantErr {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("CalculateTax(%v, %s) = %v, want %v",
tt.amount, tt.country, got, tt.want)
}
})
}
}
// 2. Benchmark: different ways to calculate tax
func BenchmarkCalculateTax(b *testing.B) {
for i := 0; i < b.N; i++ {
CalculateTax(100.0, "US")
}
}
// 3. HTTP test: paymentHandler
func TestPaymentHandler(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
wantSuccess bool
}{
{"US payment", `{"user_id":"u1","amount":100,"country":"US"}`, 200, true},
{"CN payment", `{"user_id":"u2","amount":200,"country":"CN"}`, 200, true},
{"negative amount", `{"user_id":"u3","amount":-50,"country":"US"}`, 200, false},
{"invalid JSON", `not json`, 200, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", "/pay",
jsonBody(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
paymentHandler(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
var resp PaymentResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Success != tt.wantSuccess {
t.Errorf("success = %v, want %v", resp.Success, tt.wantSuccess)
}
})
}
}
sequenceDiagram
participant Dev
participant Terminal as go test
participant Package as Package Under Test
participant Coverage as coverage.out
Dev->>Terminal: go test -v -cover
Terminal->>Package: 1. Find *_test.go
Terminal->>Package: 2. Call TestMain(m)
Package->>Package: 3. Setup
Package->>Package: 4. Execute all TestXxx
Package->>Package: 5. Execute BenchmarkXxx
Package->>Package: 6. Teardown
Package-->>Terminal: PASS / FAIL
Terminal-->>Coverage: Coverage data
Terminal-->>Dev: Result summary
_test.go でなければなりません。そうでないと、go test は実行されません。関数のシグネチャは厳密に func TestXxx(t *testing.T) でなければなりません。つまり、パラメータは *testing.T であり、*testing.TT や testing.T ではありません。
❓ よくある質問
[]struct をテストテーブルとして定義します。各ケースには name +入力 +期待される出力が含まれます。テーブルを順に処理し、t.Run(tt.name, ...) を使用してサブテストを実行します。Goコミュニティでは、これがテスト記述の標準的な方法と見なされています。go test -bench=. はすべてのベンチマークを実行します。go test -bench=FuncName は特定の関数を実行します。-benchmem はメモリ割り当て情報を表示します。b.N はフレームワークによって自動的に決定されます。go test -cover はパーセンテージを表示します。go test -coverprofile=c.out はファイルを出力します。go tool cover -html=c.out はブラウザベースの可視化機能を提供します。基準は 70% 以上で、コアロジックについては 90% 以上が推奨されます。httptest.NewRecorder() でハンドラを直接テストする方法(単体テスト)と、httptest.NewServer(handler) でテスト用の実際の HTTP サーバーを起動する方法(統合テスト)です。まずは「Recorder」の使用をお勧めします。m.Run() の前に実行され、テアダウンはその後に実行されます。注:出力はキャッシュされるため、終了するには os.Exit(code) を使用する必要があります。go test は何をキャッシュしますか?go test は(コードと環境に基づく)テスト結果をキャッシュします。再度実行しても変更がない場合は、(cached) が表示されます。go test -count=1 を使用すると、強制的に再実行できます。t.Skip("reason") で現在のテストをスキップできます。testing.Short() は go test -short と組み合わせて、時間がかかりすぎるテストをスキップするために使用します。t.Skipf(format, args...) は、フォーマット指定付きのスキップに使用します。📖 まとめ
- Goには
testingパッケージが組み込まれているため、サードパーティ製のテストフレームワークは必要ありません - テーブル駆動型テストはGoコミュニティの特徴の一つです:
[]struct+t.Run t.Runサブテストでは、テスト結果の明確な階層構造が示されていますtesting.Bベンチマーク:b.Nは自動調整、-benchmemはメモリをチェックするTestMain(m)は、パッケージレベルのセットアップおよびテアダウンを提供しますgo test -coverはラインのカバレッジを測定します- HTTPのテスト用
httptest.NewRecorderおよびhttptest.NewServer - テストファイル名
_test.go、関数シグネチャfunc TestXxx(t *testing.T)
📝 練習問題
-
基本問題(難易度 ⭐):第4課(関数)の
max(nums ...int) intに対して、正の数、負の数、正と負の数の混合、単一の要素、および引数が空の場合という、以下の5つのケースを網羅するテーブル駆動型テストを作成してください。 -
上級演習(難易度 ⭐⭐):第10課(ファイルI/OとJSON)の
Exporterに対するテストを作成してください。os.CreateTempを使用してエクスポートをテストするための一時ファイルを作成し、json.Unmarshalを使用して内容が正しいことを確認し、t.Cleanupを使用して一時ファイルを削除してください。 -
課題(難易度:⭐⭐⭐):第7課のマルチ決済ゲートウェイシステム向けに、完全なテストスイートを作成してください:(1)
MockPaymentGatewayに対するテーブル駆動テスト(固定の結果を返すもの); (2) StripeとPayPalの実装の処理速度をベンチマークする;(3)httptestを使用して、決済ハンドラのJSONレスポンス形式をテストする;(4) コードカバレッジは90%以上とする。