Skip to content
Modular feature test isolation

Modular feature test isolation

As domains decouple from the monolith, each modular feature needs a testing approach that matches the architecture: every feature testable on its own, against its contracts, without booting the monolith or any neighbouring service. This page sets out that strategy. It is the pattern proven at AWS and CircleCI, and the one Theseus steers every modular feature team towards.

The problem with spinning up the world

GitLab today tends to spin up everything to run functional end-to-end tests in CI before a change lands in production. Teams reach for it because it is the established pattern and it buys real confidence — but it is enormously costly in compute and time, and as the monolith decomposes into bounded-context domains it simply does not scale.

Worse, it quietly promotes coupling. When the test for your service requires five other services to be running, nothing forces the boundary between you and them to be explicit, strongly tested, and unbreakable. A regression in shared test scaffolding fans out across every dependent feature — the same failure mode as a broken master, reproduced at platform scale.

Desired outcomes

  • Stronger test coverage — a far wider range of failure cases and scenarios covered per feature.
  • Faster iteration — teams operate in true bounded contexts and change code with confidence that they cannot break other parts of the system.
  • Increased product stability.
  • Lower CI cost — no fleet of environments spun up per pipeline.
  • Improved mean time to production.

The strategy

1. Establish your bounded context

Scope each modular feature to an explicit bounded context. It can span multiple domains where appropriate, but the mapping must be deliberate — services must not accrete unrelated functionality because it is convenient.

The bounded context defines the contracts your feature has with the rest of the system: the APIs it exposes (HTTP or gRPC) and the APIs it consumes. These edges are where the strongest, most comprehensive testing belongs.

2. Isolate dependencies behind configurable clients

Everything your service calls — auth, other modular features, anything that is not yours — lives behind a client package with a small, stable interface, kept outside the three-layer structure. Each client’s endpoint is configurable, via LabKit configuration or an environment variable. That single property is what makes isolation possible: a test can point the client anywhere.

3. Write acceptance tests against fakes

With configurable clients, a test can stand up in-process fakes for every service dependency, boot the real application against them, and drive it over real listeners. Infrastructure dependencies stay real — Caproni provides the same PostgreSQL and Redis the service uses in production, with migrations applied:

    flowchart LR
    subgraph test [Single test context]
        AF[auth fake<br/>/api/v1/authorize]
        DF[dependency fake<br/>/api/v2/hello]
        APP[application under test]
        APP -->|configured base URL| AF
        APP -->|configured base URL| DF
    end
    subgraph caproni [Caproni]
        PG[(PostgreSQL)]
        RD[(Redis)]
    end
    APP --> PG
    APP --> RD
  

The fake is not a stub that returns canned data and nothing more — it is where some of the strongest assertions live. When the application under test calls the auth service, the fake asserts on the shape, headers, and contents of that outbound request, then responds with exactly the case the test is exercising:

authService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // Assert every aspect of the request your contract promises:
    // method, path, headers, body shape, body contents.
    w.WriteHeader(http.StatusForbidden)
}))
defer authService.Close()

// Boot the real stack, pointing the auth client at the fake.
t.Setenv("AUTH_SERVICE_URL", authService.URL)

The companion how-to guide walks through the full working pattern.

Why this works

  • Dependency failures become testable. You control the fake, so “what happens when auth is unreachable?” and “does my client’s retry logic work?” are ordinary test cases. Modelling those failures in a spun-up-world E2E suite is close to impossible.
  • Assertions get stronger. You validate that the request reaching a dependency is exactly right — shape and contents — instead of assuming it is and discovering the gap as a production regression.
  • Isolation is tunable. Model a dependency per-test when each response variant matters, or share one long-lived fake across a suite that always expects passing auth.
  • Flakiness drops. Every failure mode in the suite is one you explicitly scripted. There are no transitive failures from a neighbouring service having a bad day, so infrastructure failure cases can be tested comprehensively without breeding flaky tests.

Above all: each modular feature is fully decoupled from its dependencies. Dependencies are black boxes, and the tests encode the contract they expose.

Anti-fragility

Because failure modes are scriptable, incident remediation gains a compounding step: reproduce the incident’s failure mode as an acceptance test, fix it, and keep the test. The system grows more robust with every incident — something today’s setup makes incredibly difficult to even simulate.

Trust but verify

Full end-to-end tests that spin up the entirety of GitLab still have a place — covering key golden paths at release cuts, or validating version compatibility for Dedicated releases. The strategy makes them the exception rather than the default, not extinct.

The Developer Experience team is investigating first-class LabKit support for in-test configuration and service running, which will add DX sugar on top of this pattern. It is not a blocker: everything above works today, as the how-to guide and the go-service-template exemplar demonstrate.
Last updated on