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

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

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Go, Echo, テスト, 型安全, アーキテクチャ設計, 可観測性
- URL: https://tomodahinata.com/blog/go-echo-testing-strategy-httptest-echotest-testcontainers-guide
- カテゴリ: Go・Echo 本番運用
- 総合ガイド: https://tomodahinata.com/blog/go-echo-framework-production-guide

## 要点

- Echo ハンドラは *echo.Context を受ける素の関数なので httptest + e.NewContext でそのままテストできる。これがテスト容易性の源泉
- v5 の echotest.ContextConfig.ServeWithHandler でボイラープレートを削減。ヘッダ・JSONボディ・パスパラメータを宣言的に組める
- 速いテストの鍵はモック。Repository をインターフェースにしておけば UseCase を DB なしで全分岐テストできる（クリーンアーキの見返り）
- DB の振る舞いは testcontainers-go で実 Postgres を立てて統合テスト。SQLの正しさはモックでは検証できない
- テストピラミッド：単体（多・速）→統合（中）→E2E（少）。CI で go test -race -cover と golangci-lint をゲートにする

---

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

この記事は、[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)のテスト編です。Echo のハンドラが**素の関数**であることを最大限に活かし、[クリーンアーキテクチャ](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide)の層ごとに**適切なテスト**を当てる戦略を、実コードで解説します。題材は、実際に[ユニット・統合テストを実装した飲食店マッチングのバックエンド](/case-studies/restaurant-matching)です。

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

---

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

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

```text
        ▲  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 を組めば、**サーバーを起動せずに**テストできます。

```go
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 に手で設定します。

```go
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`** パッケージがあり、上記の定型を宣言的に書けます。

```go
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)
}
```

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

---

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

入力パターンが多いとき（バリデーション境界など）は、**テーブル駆動テスト**で網羅します。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 単体テスト：モックで全分岐を高速に

テストが速くたくさん書ける最大の理由が、**インターフェースのモック**です。[クリーンアーキテクチャ](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide)で Repository をインターフェースにしておけば、UseCase を**DB なし**でテストできます。

```go
// 手書きフェイク（小規模ならこれで十分。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 を立てられます。

```go
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 で必ず通す**」ことで初めて品質ゲートになります。

```yaml
# .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駆動開発の品質ゲート](/blog/ai-driven-development-quality-gates-ci-type-safety-test-security)で体系化しています。実プロジェクトでも `go test`/`golangci-lint` を [GitHub Actions の必須チェック](/case-studies/restaurant-matching)にし、PR ごとに自動検証していました。

---

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

[認可（RBAC・所有者チェック）](/blog/go-echo-jwt-authentication-authorization-rbac-refresh-token-guide#5-認可rbacロールで操作を制限する)は、**壊れても 200 が返ってしまう**ため気づきにくい。だからこそテストで固定します。ミドルウェアを通した状態をテストするには、`e.ServeHTTP` でルーティングごと検証します。

```go
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つを守れば、テストは自然に書けるようになります。設計の土台は[クリーンアーキテクチャ](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide)、全体像は[本番運用ガイド](/blog/go-echo-framework-production-guide)へ。
