メインコンテンツへスキップ
友田 陽大
Go・Echo 本番運用
Go
Echo
テスト
型安全
アーキテクチャ設計
可観測性

Echo テスト戦略 完全ガイド:httptest・echotest・モック・testcontainers で速くて壊れないテストを書く

Go Echo(v5)のテスト戦略を本番品質で設計するガイド。テーブル駆動テスト、httptest と e.NewContext によるハンドラ単体テスト、v5 新搭載の echotest ヘルパ、インターフェースのモックによる UseCase 単体テスト、testcontainers-go による実 DB 統合テスト、CI での -race/-cover とテストピラミッドまでを実コードで解説します。

公開日
読了時間
8分
著者
友田 陽大
シェア

「テストを書く時間がない」の正体は、たいてい「テストを書きにくい設計」です。ハンドラに DB と業務ロジックが絡みついていると、1つのテストに本物の DB とネットワークが要り、遅く・脆く・誰も書かなくなります。逆に、設計が正しければテストは速く・安く・たくさん書けます。

この記事は、Go Echo 本番運用ガイドのテスト編です。Echo のハンドラが素の関数であることを最大限に活かし、クリーンアーキテクチャの層ごとに適切なテストを当てる戦略を、実コードで解説します。題材は、実際にユニット・統合テストを実装した飲食店マッチングのバックエンドです。

この記事のルール:Echo の API は 公式ドキュメント(v5・2026年6月時点) に基づきます。testcontainers-gotestify 等は更新されるため公式で最新を確認してください。テスト方針は本番運用で実証済みの形に整えています。


0. 戦略:テストピラミッドで「どこを何でテストするか」を決める

すべてを E2E で確かめるのは遅くて壊れやすく、すべてをモックで済ますと SQL の誤りを見逃します。層ごとに道具を変えるのが正解です。

        ▲  E2E(少数・遅い)         … 主要導線を本物に近い構成で
       ───
      統合テスト(中程度)          … Repository × 実DB(testcontainers)
     ─────
   単体テスト(多数・高速)         … UseCase(モック)/ ハンドラ(httptest)
何を検証道具速度
UseCase 単体業務ロジックの全分岐モック(インターフェース差替)最速
ハンドラ単体HTTP 入出力・ステータスhttptest / echotest
Repository 統合SQL の正しさtestcontainers-go(実 Postgres)
E2E主要導線の通し起動済みサーバー

1. ハンドラ単体テスト:httptest + e.NewContext

Echo のハンドラは *echo.Context を受け取る素の関数です。標準の net/http/httptest でリクエスト/レコーダを作り、e.NewContext で Context を組めば、サーバーを起動せずにテストできます。

func TestUserHandler_Create(t *testing.T) {
	e := echo.New()
	e.Validator = &CustomValidator{validator: validator.New()}

	body := `{"name":"Hinata","email":"hinata@example.com"}`
	req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)

	h := &UserHandler{uc: newUseCaseWithMockRepo()} // 依存はモック

	if assert.NoError(t, h.Create(c)) {
		assert.Equal(t, http.StatusCreated, rec.Code)
		assert.JSONEq(t, `{"id":"...","name":"Hinata"}`, rec.Body.String())
	}
}

パスパラメータが要るハンドラは、Context に手で設定します。

