メインコンテンツまでスキップ

Goのテスト

.NET (C#) の経験がある開発者向けに、testing パッケージ、テーブル駆動テスト、testify、モック、ベンチマークを解説します。Go のテストは標準ツールチェーンに組み込まれており、xUnit のようなテストフレームワークや属性を必要としません。C# との考え方の違いを押さえれば、少ない道具で十分なテストを書けます。

1. このドキュメントで扱うこと​

  • _test.go と go test の基本
    • テストファイルの命名規則、TestXxx 関数、t.Error と t.Fatal の使い分け
  • テーブル駆動テストとサブテスト
    • [Theory] / [InlineData] に相当する書き方と t.Run、t.Parallel
  • モックよりfakeを優先する考え方
    • 利用側で小さなインターフェースを定義し、手書きの fake で差し替える
  • httptest と Testcontainers
    • HTTP ハンドラーのテストと、実 DB を使った統合テスト
  • ベンチマークとFuzzing
    • testing.B による計測と、testing.F によるファジングテスト

2. .NET との対応表​

.NET (C#)Go
xUnit / NUnittesting パッケージ (go test)
[Theory] / [InlineData]テーブル駆動テスト + t.Run
NSubstitute / Moqインターフェースの手書きfake / gomock / mockery
Shouldly / FluentAssertionstestify / go-cmp
BenchmarkDotNettesting.B (go test -bench)

その他の対応は次のとおりです。

.NET (C#)Go
コンストラクター / IDisposable による後始末t.Cleanup
[Fact(Skip = "...")]t.Skip
テストプロジェクト (*.Tests.csproj)同じディレクトリの _test.go ファイル
dotnet test --filtergo test -run
Coverletgo test -cover
並列実行の制御 (xUnit のコレクション)t.Parallel

3. C# 開発者がつまずきやすいポイント​

3.1 テストの発見は命名規則で決まる​

Go には [Fact] や [Test] のような属性がありません。次の規則だけでテストが認識されます。

  • ファイル名が _test.go で終わる
  • 関数名が Test で始まり、シグネチャが func TestXxx(t *testing.T)
  • テスト対象と同じディレクトリに置く

テストコードは本番コードと同じディレクトリに置きます。別プロジェクトに分ける C# とは慣習が異なります。

package foo と書くと内部の非公開要素にもアクセスできるホワイトボックステストになります。package foo_test と書くと公開 API だけを使うブラックボックステストになります。同じディレクトリに両方を共存させられます。

3.2 アサーションは標準では存在しない​

Assert.Equal に相当するものは標準ライブラリにありません。if で比較して t.Errorf で報告するのが基本です。

  • t.Error / t.Errorf: 失敗を記録してテストを続行する
  • t.Fatal / t.Fatalf: 失敗を記録してその場でテストを終了する

前提条件が崩れると後続の処理が成り立たない場面では Fatal を使います。たとえば err が nil でないのに戻り値のフィールドを参照すると panic するため、先に Fatal で止めます。

失敗メッセージは got と want の形式で書くのが慣習です。

Add(1, 2) = 4, want 3

3.3 [Theory] の代わりはテーブル駆動テスト​

Go ではテストケースをスライスに並べ、ループ内で t.Run を呼ぶのが定番です。ケースの追加は 1 行足すだけで済み、go test -run TestAdd/negative のように個別のケースだけを実行することもできます。

3.4 モックフレームワークより fake を優先する​

C# では Moq や NSubstitute で Setup / Verify を書くことが多いですが、Go では次の流れが一般的です。

  1. テスト対象のパッケージ側で、必要なメソッドだけを持つ小さなインターフェースを定義する
  2. テストでは、そのインターフェースを満たす簡素な fake 構造体を手書きする
  3. 呼び出し回数の検証 (Verify) よりも、結果や状態を検証する

fake は単なる Go のコードなので、リファクタリングに強く、デバッガーでも追いやすい利点があります。インターフェースが大きく、手書きが負担になる場合に限り gomock や mockery を検討します。

3.5 後始末は t.Cleanup​

xUnit ではコンストラクターと Dispose でセットアップと後始末を行います。Go では t.Cleanup に関数を登録します。登録は LIFO (後入れ先出し) で実行され、サブテストでも使えます。一時ディレクトリは t.TempDir()、環境変数は t.Setenv を使うと、後始末が自動化されます。

共通の検証処理を別関数に切り出すときは、先頭で t.Helper() を呼びます。失敗時の行番号が呼び出し元を指すようになります。

3.6 並列実行とループ変数​

t.Parallel() を呼んだテストは、他の並列テストと同時に実行されます。Go 1.21 以前では、ループ変数がイテレーションをまたいで共有されるため、サブテストのクロージャーが同じ変数を参照する問題がありました。Go 1.22 以降は各イテレーションで変数が別になり、この問題は解消されています。古い Go を対象にする場合は tt := tt が必要です。

並行処理を含むコードは、go test -race でデータ競合を検出します。CI では常に有効にすることを推奨します。

3.7 テストヘルパーライブラリは必須ではない​

testify は広く使われていますが、必須ではありません。標準の testing だけで書くチームも多くあります。構造体の比較は reflect.DeepEqual より、差分を読みやすく出力できる go-cmp の cmp.Diff が便利です。

4. コード例​

4.1 テーブル駆動テスト​

C# (xUnit) では [Theory] で書きます。

public class CalculatorTests
{
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
public void Add_ReturnsSum(int a, int b, int expected)
{
Assert.Equal(expected, Calculator.Add(a, b));
}
}

Go ではテーブル駆動テストとサブテストで書きます。

package calc

import "testing"

func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{name: "positive", a: 1, b: 2, want: 3},
{name: "zero", a: 0, b: 0, want: 0},
{name: "negative", a: -1, b: 1, want: 0},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

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

# 全パッケージを再帰的に実行 (詳細表示)
go test ./... -v

# 名前で絞り込み (サブテストはスラッシュで指定)
go test -run 'TestAdd/negative'

# データ競合検出とカバレッジ
go test -race -cover ./...

4.2 testify と go-cmp​

testify の assert は失敗しても続行し、require は失敗した時点でテストを終了します。C# の Shouldly や FluentAssertions のような読みやすさが得られます。

package user_test

import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseUser(t *testing.T) {
got, err := ParseUser(`{"id":1,"name":"alice"}`)
require.NoError(t, err) // 失敗したらここで終了

assert.Equal(t, 1, got.ID)
assert.Equal(t, "alice", got.Name)
}

func TestBuildUser(t *testing.T) {
want := &User{ID: 1, Name: "alice"}
got := BuildUser(1, "alice")

// 標準ライブラリ + go-cmp の組み合わせでも差分が読みやすい
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("BuildUser() mismatch (-want +got):\n%s", diff)
}
}

4.3 モックではなく fake を使う​

C# (NSubstitute) の例です。

var repo = Substitute.For<IUserRepository>();
repo.FindByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new User { Id = 1, Name = "Alice" });

var service = new UserService(repo);
var name = await service.GetDisplayNameAsync(1, CancellationToken.None);

Assert.Equal("Alice", name);

Go では、利用側 (UserService のあるパッケージ) で必要なメソッドだけのインターフェースを定義します。

package user

import (
"context"
"errors"
"fmt"
)

var ErrNotFound = errors.New("user not found")

type User struct {
ID int
Name string
}

// 利用側で、必要なメソッドだけを宣言する
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
}

