Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
テスト
型安全
アーキテクチャ設計
可観測性

Complete Echo testing-strategy guide: writing fast, unbreakable tests with httptest, echotest, mocks, and testcontainers

A guide to designing Go Echo (v5) testing strategy at production quality. With real code it covers table-driven tests, handler unit tests with httptest and e.NewContext, the echotest helper newly built into v5, UseCase unit tests with interface mocks, real-DB integration tests with testcontainers-go, and -race/-cover in CI plus the test pyramid.

Published
Reading time
7 min read
Author
友田 陽大
Share

The true identity of "no time to write tests" is usually "a design that makes tests hard to write." When a handler is entangled with the DB and business logic, one test needs a real DB and network, so it's slow, brittle, and nobody writes it. Conversely, if the design is right, tests can be written fast, cheap, and in abundance.

This article is the testing chapter of the Go Echo production-operations guide. It explains, with real code, a strategy of maximally leveraging the fact that Echo's handler is a plain function and applying appropriate tests to each clean-architecture layer. The material is the restaurant-matching backend where I actually implemented unit and integration tests.

Rules for this article: Echo's API is based on the official documentation (v5, as of June 2026). testcontainers-go, testify, etc., are updated, so confirm the latest in the official docs. The testing policy is arranged in a form proven in production operation.


0. Strategy: decide "what to test with what" with the test pyramid

Verifying everything with E2E is slow and brittle, and doing everything with mocks misses SQL errors. The right answer is to change the tool by layer.

        ▲  E2E (few, slow)            … main flows in a near-production setup
       ───
      integration tests (medium)      … Repository × real DB (testcontainers)
     ─────
   unit tests (many, fast)            … UseCase (mock) / handler (httptest)
LayerWhat it verifiesToolSpeed
UseCase unitevery branch of business logicmock (interface swap)fastest
Handler unitHTTP I/O, statushttptest / echotestfast
Repository integrationSQL correctnesstestcontainers-go (real Postgres)medium
E2Emain flows end-to-enda started serverslow

1. Handler unit test: httptest + e.NewContext

An Echo handler is a plain function that takes *echo.Context. Create a request/recorder with the standard net/http/httptest and assemble the Context with e.NewContext, and you can test it without starting a server.

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())
	}
}

A handler that needs path parameters sets them on the Context by hand.

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: cut boilerplate with v5's helper

Echo v5 has a new echotest package, with which you can write the above boilerplate declaratively.

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 can also declaratively assemble PathValues (path parameters), forms, multipart, and more. It's more readable in intent than writing httptest by hand, and improves test maintainability.


3. Table-driven tests: cover with Go's royal road

When there are many input patterns (validation boundaries, etc.), cover them with table-driven tests. It's the most recommended form in 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)
		})
	}
}

Making subtests with t.Run makes it clear at a glance which case failed on failure.


4. UseCase unit test: every branch fast with mocks

The biggest reason tests can be written fast and in abundance is interface mocks. Keep the Repository an interface with clean architecture and you can test the UseCase without a 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)
	})
}

Hand-written fakes vs. mock generation (go.uber.org/mock, etc.): if the interfaces are small and few, hand-written fakes are more readable (KISS/YAGNI); if large-scale and numerous, save effort with a generation tool. Choose by the project's scale.


5. Repository integration test: use a real DB with testcontainers-go

SQL correctness can't be verified with mocks. Schemas, constraints, transactions, and the actual query results can only be confirmed with a real Postgres. With testcontainers-go, you can stand up a disposable Postgres in Docker at test time.

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

Make it skippable on -short with testing.Short() to separate fast unit tests from slow integration tests. The standard division is unit-centric locally and pre-commit, with integration also run in CI.


6. CI gate: make -race / -cover / golangci-lint mandatory

Tests become a quality gate only by not just "writing" them but "always passing them in CI."

# .github/workflows/ci.yml(抜粋)
- name: Test
  run: go test ./... -race -coverprofile=coverage.out -covermode=atomic
- name: Lint
  run: golangci-lint run ./...
  • -race: data-race detection. Go's concurrency bugs often appear only in production, and -race in CI is the only realistic breakwater.
  • -cover: coverage measurement. Don't make the number an end, but visualize untested important paths.
  • golangci-lint: static analysis (go vet, unused, nil-deref possibility, etc.). A quality gate alongside tests.

The philosophy of this gate design (mechanically enforcing types, tests, static analysis, and security in CI) is systematized in AI-driven development's quality gates. In the actual project too, I made go test / golangci-lint GitHub Actions required checks and auto-verified per PR.


7. Testing middleware / authorization: regression-verify the boundary

Authorization (RBAC, owner check) is hard to notice because even when broken, 200 gets returned. That's exactly why you fix it with tests. To test the state through the middleware, verify it with routing via 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 ルートを叩けない
}

The crux is to explicitly test "that an unauthorized user can't hit it." By putting the rejection cases, not only the happy path, into regression tests, CI fails the moment a refactor loosens authorization.


Conclusion: 7 principles of unbreakable tests

  1. With the test pyramid, change the tool by layer (unit, integration, E2E).
  2. Handlers with httptest + e.NewContext, and concisely with echotest on v5.
  3. Cover many inputs with table-driven tests, and make failure spots clear with t.Run.
  4. Test every branch of the UseCase fast with mocks. The payoff of interface design.
  5. SQL with a real DB via testcontainers. Mocks can't detect SQL errors.
  6. Make -race/-cover/golangci-lint mandatory gates in CI.
  7. Regression-test the rejection cases of authorization and catch loosened permissions in CI.

Testability is not a bolted-on effort but a result of design. Keep two things — that the Echo handler is a plain function, and hiding the Repository behind an interface — and tests become naturally writable. The foundation of design is clean architecture; the big picture is the production-operations guide.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading