「テストを書く時間がない」の正体は、たいてい「テストを書きにくい設計」です。ハンドラに DB と業務ロジックが絡みついていると、1つのテストに本物の DB とネットワークが要り、遅く・脆く・誰も書かなくなります。逆に、設計が正しければテストは速く・安く・たくさん書けます。
この記事は、Go Echo 本番運用ガイドのテスト編です。Echo のハンドラが素の関数であることを最大限に活かし、クリーンアーキテクチャの層ごとに適切なテストを当てる戦略を、実コードで解説します。題材は、実際にユニット・統合テストを実装した飲食店マッチングのバックエンドです。
この記事のルール:Echo の API は 公式ドキュメント(v5・2026年6月時点) に基づきます。
testcontainers-go・testify等は更新されるため公式で最新を確認してください。テスト方針は本番運用で実証済みの形に整えています。
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)
}
ContextConfig は PathValues(パスパラメータ)、フォーム、マルチパートなども宣言的に組めます。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-lint を GitHub 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原則
- テストピラミッドで層ごとに道具を変える(単体・統合・E2E)。
- ハンドラは httptest +
e.NewContext、v5 ならechotestで簡潔に。 - 多入力はテーブル駆動で網羅し、
t.Runで失敗箇所を明示。 - UseCase はモックで全分岐を高速に。インターフェース設計の見返り。
- SQL は testcontainers で実 DB。モックでは SQL の誤りを検出できない。
- CI で
-race/-cover/golangci-lint を必須ゲートに。 - 認可の拒否系を回帰テストし、権限の緩みを CI で捕まえる。
テスト容易性は、後付けの努力ではなく設計の結果です。Echo のハンドラが素の関数であること、Repository をインターフェースで隠すこと——この2つを守れば、テストは自然に書けるようになります。設計の土台はクリーンアーキテクチャ、全体像は本番運用ガイドへ。