type UserService struct {
repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}

func (s *UserService) DisplayName(ctx context.Context, id int) (string, error) {
u, err := s.repo.FindByID(ctx, id)
if err != nil {
return "", fmt.Errorf("find user %d: %w", id, err)
}
return u.Name, nil
}

テストでは、インメモリの fake を手書きします。

package user

import (
"context"
"errors"
"testing"
)

// インターフェースを満たすだけの単純な fake
type fakeUserRepo struct {
users map[int]*User
}

func (f *fakeUserRepo) FindByID(_ context.Context, id int) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}

func TestUserService_DisplayName(t *testing.T) {
repo := &fakeUserRepo{
users: map[int]*User{1: {ID: 1, Name: "Alice"}},
}
svc := NewUserService(repo)

t.Run("found", func(t *testing.T) {
got, err := svc.DisplayName(context.Background(), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "Alice" {
t.Errorf("DisplayName() = %q, want %q", got, "Alice")
}
})

t.Run("not found", func(t *testing.T) {
_, err := svc.DisplayName(context.Background(), 999)
if !errors.Is(err, ErrNotFound) {
t.Errorf("error = %v, want ErrNotFound", err)
}
})
}

エラーの検証には errors.Is / errors.As を使います。%w でラップされていても判定できます。

4.4 httptest で HTTP ハンドラーをテストする​

net/http/httptest は標準ライブラリに含まれます。ASP.NET Core の WebApplicationFactory に近い用途ですが、はるかに軽量です。

package server

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}

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)
}
if !strings.Contains(rec.Body.String(), `"status":"ok"`) {
t.Errorf("unexpected body: %s", rec.Body.String())
}
}

