Backend · Guide

Backend Testing

Unit tests around the logic, integration tests against a real database, and load tests before the launch.

— min read Backend

Confidence Per Second Of Runtime

A backend suite is a budget. Every test costs runtime on every commit, and buys some confidence. The shape of a good suite is whatever maximises that ratio — which is why most tests are fast and few are thorough.

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"
If a rule is hard to unit test, it is usually because it is tangled with I/O. Extracting the decision from the fetching is the fix, and it improves the code independently of the test.

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.

ApproachVerdict
A real database in a containerBest — same engine and version as production
SQLite standing in for PostgresFast and lies: different SQL, types and constraints
Mocking the database layerTests your mocks, not your queries
A shared staging databaseFlaky 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
Run migrations as part of the fixture. It makes every integration run a test of the migration path, which is otherwise only exercised in production.

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'],
  },
};
DoBecause
Test with production-shaped dataA thousand seeded rows hides every missing index
Report p95 and p99Averages conceal the users who leave
Watch the database, not just the APIThe bottleneck is usually behind the service
Test the realistic mix of endpointsOne endpoint in a loop measures nothing real
Run against staging, never productionA load test is a deliberate denial of service
The output that matters is not a number but a bottleneck: the connection pool, a missing index, a lock, a downstream service. A load test that ends with "it got slow" has not finished.

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.

Quick Quiz

1. Integration tests should run against…
2. Running migrations in the fixture also tests…
3. Load tests should be run against…
4. Asserting which private method was called is…
5. The most valuable output of a load test is…