How to test microservices without a staging environment

Every team with more than two services eventually builds a staging environment. And every team with a staging environment eventually complains about it.

The complaints are always the same three. It has drifted from production, so passing there means less than it should. It costs real money to keep running. And it is shared, so your test run queues behind whoever deployed last and half your failures turn out to be someone else's half-finished branch.

This post is about the alternative: spinning up a production-like environment per test run, and tearing it down afterwards.

Why staging drifts

Staging starts as a copy of production. Then production changes.

Someone bumps a Postgres version in prod and forgets staging. A service gets a new environment variable that only exists in the prod deployment config. A third-party integration gets swapped from sandbox to live in prod but staging keeps pointing at the sandbox, which behaves subtly differently.

None of these are negligence. They are the natural result of maintaining two environments by hand. The gap opens slowly and nobody notices until a test passes on staging and the same code fails in production.

The deeper problem is that staging is long-lived. Anything long-lived accumulates state: leftover rows from last month's test, a feature flag someone flipped and forgot, a queue with stale messages. Your test runs against that accumulated state, which means it is not really reproducible.

The shared environment problem

The second issue is contention.

One staging environment, several engineers. Someone deploys a branch to debug something. Your CI run starts thirty seconds later and fails. You spend twenty minutes investigating a failure that has nothing to do with your code.

Teams work around this by adding more environments - staging, staging-2, a per-team environment - which multiplies the drift problem and the cost at the same time.

What "on demand" actually means

The alternative is that every test run gets its own environment, created at the start and destroyed at the end.

For that to be useful, the environment needs four things:

Your real services, unmodified. Not stubs and not a simplified version. The actual containers, running the actual code.

A real database. Postgres, MySQL, MongoDB or Redis, seeded to a known state before the test starts. Not an in-memory fake that behaves differently under load or handles transactions differently.

Controlled external dependencies. Stripe, Auth0, Twilio and the rest, mocked so your test does not depend on a third party being up, and so you can force the error paths you would never see against a sandbox.

Isolation. No shared state between runs. Two tests running at the same time cannot interfere with each other.

Get those four and the drift problem disappears, because the environment is defined in a file next to your code. When a service changes, the definition changes in the same commit.

Why Docker Compose gets you most of the way and then stops

Most teams reach for Docker Compose first, and it is a reasonable instinct. It brings up your services and a database in one command.

Where it runs out:

It has no test runner. Compose starts containers. Asserting on behaviour is left entirely to you, in whatever framework you bolt on.

It cannot see traffic between services. When your gateway calls your post service, Compose has no view of that request. If the bug is a malformed header between two internal services, Compose will not help you find it.

Mocking external APIs is manual. You write the mock server, add it to the compose file, override the service's base URL through environment variables, and maintain it as the real API changes.

Browser testing is separate. You end up running Playwright or Cypress alongside, in a different process, with its own configuration and no shared context with the rest of the test.

You can assemble all of this. Plenty of teams have. The result is usually a few hundred lines of YAML, a mock server nobody wants to own, and a test harness that breaks whenever someone adds a service.

What a per-run environment looks like in practice

Dokkimi takes the declarative part of Compose and adds the testing layer around it.

You describe the environment and the tests in the same file:

name: checkout-flow
items:
  - $ref: ../shared/web-app.yaml
  - $ref: ../shared/api-gateway.yaml
  - $ref: ../shared/order-service.yaml
  - $ref: ../shared/postgres-db.yaml
  - $ref: ../shared/mock-stripe.yaml

tests:
  - name: Customer completes checkout
    steps:
      - action:
          type: ui
          url: http://web-app:3000/cart
          subSteps:
            - action: click
              selector: '[data-testid="checkout"]'
            - action: waitForSelector
              selector: '[data-testid="order-confirmed"]'
        assertions:
          - match:
              path: $.traffic
              where:
                - path: $$.request.url
                  operator: contains
                  value: order-service/v1/orders
              count: 1
            assertions:
              - path: $.match.response.status
                operator: eq
                value: 201

      - action:
          type: dbQuery
          database: postgres-db
          query: "SELECT status FROM orders ORDER BY created_at DESC LIMIT 1"
        assertions:
          - path: $.response.data[0].status
            operator: eq
            value: 'confirmed'

dokkimi run creates an isolated Docker namespace with those services, a dedicated Postgres, a headless Chromium, and interceptor sidecars capturing every HTTP call between services. The test runs. Everything is torn down.

Your services run unmodified. Dokkimi handles the sidecars, routing, DNS, browser and cleanup, so there are no code changes on your side.

The $ref pattern matters more than it looks. Shared service definitions live in one place and get referenced across every test, so when a service gains a dependency you update one fragment rather than every file.

Seeding, and why it matters more than people expect

A test is only reproducible if the data it starts from is known.

Dokkimi seeds Postgres, MySQL, MongoDB or Redis before the test runs, from SQL or JS scripts. Every query the services make during the run is intercepted and logged, so when an assertion fails you can see exactly what the service asked the database and what came back.

That log is usually the fastest route to a diagnosis. Most "flaky" integration tests are not flaky. They are order-dependent, and they only look random because nobody could see the state they started from.

Running it in CI

None of this helps if it only runs locally.

Dokkimi ships a GitHub Action, so the same definitions run in CI on every pull request. Because each run is isolated, tests can run in parallel without interfering - which is the thing a shared staging environment can never do.

There is a working example at github.com/dokkimi/demo-nextjs: a Next.js app with Google OAuth and Postgres, tested with mocked external APIs, traffic assertions, database queries and visual regression, wired into a GitHub Actions pipeline.

When you still want a staging environment

To be fair about the limits.

Staging is still useful for manual QA, for demos to stakeholders, for load testing against realistic infrastructure, and for validating deployment and migration processes as they will actually run.

What it should not be is the place you find out whether your services talk to each other correctly. That belongs in a test that runs on every pull request, in an environment defined next to the code, which is destroyed the moment it finishes.

Getting started

brew install dokkimi/tap/dokkimi
# or
npm install -g dokkimi

dokkimi init      # scaffolds a .dokkimi/ folder with examples
dokkimi doctor    # verify Docker and Node are set up
dokkimi run       # bring up the environment and run

Requires Node 20+ and Docker. Free to run locally and in CI.

If you work with Claude Code, Cursor or GitHub Copilot, the MCP server auto-registers on install, so your agent can write definitions, run tests, and diagnose failures directly.

Docs at dokkimi.com/docs.