外部 API を呼ぶクライアントのテストには、httptest.NewServer でテスト用サーバーを起動します。

func TestClient_Fetch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()

// srv.URL をクライアントのベース URL として渡す
got, err := NewClient(srv.URL).Fetch()
if err != nil {
t.Fatal(err)
}
if got != "hello" {
t.Errorf("Fetch() = %q, want %q", got, "hello")
}
}

4.5 Testcontainers による統合テスト​

実際のデータベースを使った統合テストには、.NET と同様に Testcontainers (testcontainers-go) を利用できます。

package repository_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)

func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}

ctx := context.Background()

ctr, err := postgres.Run(ctx, "postgres:16-alpine")
testcontainers.CleanupContainer(t, ctr)
require.NoError(t, err)

dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)

// dsn を使って接続し、マイグレーション後にリポジトリをテストする
_ = dsn
}

testing.Short() と go test -short を組み合わせると、時間のかかる統合テストを開発中だけスキップできます。コンテナーの起動にはコンテナーランタイム (Docker など) が必要です。

4.6 ベンチマーク​

BenchmarkDotNet では属性とランナーを用意しますが、Go では Benchmark で始まる関数を書くだけです。

package calc

import "testing"

func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}

Go 1.24 以降は b.Loop() も使えます。

func BenchmarkAddLoop(b *testing.B) {
for b.Loop() {
Add(1, 2)
}
}

実行時は -bench で対象を指定します。通常のテストは -run '^$' で省略できます。

# 全ベンチマークをメモリ割り当て付きで実行
go test -run '^$' -bench . -benchmem ./...

出力にはイテレーションあたりの時間 (ns/op)、確保したバイト数 (B/op)、アロケーション回数 (allocs/op) が表示されます。変更前後の比較には benchstat が便利です。

4.7 Fuzzing​

Go 1.18 以降は、ファジングテストが標準で使えます。予期しない入力で壊れる箇所を、自動生成した入力で探せます。

package strutil

import (
"testing"
"unicode/utf8"
)

func FuzzReverse(f *testing.F) {
// シードコーパス
f.Add("hello")
f.Add("日本語")

f.Fuzz(func(t *testing.T, s string) {
if !utf8.ValidString(s) {
t.Skip()
}
// 2回反転すると元に戻るという性質を検証する
if got := Reverse(Reverse(s)); got != s {
t.Errorf("Reverse(Reverse(%q)) = %q", s, got)
}
})
}
# 30 秒間ファジングを実行
go test -fuzz FuzzReverse -fuzztime 30s

失敗した入力は testdata/fuzz/ 以下に保存されます。以降は通常の go test で回帰テストとして再実行されます。

5. まとめ​

  • Go のテストは _test.go、TestXxx(t *testing.T)、go test だけで動き、属性やテストランナーの設定は不要です。
  • [Theory] に相当するのはテーブル駆動テストです。t.Run でサブテストにすると、個別実行や並列化がしやすくなります。
  • アサーションは標準では if と t.Errorf で書きます。読みやすさが必要なら testify、構造体の差分比較には go-cmp を使います。
  • モックフレームワークより、利用側で定義した小さなインターフェースと手書きの fake を優先します。検証は呼び出し回数ではなく、結果や状態に対して行います。
  • HTTP は httptest、実 DB を使う統合テストは Testcontainers を使います。重いテストは testing.Short() で切り替えられます。
  • go test -race -cover ./... を CI の基本形にし、性能は testing.B、入力の堅牢性は Fuzzing で検証します。

参考資料​