Integration Tests: The Real System Boundary
- date
- category
- Testing
- reading
- 2 min / 495 words
Code can be correct and still fail with the database.
SQL can have a typo in a column name.
A transaction can end too early.
A serializer can write a date differently than the parser reads it.
A unit test does not see that, because its job was to cut dependencies away.
Integration test brings back a chosen dependency and checks a real boundary.
What kind of test this is
Integration test checks cooperation between two or more parts of a system.
Most often it covers boundaries such as:
application + database
application + queue
repository + migrations
endpoint + middleware
adapter + external format
worker + event
The point is not to start everything.
The point is to stop pretending the dependency exists where the real bug can appear.
If the risk is in SQL, the test should use a real database.
If the risk is in event serialization, the test should go through the real serializer and parser.
If the risk is in middleware, the test should go through the real request stack.
What happens when they are missing
Without integration tests, a system can have green unit tests and broken boundaries.
Each part looks fine on its own.
Only after composition it turns out that:
the column has a different name
a constraint rejects the write
timezone changes a date
middleware does not set the user
retry writes the record twice
the event does not pass validation
These are integration bugs, not bugs in a single function.
If E2E catches them, diagnosis is more expensive.
If production catches them, the cost is already operational.
Real dependency, controlled world
An integration test should be as real as possible at the boundary under test and as artificial as possible outside it.
A good example:
Fastify app
PostgreSQL in a container
migrations before the test
minimal fixture data
request through Supertest
A bad example:
whole staging
shared database for several teams
random data from previous runs
internet dependency
The first test checks a real boundary.
The second test checks the weather in the environment.
Fixtures and test data
An integration test without good data quickly becomes flaky.
The test should create only what it needs.
user
account
order
dependent record
If the fixture is too large, the test starts inheriting accidental assumptions.
If the fixture is shared, one test can break another.
The safest model is data isolation per test or fast cleanup between tests.
It is not always free.
But lack of isolation costs more later, because a flaky integration test is one of the fastest ways to destroy trust in the pipeline.
Tool from the stack
For integration tests on a Node backend I would choose Testcontainers and Supertest.
Testcontainers gives a real database or another dependency without maintaining a shared environment by hand.
Supertest lets the test go through a real HTTP request into the Fastify application without exposing it on an external port.
That is a good setup for this boundary:
Fastify + migrations + PostgreSQL + HTTP request
At this layer I would not mock the database, because the database is part of the risk.
When an integration test is too large
An integration test becomes too large when it tries to check a whole user path.
Then it mixes several questions:
does the API work
does the database work
does the UI work
does routing work
is test data correct
That is closer to E2E.
An integration test should have one important boundary.
If I cannot name that boundary, the test is probably placed poorly.
The next layer is contract tests and API tests, boundaries that leave one process or one repository.