# 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: 2026-06-28
- Author: 友田 陽大
- Tags: Go, Echo, テスト, 型安全, アーキテクチャ設計, 可観測性
- URL: https://tomodahinata.com/en/blog/go-echo-testing-strategy-httptest-echotest-testcontainers-guide
- Category: Go & Echo in production
- Pillar guide: https://tomodahinata.com/en/blog/go-echo-framework-production-guide

## Key points

- An Echo handler is a plain function that takes *echo.Context, so you can test it as-is with httptest + e.NewContext. This is the source of testability.
- v5's echotest.ContextConfig.ServeWithHandler cuts boilerplate. You can declaratively assemble headers, a JSON body, and path parameters.
- The key to fast tests is mocks. Keep the Repository an interface and you can test every branch of the UseCase without a DB (the payoff of clean architecture).
- Test DB behavior by standing up a real Postgres with testcontainers-go for integration tests. SQL correctness can't be verified with mocks.
- Test pyramid: unit (many, fast) → integration (medium) → E2E (few). Gate with go test -race -cover and golangci-lint in CI.

---

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](/blog/go-echo-framework-production-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](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide) layer. The material is the restaurant-matching backend where I [actually implemented unit and integration tests](/case-studies/restaurant-matching).

> **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.**

```text
        ▲  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.**

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

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

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

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

```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` 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.

```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](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide) and you can test the UseCase **without a 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)
	})
}
```

> **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.

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

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.**"

```yaml
# .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](/blog/ai-driven-development-quality-gates-ci-type-safety-test-security). In the actual project too, I made `go test` / `golangci-lint` [GitHub Actions required checks](/case-studies/restaurant-matching) and auto-verified per PR.

---

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

[Authorization (RBAC, owner check)](/blog/go-echo-jwt-authentication-authorization-rbac-refresh-token-guide#5-認可rbacロールで操作を制限する) 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`.

```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 ルートを叩けない
}
```

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](/blog/go-echo-clean-architecture-dependency-injection-google-wire-guide); the big picture is the [production-operations guide](/blog/go-echo-framework-production-guide).
