Traefik — Testing#

Test metrics#

  • Test files: 255 (excluding vendor; 39 of these are in integration/)
  • Source files (non-test): ~483 Go files
  • Ratio (test files / source files): ~0.53 — well above average for a project this size
  • Test frameworks: testify/assert + testify/require (universal), testify/suite (integration layer), stdlib testing (for all unit tests)

Test organization#

  • Placement: Almost exclusively same-package tests (254 of 255 test files use the package’s own name, not a _test suffix). The one external _test package is the rare exception, not the rule.
  • Helper packages:
    • pkg/testhelpers/ — functional-option config builders (BuildConfiguration, WithRouters, WithServices, WithMiddlewares, etc.) that compose dynamic.HTTPConfiguration objects for unit tests. These use the same functional-options idiom documented in the patterns analysis.
    • integration/try/ — a polling/retry helper with typed ResponseCondition functions (StatusCodeIs, BodyContains, BodyNotContains, etc.) and CITimeoutMultiplier support for slower CI environments. Wraps HTTP polling behind try.GetRequest / try.Request / try.Do.
  • Fixtures: testdata/ directories under pkg/api/ and pkg/redactor/ contain JSON snapshots of expected API responses. Tests load these files and compare against actual output. The --update_expected flag (via flag.Bool("update_expected", false, ...)) allows regenerating golden files in-place — a standard Go golden file pattern.

Test patterns#

Table-driven tests#

  • Prevalence: Heavy — 770 occurrences of testCases, tt.Run, tc.name, tc.expected across test files.
  • Style: Anonymous struct slice is the default. Struct fields are desc (description) + inputs + expected outputs. Named struct types appear occasionally in larger test suites.
  • Example: pkg/middlewares/ratelimiter/rate_limiter_test.goTestNewRateLimiter defines a testCases []struct{ desc string; config dynamic.RateLimit; expectedMaxDelay time.Duration; ... } slice covering a dozen scenarios. pkg/server/configurationwatcher_test.go uses the same pattern for config-reload scenarios.

Mocking approach#

  • Strategy: Manual inline mock structs implementing the relevant interface. No gomock, mockery, or other code-generation tool is used anywhere.
  • Example: pkg/server/configurationwatcher_test.go defines mockProvider as a local struct that satisfies the Provider interface by implementing Provide(), ThrottleDuration(), and Init(). The struct holds pre-canned messages []dynamic.Message to replay. This pattern repeats throughout: each test file defines its own minimal mock for exactly the interfaces it needs, keeping mocks co-located with their tests.

HTTP testing#

  • Prevalence: Heavy — 417 occurrences of httptest.NewRecorder or httptest.NewServer.
  • Pattern: Middleware tests follow a standard shape: create an httptest.ResponseRecorder, wrap a handler under test with the middleware, fire an http.Request, and assert on the recorded response. The pkg/testhelpers config builders keep test setup concise.

Parallel tests#

  • Usage: Moderate — 324 occurrences of t.Parallel(). Unit tests in hotly-contested packages (middlewares, providers) are parallelized; integration tests are not.

Integration tests#

  • Present: Yes — 39 test files in integration/, covering Docker, Consul, etcd, Redis, k8s (k3s), ACME, gRPC, WebSocket, TCP, UDP, TLS, tracing, rate limiting, headers, routing, and more.
  • How: testcontainers-go manages Docker containers programmatically. The BaseSuite struct (embedding testify/suite.Suite) handles Docker network lifecycle, container creation from YAML compose fixtures, container teardown, and log capture on failure. The actual Traefik binary is launched as a subprocess (exec.Command) pointing at config fixtures in integration/fixtures/. Tests poll http://127.0.0.1:8080/api/rawdata via try.Request to confirm Traefik is ready before asserting on routing behavior.
  • Separation: All integration tests live in the top-level integration/ directory. No build tags are used; separation is purely by directory. Each feature domain gets its own _test.go file (docker_test.go, consul_test.go, redis_test.go, etc.), each declaring its own Suite struct and TestXxxSuite(t *testing.T) entry point.
  • Conformance tests: Two additional conformance-level test suites exist: integration/gateway_api_conformance_test.go (Gateway API) and integration/knative_conformance_test.go, each driven by separate CI workflows.

Test quality observations#

What’s done well#

  • Coverage breadth: The integration suite spans nearly every provider and protocol Traefik supports. Testing the real binary against real containers (via testcontainers-go) rather than mocking the runtime gives high confidence that the full system works.
  • Testhelper ergonomics: pkg/testhelpers provides a clean DSL for building complex dynamic.Configuration objects in unit tests, avoiding repetitive struct literal boilerplate. The functional-option builder makes test intentions explicit.
  • Golden file pattern: The --update_expected flag in API tests allows regenerating JSON fixtures with a single command, keeping expected output under version control without manual editing. This is a maintainability win for serialization-heavy tests.
  • try package design: The integration/try package encodes polling idioms (with exponential backoff, CI multiplier, and typed response conditions) as composable functions. This prevents ad-hoc time.Sleep loops in individual test files and centralizes retry semantics.
  • Table-driven discipline: 770 table-driven test cases across the codebase — the team has consistently resisted writing one-test-per-scenario functions, keeping test files concise and the full case matrix visible at a glance.
  • t.Parallel() adoption: 324 uses of t.Parallel() significantly reduce unit test suite wall-clock time without requiring external tooling.

What could improve#

  • Mock proliferation: Each test file defines its own ad-hoc mock structs. With 255 test files, there is significant duplication — several files define nearly identical mockProvider or mockHandler types. A shared pkg/testhelpers/mocks package would reduce boilerplate and keep mock contracts synchronized with interfaces.
  • Integration test locality: Integration tests run against a real Traefik binary (../dist/linux/amd64/traefik), which requires a prior build step. This creates friction for local development and can cause confusing failures when the binary is stale. An in-process integration layer (running the server in a goroutine) would be faster and eliminate the build dependency.
  • No fuzz tests: Given Traefik parses untrusted rule expressions (Host(...), PathPrefix(...), etc.) and provider configs, fuzz testing the rule parser and config loaders would meaningfully increase security and reliability confidence. None exists.
  • CITimeoutMultiplier as a smell: The explicit CITimeoutMultiplier = 3 in integration/try/try.go indicates timing-sensitive tests that could be made deterministic by polling until a condition is met rather than sleeping.

Patterns worth emulating (for the book)#

  1. testify/suite for integration test lifecycle — The BaseSuite + named XxxSuite pattern cleanly handles SetupSuite/TearDownSuite/TearDownTest without global state. Each suite manages its own containers, so suites can run concurrently at the file level.
  2. try package as a first-class test concern — Encapsulating polling/retry as composable ResponseCondition functions is a reusable pattern for any project that tests eventually-consistent state.
  3. Functional-option config builders in testhelpers — Using the same functional-option idiom in test DSLs as in production code creates consistency and makes test setup readable without fighting Go’s verbose struct literals.
  4. Golden file with -update flag — The flag.Bool("update_expected", false, ...) pattern for snapshot tests is well-understood, version-control-friendly, and trivially adaptable to any serialization format.