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)
| Layer | What it verifies | Tool | Speed |
|---|---|---|---|
| UseCase unit | every branch of business logic | mock (interface swap) | fastest |
| Handler unit | HTTP I/O, status | httptest / echotest | fast |
| Repository integration | SQL correctness | testcontainers-go (real Postgres) | medium |
| E2E | main flows end-to-end | a started server | slow |
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-racein 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
- With the test pyramid, change the tool by layer (unit, integration, E2E).
- Handlers with httptest +
e.NewContext, and concisely withechoteston v5. - Cover many inputs with table-driven tests, and make failure spots clear with
t.Run. - Test every branch of the UseCase fast with mocks. The payoff of interface design.
- SQL with a real DB via testcontainers. Mocks can't detect SQL errors.
- Make
-race/-cover/golangci-lint mandatory gates in CI. - 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.