Testing backends cheat sheet

The 10 Testing backends interview answers in 30 seconds each, plus quick facts. Printable, free.

One page per topic: print it or save it as PDF from your browser's print menu. Practise these questions

Rules of thumb

  • Integration tests against the same Postgres version as production.
  • Prefer fakes behind a Protocol; mock only side effects.
  • Inject clocks and seeded RNGs; never sleep in tests.
  • Contract tests block breaking provider deploys.
  • Flaky tests are bugs: detect, quarantine with an owner, fix.
  • Rehearse big migrations on production-sized data.

The test pyramid for a backend

Many fast unit tests for logic, a solid layer of integration tests that hit real dependencies you own (the database, a real Redis), contract tests at service boundaries, and a few end-to-end tests of critical journeys. Backend bugs often live in SQL, serialisation and wiring, so integration tests against a real Postgres earn their cost more than in UI-heavy apps. Keep the whole suite fast enough to run on every pull request.

Fakes, stubs and mocks

A stub returns canned answers. A fake is a working lightweight implementation, such as an in-memory repository behind the same interface. A mock records calls so the test can assert how it was used. Prefer fakes for collaborators you own: tests check outcomes and survive refactoring. Use mocks sparingly, for verifying a side effect you can't observe otherwise, like 'an email was sent once'.

Integration tests with a real database

Start a real Postgres of the production version (Testcontainers or a CI service container), apply your migrations once, then isolate each test: run it inside a transaction that's rolled back at the end, or give each worker its own schema or template-cloned database. That keeps tests independent and fast while exercising real SQL, constraints and types. Avoid SQLite stand-ins; their behaviour differs.

import psycopg

with psycopg.connect() as conn:
    conn.execute("CREATE TEMP TABLE t (id int) ON COMMIT PRESERVE ROWS")
    conn.commit()
    try:
        with conn.transaction():
            conn.execute("INSERT INTO t VALUES (1), (2)")
            n = conn.execute("SELECT count(*) FROM t").fetchone()
            print("inside test:", n)
            raise RuntimeError("roll back after the test")
    except RuntimeError:
        pass
    print("after:", conn.execute("SELECT count(*) FROM t").fetchone())

Contract testing between services

Write down the contract and test both sides against it. In consumer-driven contract testing (Pact), each consumer records the requests it makes and the response fields it relies on; the provider's CI replays those against the real provider and fails if it would break any consumer. Schema-first alternatives check an OpenAPI or protobuf schema for breaking changes on every pull request. Both catch breakage before deploy, quickly.

Testing code that uses time and randomness

Inject them. Pass a clock (a function returning now) and a seeded random generator into the code, so tests control them; production passes the real clock and an unseeded generator. Then a test can step the clock past an expiry or reproduce a jitter sequence exactly, without sleeping. Store and compare times in UTC with timezone-aware datetimes.

from collections.abc import Callable
from datetime import UTC, datetime, timedelta


def is_expired(issued: datetime,
               now: Callable[[], datetime]) -> bool:
    return now() - issued > timedelta(minutes=15)


issued = datetime(2026, 10, 31, 12, 0, tzinfo=UTC)
print(is_expired(issued, lambda: issued + timedelta(minutes=10)))
print(is_expired(issued, lambda: issued + timedelta(minutes=16)))

Flaky tests

Treat flakiness as a bug. Track which tests flake (re-run failures automatically to detect, not to hide), quarantine the worst so they don't block everyone, and fix the cause. Common backend causes: shared state between tests, ordering assumptions (SQL without ORDER BY), real time and sleeps, timeouts too tight for CI, network calls to real third parties, and races in async code.

Test data and fixtures

Each test should build the minimum data it needs, explicitly, using small factory functions with sensible defaults that tests override only where it matters: make_order(status="shipped"). Avoid one giant shared fixture file that every test depends on, because changing it breaks unrelated tests and hides what each test is about. Use unique values so tests can run in parallel.

Testing database migrations

Run every migration in CI against a database built from the previous release's schema with realistic data, checking it applies, that old and new code both work against the result (for expand-and-contract), and that it finishes in acceptable time. For big tables, rehearse on a production-sized copy to see locks and duration. Check lock-heavy operations with a linter (squawk for Postgres) and set lock_timeout in migrations.

SET lock_timeout = '2s';
CREATE TEMP TABLE accounts (id int);
ALTER TABLE accounts ADD COLUMN plan text;
SELECT count(*) FROM information_schema.columns
WHERE table_name = 'accounts';

Property-based testing

Instead of hand-picking examples, you state a property that should hold for all inputs, like 'decode(encode(x)) == x' or 'a sorted list is ordered and has the same items', and a tool generates hundreds of inputs, shrinking any failure to a minimal example. It pays off for parsers, serialisers, money and date arithmetic, idempotency ('applying twice equals applying once') and state machines.

Chaos experiments and failure testing

Run controlled experiments: state a hypothesis ('if one Redis replica dies, p99 stays under 300 ms'), inject the failure in a limited blast radius (kill an instance, add latency, drop a dependency), watch the SLO metrics, and stop immediately if they degrade beyond a limit. Start in staging with game days, then move carefully to production. The findings are usually missing timeouts, bad retries and untested failovers.

Quick facts

Postgres now()
Returns the transaction's start time, so it's the same for every statement in one transaction.
Template databases
CREATE DATABASE … TEMPLATE copies an existing database, a quick way to give each test worker a migrated copy.
Row order
Without ORDER BY, SQL doesn't guarantee the order of rows.
Hypothesis
Python's property-based testing library; it shrinks failing inputs to a minimal example.