req := httptest.NewRequest(http.MethodGet, "/users/42", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("42") // c.Param("id") == "42"

2. echotest:v5 のヘルパでボイラープレートを削る

Echo v5 には新しい echotest パッケージがあり、上記の定型を宣言的に書けます。

import "github.com/labstack/echo/v5/echotest"

func TestUserHandler_Create_WithHelper(t *testing.T) {
	h := &UserHandler{uc: newUseCaseWithMockRepo()}

	rec := echotest.ContextConfig{
		Headers: map[string][]string{
			echo.HeaderContentType: {echo.MIMEApplicationJSON},
		},
		JSONBody: []byte(`{"name":"Hinata","email":"hinata@example.com"}`),
	}.ServeWithHandler(t, h.Create)

	assert.Equal(t, http.StatusCreated, rec.Code)
}

ContextConfigPathValues(パスパラメータ)、フォーム、マルチパートなども宣言的に組めます。httptest を直書きするより意図が読みやすく、テストの保守性が上がります。


3. テーブル駆動テスト:Go の王道で網羅する

入力パターンが多いとき(バリデーション境界など)は、テーブル駆動テストで網羅します。Go で最も推奨される形です。

func TestCreateUserDTO_Validation(t *testing.T) {
	v := validator.New()
	tests := []struct {
		name    string
		body    string
		wantErr bool
	}{
		{"valid", `{"name":"Hinata","email":"a@example.com"}`, false},
		{"missing name", `{"email":"a@example.com"}`, true},
		{"bad email", `{"name":"Hinata","email":"not-an-email"}`, true},
		{"name too short", `{"name":"H","email":"a@example.com"}`, true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var dto CreateUserDTO
			_ = json.Unmarshal([]byte(tt.body), &dto)
			err := v.Struct(dto)
			assert.Equal(t, tt.wantErr, err != nil)
		})
	}
}

t.Run でサブテスト化すると、失敗時にどのケースが落ちたかが一目で分かります。


4. UseCase 単体テスト:モックで全分岐を高速に

テストが速くたくさん書ける最大の理由が、インターフェースのモックです。クリーンアーキテクチャで Repository をインターフェースにしておけば、UseCase をDB なしでテストできます。

// 手書きフェイク(小規模ならこれで十分。KISS)
type fakeUserRepo struct {
	createErr error
	user      *domain.User
}

func (f *fakeUserRepo) Create(ctx context.Context, u *domain.User) error { return f.createErr }
func (f *fakeUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) {
	return f.user, nil
}

func TestRegister(t *testing.T) {
	t.Run("メール重複で ErrEmailTaken", func(t *testing.T) {
		uc := usecase.NewUserUseCase(&fakeUserRepo{createErr: domain.ErrEmailTaken})
		_, err := uc.Register(context.Background(), "Hinata", "dup@example.com")
		assert.ErrorIs(t, err, domain.ErrEmailTaken)
	})
	t.Run("正常系", func(t *testing.T) {
		uc := usecase.NewUserUseCase(&fakeUserRepo{})
		u, err := uc.Register(context.Background(), "Hinata", "a@example.com")
		assert.NoError(t, err)
		assert.Equal(t, "Hinata", u.Name)
	})
}

手書きフェイク vs モック生成(go.uber.org/mock 等):インターフェースが小さく数が少ないなら手書きフェイクが読みやすく(KISS/YAGNI)、大規模・多数なら生成ツールで省力化します。プロジェクトの規模で選びます。


5. Repository 統合テスト:testcontainers-go で実 DB を使う

SQL の正しさはモックでは検証できません。 スキーマ・制約・トランザクション・実際のクエリ結果は、本物の Postgresでしか確かめられません。testcontainers-go なら、テスト実行時に Docker で使い捨ての Postgres を立てられます。

import (
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
)

func setupPostgres(t *testing.T) *pgxpool.Pool {
	ctx := context.Background()
	pgC, err := postgres.Run(ctx, "postgres:16-alpine",
		postgres.WithDatabase("test"),
		postgres.WithUsername("test"),
		postgres.WithPassword("test"),
		testcontainers.WithWaitStrategy(wait.ForListeningPort("5432/tcp")),
	)
	require.NoError(t, err)
	t.Cleanup(func() { _ = pgC.Terminate(ctx) }) // テスト後に破棄

	dsn, _ := pgC.ConnectionString(ctx, "sslmode=disable")
	pool, err := pgxpool.New(ctx, dsn)
	require.NoError(t, err)
	runMigrations(t, dsn) // スキーマを適用
	return pool
}

