Backend Testing
Unit tests around the logic, integration tests against a real database, and load tests before the launch.
Confidence Per Second Of Runtime
The general testing discipline — the pyramid, doubles, fixtures, flakiness — lives in the QA track. This page is the backend-specific part: what to do about the database, the network, and the fact that your service has dependencies it does not own.
Unit Tests
Unit tests belong around logic: pricing rules, validation, state machines, permission checks, retry policy. They should not touch a database, a network or a clock, which is what keeps them in milliseconds.
# the rule is pure, so the test is trivial and fast
def test_free_shipping_applies_over_threshold():
order = make_order(subtotal=5000, country="GB")
assert shipping_cost(order) == 0
def test_expired_token_is_rejected():
assert verify(make_token(expires_in=-60)).reason == "expired"
Test behaviour, not implementation. A test that asserts which private method was called breaks on every refactor and passes when the behaviour is wrong — the worst of both.
Integration Tests
Integration tests are where backend suites earn their keep, because the interesting bugs are at the boundaries: the query that does not match the schema, the migration nobody ran, the transaction that never commits.
| Approach | Verdict |
|---|---|
| A real database in a container | Best — same engine and version as production |
| SQLite standing in for Postgres | Fast and lies: different SQL, types and constraints |
| Mocking the database layer | Tests your mocks, not your queries |
| A shared staging database | Flaky and order-dependent — tests fight each other |
# testcontainers: a real Postgres per test session, thrown away after
@pytest.fixture(scope="session")
def db():
with PostgresContainer("postgres:16") as pg:
run_migrations(pg.get_connection_url()) # migrations are under test too
yield pg.get_connection_url()
def test_order_is_visible_after_commit(db):
with session(db) as s:
create_order(s, id="42")
with session(db) as s:
assert fetch_order(s, "42") is not None
For the services you call, prefer a recorded or contract-tested fake over a live dependency. Tests that hit a real third-party API are slow, rate-limited, and fail for reasons that have nothing to do with your change.
Load Testing
Load testing answers a different question from the rest of the suite: not "is it correct" but "what happens at 10× and where does it break first".
// k6: fail the run on the percentile and the error rate, not the mean
export const options = {
stages: [
{ duration: '2m', target: 300 },
{ duration: '5m', target: 300 },
{ duration: '1m', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<400'],
http_req_failed: ['rate<0.005'],
},
};
| Do | Because |
|---|---|
| Test with production-shaped data | A thousand seeded rows hides every missing index |
| Report p95 and p99 | Averages conceal the users who leave |
| Watch the database, not just the API | The bottleneck is usually behind the service |
| Test the realistic mix of endpoints | One endpoint in a loop measures nothing real |
| Run against staging, never production | A load test is a deliberate denial of service |
Interview Questions
What belongs in a unit test on the backend?
Logic with no I/O — pricing rules, validation, permissions, state machines. Anything touching a database or network belongs in an integration test.
Why not use SQLite to stand in for Postgres?
Different SQL dialect, type handling and constraint behaviour. Tests pass against a database you do not run, which is the definition of false confidence.
Why run migrations inside the test fixture?
It makes every integration run exercise the migration path, which is otherwise only tested in production during a deploy.
What is wrong with mocking the database layer?
You end up asserting that your code calls your mocks in a particular way. The queries themselves — where the real bugs are — are never executed.
What does a load test actually need to produce?
A bottleneck, not a number: the pool, an index, a lock, a downstream service. Percentiles and error rate are the pass criteria; the diagnosis is the deliverable.
Why test behaviour rather than implementation?
Implementation tests break on every refactor and can pass while the behaviour is wrong. Behavioural tests survive refactoring and fail when something real changes.