Testcontainers gives you containers. The hard part is everything else.
Testcontainers was a genuinely good idea. Before it, integration tests either ran against a shared database that everyone corrupted, or they mocked the database entirely and tested nothing interesting.
Testcontainers said: spin up a real Postgres in Docker, for this test, throw it away afterwards. That was right, and it is why the library is everywhere.
This post is about what happens after that, because getting a container running is the easy part of an integration test.
What Testcontainers gives you
Programmatic container lifecycle from inside your test code. You declare a container, it starts, you get a connection string, it stops when the test finishes.
That solves three real problems: no shared database, no manual setup before running tests, and real dependency behaviour instead of an in-memory approximation that handles transactions differently.
For testing a repository layer against real Postgres, it is close to perfect.
Where the work starts
The gap shows up as soon as the thing you want to test involves more than one service.
You still assemble the environment yourself. Testcontainers starts containers. Wiring several services together so they can find each other, in the right order, with the right environment variables and health checks, is code you write and maintain. Every new service is another edit.
You cannot see traffic between services. This is the big one. When your API gateway calls your order service, Testcontainers has no view of that request. If the bug is a malformed header or a dropped field between two internal services, nothing in the framework helps you find it. You end up adding logging to production code to debug tests.
Mocking external APIs is a separate build. Testing a flow that calls Stripe means standing up a mock server, adding it to the network, overriding the service's base URL, and maintaining that mock as the real API changes. Testcontainers has no opinion here, so it becomes yours.
Browser testing is a different tool entirely. You run Playwright or Cypress alongside, in a separate process, with its own config and no shared context. Your UI assertions and your database assertions live in different worlds and cannot reference each other in one test.
Setup code grows faster than test code. The common end state is a few hundred lines of container orchestration, a mock server nobody wants to own, and a fragile bridge between the browser tool and everything else. New engineers avoid touching it.
None of this is a flaw in Testcontainers. It is a container library and it does that job well. The issue is that a real integration test needs four layers, and containers are one.
The four layers again
A single user action crosses:
Browser - what the user clicks and sees Services - the HTTP calls between your own components External APIs - Stripe, Auth0, Twilio, anything you do not control Database - what actually got written
Testcontainers covers the dependency layer. Playwright covers the browser. Postman covers APIs in isolation. Nothing follows one action across all four, so teams stitch tools together and the seams are where the untested behaviour lives.
What a declarative version looks like
Dokkimi takes the same premise - real dependencies in Docker, isolated per run - and covers the other three layers rather than leaving them to you.
You describe the environment and the tests in one file instead of writing orchestration code:
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: $$.origin
operator: eq
value: api-gateway
- 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'Three things in there are not available from a container library:
The $.traffic assertion is inspecting an HTTP call between two of your services. Interceptor sidecars capture every inter-service request during the run, so you can assert on what was actually sent rather than inferring it from the outcome.
The mock-stripe.yaml fragment is a declared mock, referenced like any other service. No mock server to build or maintain.
The UI step and the database query are in the same test, sharing the same run and the same context.
Your services run unmodified throughout. Dokkimi wires up the sidecars, routing, DNS, browser and cleanup, so there are no code changes and no test-only branches.
The $ref pattern
Worth calling out because it is the difference between a maintainable suite and a copy-pasted one.
Service definitions live once in a shared folder and are referenced across every test. When a service gains a dependency or changes a port, you update one fragment rather than thirty test files. It is the same instinct as extracting a container builder into a helper, except it is configuration rather than code, so it does not accumulate logic.
When Testcontainers is still the right call
Being fair about this.
Testcontainers is the better fit when you are testing a single service against real dependencies, when your team wants everything expressed in application code rather than configuration, when you are already deep into a JVM or .NET ecosystem where the library is most mature, or when your integration surface genuinely is just "my code plus a database".
Dokkimi fits better when multiple services need to run together, when the failures you care about happen between them, when you need browser, traffic, external API and database assertions in one test, or when your setup code has grown to the point that nobody wants to maintain it.
Some teams run both - Testcontainers for focused per-service tests, Dokkimi for the cross-service flows.
Trying it
brew install dokkimi/tap/dokkimi
# or
npm install -g dokkimi
dokkimi init # scaffolds .dokkimi/ with examples
dokkimi validate
dokkimi runNode 20+ and Docker required. Free locally and in CI.
There is a working reference at github.com/dokkimi/demo-nextjs - Next.js with Google OAuth and Postgres, mocked identity provider, traffic assertions, database queries and visual regression, running in GitHub Actions.
Docs at dokkimi.com/docs.