Learn Echo - Testing & Benchmark
Series/Learn Echo/Episode 17
Episode 17 of 23

Learn Echo - Testing & Benchmark

This episode tests the Echo application: unit testing handlers with echo.New and httptest, testing middleware directly, mocking services with interfaces, and testing.B benchmarks to measure allocation and route latency.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

One of Echo's biggest advantages is how easy it is to test. Because it stands on top of net/http, all of Go's testing tooling works without adaptation. The layered architecture from episode 8 also lets each layer be tested separately.

Episode 17 tests the Echo application: unit testing handlers with echo.New() and httptest, testing middleware, mocking services with interfaces, and benchmarking with testing.B to measure allocation and latency.

Unit Testing Handlers

echo.New() and httptest

Echo handlers are tested by creating an Echo instance, registering a route, and sending a fake request through httptest:

First handler test
func TestHello(t *testing.T) {
	e := echo.New()
	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "hello")
	})
 
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rec := httptest.NewRecorder()
	e.ServeHTTP(rec, req)
 
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
	}
	if rec.Body.String() != "hello" {
		t.Fatalf("body = %q, ingin %q", rec.Body.String(), "hello")
	}
}

httptest.NewRequest creates a fake request, httptest.NewRecorder captures the response. e.ServeHTTP(rec, req) runs the entire pipeline — router, middleware, handler — exactly like a real request.

Testing with a JSON Body

For handlers that receive payloads, attach a body and headers:

Testing with a JSON payload
func TestCreateUser(t *testing.T) {
	e := echo.New()
	e.Validator = &CustomValidator{validator: validator.New()}
	e.POST("/users", createUserHandler)
 
	body := strings.NewReader(`{"name":"Arman","email":"a@dev.id"}`)
	req := httptest.NewRequest(http.MethodPost, "/users", body)
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()
	e.ServeHTTP(rec, req)
 
	if rec.Code != http.StatusCreated {
		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusCreated)
	}
}

Notice that the validator must also be registered on the test instance — the test instance is a full instance, not a slice.

Testing Middleware

Purely Unit Testing Middleware

Middleware is tested directly with a fake context:

Testing middleware directly
func TestSecurityHeaders(t *testing.T) {
	e := echo.New()
	handler := securityHeaders()(func(c echo.Context) error {
		return c.String(http.StatusOK, "ok")
	})
 
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)
 
	if err := handler(c); err != nil {
		t.Fatalf("handler gagal: %v", err)
	}
	if rec.Header().Get("X-Frame-Options") != "DENY" {
		t.Fatal("X-Frame-Options tidak terpasang")
	}
}

e.NewContext creates an Echo context from a fake request and recorder — a direct way to test a single middleware without a running server.

Mocking Services

Interfaces Make Mocks Easy

The episode 8 architecture makes mocking simple: the service accepts a UserRepository interface, and the test simply provides a stub implementation:

Mock repository for handler tests
type mockRepo struct{}
 
func (m *mockRepo) FindByID(ctx context.Context, id int) (*model.User, error) {
	if id != 42 {
		return nil, errors.New("user tidak ditemukan")
	}
	return &model.User{ID: 42, Name: "Arman"}, nil
}
 
func TestGetUser(t *testing.T) {
	svc := service.NewUserService(&mockRepo{})
	h := handler.NewUserHandler(svc)
 
	e := echo.New()
	h.RegisterRoutes(e.Group("/api/v1"))
 
	req := httptest.NewRequest(http.MethodGet, "/api/v1/users/42", nil)
	rec := httptest.NewRecorder()
	e.ServeHTTP(rec, req)
 
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, ingin %d", rec.Code, http.StatusOK)
	}
}

The handler is tested with no database at all. &mockRepo{} defines the data behavior, so the handler logic can be verified in isolation.

Benchmarking Routes

testing.B for Measuring Performance

Benchmarks measure route allocation and latency. The pattern is almost identical to tests, only using testing.B:

Route benchmark
func BenchmarkHello(b *testing.B) {
	e := echo.New()
	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "hello")
	})
 
	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rec := httptest.NewRecorder()
 
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		e.ServeHTTP(rec, req)
	}
}

Run it:

Running tests and benchmarks
go test -v ./...
go test -bench=. -benchmem ./internal/handler/

go test -bench=. -benchmem shows the number of allocations per operation. Watch two numbers: allocations per op and the byte count — both are optimization targets in episode 19.

Closing

Episode 17 tests the Echo application from every angle: unit testing handlers with echo.New() and httptest, testing middleware through e.NewContext, mocking services with interfaces, and testing.B benchmarks to measure latency and allocation.

Key takeaways:

  • Handlers are tested with httptest.NewRequest and httptest.NewRecorder.
  • The test Echo instance is a full instance, including the validator.
  • e.NewContext allows testing middleware directly.
  • Interfaces make mocking services easy without a database.
  • Benchmarks use testing.B with repeated e.ServeHTTP calls.
  • -benchmem measures allocations, the optimization target in episode 19.
  • Run go test -race to detect data races.

In episode 18 next, we'll discuss observability & monitoring — Prometheus metrics, tracing with OpenTelemetry, structured logs with slog, health check endpoints, and dashboard and alerting integration for latency, error rate, and throughput.