Echo — Testing#

Test metrics#

  • Test files: 46
  • Source files (non-test): 44 (root + middleware combined)
  • Ratio (test files / source files): ~1.05 — effectively 1:1 parity; almost every source file has a dedicated test file
  • Test frameworks: testify/assert and testify/require (stdlib testing as the base); no mocking framework

Test organization#

Placement#

Both internal (same package) and external (_test suffix package). Most root-level tests use package echo (white-box), while 4 files use package echo_test or package echotest_test (black-box). The echotest/ sub-package has internal and external variants (context_test.go vs context_external_test.go). Middleware tests all use package middleware (white-box).

The deliberate mix signals intent: white-box tests for internals (binder dispatch, router tree internals), black-box tests for the public API surface (to catch regressions in exported contracts).

Helper packages#

echotest/ — a first-class public testing helper package shipped with the framework:

  • context.goContextConfig struct with builder-style API for constructing echo.Context values for tests. Supports setting request, response, query params, headers, form values, multipart forms, JSON body, path values, and route info. ToContext(t), ToContextRecorder(t), and ServeWithHandler(t, handler) are the three primary methods.
  • reader.goLoadBytes(t, name) reads fixture files relative to the calling test’s source directory using runtime.Caller(2) to resolve the path. TrimNewlineEnd is a functional option for stripping trailing newlines. This allows test fixtures to live next to the test file, not at a fixed absolute path.

Fixtures#

  • echotest/testdata/ — fixture files for echotest package tests
  • middleware/testdata/ — contains test.json, a dist/ directory, and private.txt for static file serving tests
  • No code generation or embedded fixture binaries; fixtures are plain files loaded at test time

Test patterns#

Table-driven tests#

  • Prevalence: Extremely heavy — 648 matches for t.Run( / testCases / tests := in test files
  • Style: Anonymous struct with descriptive field names. Consistent given/when/expect field naming: givenConfig, whenMethod, whenHeaders, expectHeaders, expectErr (visible in cors_test.go:37–43). This naming scheme makes test case intent self-documenting.
  • Example: middleware/cors_test.go:36TestCORSConfig with 20+ cases covering wildcard origins, credentials, preflight, vary headers, and edge cases. Each case is name + givenConfig + whenXxx + expectXxx fields.

Mocking approach#

  • Strategy: No mocking framework. Two patterns are used:
    1. net/http/httptest directlyhttptest.NewRequest + httptest.NewRecorder are used 640 times across test files. This is the dominant approach for all HTTP-level tests.
    2. echotest.ContextConfig — higher-level wrapper around httptest that removes boilerplate for constructing rich contexts (forms, multipart, JSON bodies, path params). Shipped as a public API for end users writing handler tests.
  • No gomock, mockery, or hand-written mock structs. Dependencies are swapped at the Echo.Config level (e.g., substituting a custom Logger, Binder, or Renderer) using the framework’s own interface slots — tests exercise real implementations, not mocks.

Integration tests#

  • Present: Yes, functionally (no separate _integration_test.go files by name)
  • How: The middleware tests in middleware/ start a real echo.New() instance and run handlers through the full middleware chain using httptest. These tests exercise binding, error handling, header propagation, and response writing end-to-end — more integration than unit. echotest.ContextConfig.ServeWithHandler similarly runs the full handler + error handler path.
  • Separation: No build tags or separate directories. Integration-style tests are co-located with unit tests. The distinction is implicit (table-driven tests with many cases covering end-to-end scenarios vs. narrow unit tests of internals).

Concurrency tests#

  • router_concurrent_test.go — dedicated file for concurrent router correctness (TestConcurrentRouter_ConcurrentReads launches 10 goroutines with sync.WaitGroup + atomic.Int64 counters). Tests ConcurrentRouter wrapper under concurrent reads/writes.
  • t.Parallel() is used in 14 test functions.
  • CI runs all tests with -race (make race) as a required step.

Benchmarks#

  • 10+ benchmark functions in router_test.go and binder_test.go.
  • Router benchmarks use real-world route tables: static routes, GitHub API routes (BenchmarkRouterGitHubAPI), with and without cache misses.
  • Binder benchmarks compare DefaultBinder vs ValueBinder for single and 10-field cases.
  • make benchmark is a documented Makefile target.

CI configuration#

GitHub Actions (.github/workflows/):

  • checks workflow: golint, staticcheck, govulncheck on the latest Go version
  • tests workflow: Matrix of Go versions; runs go test ./... with race detector
  • No test coverage enforcement visible (no coverage thresholds or coverage upload steps)

Test quality observations#

What’s done well#

  • 1:1 test parity — nearly every source file has a test file. Coverage is comprehensive.
  • Public echotest package — Echo ships testing helpers as a first-class library feature. This is unusual and highly valuable: it means downstream users can write clean handler tests without reinventing httptest boilerplate.
  • Consistent table-driven style — the given/when/expect field naming convention across middleware tests reads almost like a BDD spec. Each test case name describes the scenario in plain English.
  • Benchmarks as first-class citizens — router and binder benchmarks use real API route tables (Go stdlib routes, GitHub API routes), not synthetic microbenchmarks. Combined with make race and make benchmark in CI, performance regressions are treated as bugs.
  • No mock sprawl — avoiding mock frameworks keeps tests readable and close to real behavior. The framework’s own interface-slot DI pattern makes test setup clean without needing mocks.
  • runtime.Caller for fixture pathsLoadBytes resolves fixture paths relative to the source file, not the working directory. This is a subtle but important correctness improvement for monorepo or nested test scenarios.

What could improve#

  • No coverage thresholds in CI — there is no go test -coverprofile or coverage gate. The repo relies on test comprehensiveness by convention rather than enforcement.
  • No explicit separation of integration tests — mixing unit and integration-style tests in the same files makes selective test execution (e.g., go test -short) harder. A build tag (//go:build integration) or naming convention would add clarity.
  • t.Parallel() underused — only 14 uses across 46 test files. Given the framework’s concurrency focus, more parallelized tests could catch races earlier in CI.

Patterns worth emulating#

  1. Shipping echotest as a public package — frameworks that provide official test helpers dramatically reduce the cost of writing good tests for users. The ContextConfig.ServeWithHandler pattern in particular is a clean, reusable handler testing primitive.
  2. given/when/expect naming in anonymous struct test cases — self-documenting table test fields that read as specifications.
  3. Using the framework’s own DI slots for test customization — instead of mocking, substitute real test-specific implementations via Config{}. This keeps tests honest and avoids test-only code paths.
  4. Benchmark tables with real-world data — using Go stdlib and GitHub API route sets as benchmark inputs makes performance numbers meaningful and comparable across versions.