func TestUserRepository_CreateAndFind(t *testing.T) {
	if testing.Short() {
		t.Skip("skip integration test in -short")
	}
	pool := setupPostgres(t)
	repo := infra.NewUserRepository(pool)

	err := repo.Create(context.Background(), &domain.User{ID: "u1", Name: "Hinata", Email: "a@example.com"})
	require.NoError(t, err)

	got, err := repo.FindByID(context.Background(), "u1")
	require.NoError(t, err)
	assert.Equal(t, "Hinata", got.Name)
}

testing.Short()-short 時にスキップできるようにし、速い単体テストと遅い統合テストを分離します。ローカルやプレ commit は単体中心、CI では統合まで回す、という使い分けが定番です。


6. CI ゲート:-race / -cover / golangci-lint を必須化

テストは「書く」だけでなく「CI で必ず通す」ことで初めて品質ゲートになります。

# .github/workflows/ci.yml(抜粋)
- name: Test
  run: go test ./... -race -coverprofile=coverage.out -covermode=atomic
- name: Lint
  run: golangci-lint run ./...
  • -race:データ競合検出。Go の並行バグは本番でしか出ないことが多く、CI での -race が唯一の現実的な防波堤。
  • -cover:カバレッジ計測。数字を目的化しないが、重要パスの未テストを可視化する。
  • golangci-lint:静的解析(go vet・未使用・nil 参照可能性など)。テストと並ぶ品質ゲート。

このゲート設計の思想(型・テスト・静的解析・セキュリティを CI で機械的に強制する)は、AI駆動開発の品質ゲートで体系化しています。実プロジェクトでも go test/golangci-lintGitHub Actions の必須チェックにし、PR ごとに自動検証していました。


7. ミドルウェア・認可のテスト:境界を回帰検証する

認可(RBAC・所有者チェック)は、壊れても 200 が返ってしまうため気づきにくい。だからこそテストで固定します。ミドルウェアを通した状態をテストするには、e.ServeHTTP でルーティングごと検証します。

func TestRequireRole_Forbidden(t *testing.T) {
	e := echo.New()
	g := e.Group("/admin")
	g.Use(injectClaims(&AccessClaims{Roles: []string{"user"}})) // 一般ユーザー
	g.Use(RequireRole("admin"))
	g.GET("/x", func(c *echo.Context) error { return c.NoContent(http.StatusOK) })

	req := httptest.NewRequest(http.MethodGet, "/admin/x", nil)
	rec := httptest.NewRecorder()
	e.ServeHTTP(rec, req) // ルーター + ミドルウェアを通す

	assert.Equal(t, http.StatusForbidden, rec.Code) // user は admin ルートを叩けない
}

「権限のないユーザーが叩けないこと」を明示的にテストするのが要点です。正常系だけでなく拒否系を回帰テストに入れることで、リファクタで認可が緩んだ瞬間に CI が落ちます。


まとめ:壊れないテストの7原則

  1. テストピラミッドで層ごとに道具を変える(単体・統合・E2E)。
  2. ハンドラは httptest + e.NewContext、v5 なら echotest で簡潔に。
  3. 多入力はテーブル駆動で網羅し、t.Run で失敗箇所を明示。
  4. UseCase はモックで全分岐を高速に。インターフェース設計の見返り。
  5. SQL は testcontainers で実 DB。モックでは SQL の誤りを検出できない。
  6. CI で -race/-cover/golangci-lint を必須ゲートに。
  7. 認可の拒否系を回帰テストし、権限の緩みを CI で捕まえる。

テスト容易性は、後付けの努力ではなく設計の結果です。Echo のハンドラが素の関数であること、Repository をインターフェースで隠すこと——この2つを守れば、テストは自然に書けるようになります。設計の土台はクリーンアーキテクチャ、全体像は本番運用ガイドへ。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

Go / Echo のバックエンドを、設計から本番運用まで承ります

Echo v5 へのAPI設計・移行、クリーンアーキテクチャ(Controller/UseCase/Repository + DI)、ミドルウェアとセキュリティ、集中エラー処理、グレースフルシャットダウン、テストとCIまで。Go/Echo + google/wire で実際にクリーンアーキのバックエンドを構築した知見で、落ちない・追える・変更しやすいAPIを実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい