Skip to content

Write the Unit and Integration Tests

On the previous page we turned the risk map into a table of concrete cases — inputs, the state they run against, and the exact behaviour we expect back. That table was the design. Now we translate it into code that a machine runs on every commit, in seconds, without a human watching.

This is the page where the abstract becomes real. We are testing Snip, a URL shortener with two endpoints: POST /shorten takes a long URL and returns a short code; GET /:code looks that code up and redirects. The whole app is a pure core — generate a code, validate and normalize a URL — wrapped in a thin layer of I/O that reads the request, reads and writes the store, and writes the response. That split is not incidental. It is the single most important fact for how we test it, because the pure core and the I/O layer want completely different kinds of tests.

Before writing a line of test code, look at where the work happens. Almost every request through Snip decomposes into two phases:

POST /shorten
── I/O shell ───────────────┐ ── pure core ──────────────┐ ── I/O shell ──┐
read + parse JSON body │ validate_url(raw) │ INSERT row │
│ normalize_url(valid) │ (or read back │
│ generate_code(rng) │ existing) │
───────────────────────────┘ ───────────────────────────┘ write response ┘

The middle column touches no disk, no socket, no clock — give it a string, it returns a string or an error, deterministically. The outer columns do nothing but touch the outside world. This is not an accident of Snip; it is the shape of almost every service once you look, and it dictates the split at the heart of this page:

  • Unit tests cover the pure core. No I/O means they run in milliseconds, so you can afford hundreds and run them on every keystroke.
  • Integration tests cover the shell and its contract with the real store. They are slower and fewer, and they earn their keep by testing the one thing a unit test structurally cannot: that the wiring to a real database actually works.

Keeping these separate is the difference between a suite that finishes before you blink and one you dread running.

Unit tests: the pure core, at millisecond speed

Section titled “Unit tests: the pure core, at millisecond speed”

Take the three pure pieces the design page identified — code generation, URL validation, URL normalization — and test each in isolation. No test in this section opens a file, a socket, or a database connection. If one does, it is in the wrong section.

A generated code must match ^[a-z0-9]{6}$ and, over many draws, not collide constantly. The trap is randomness: a function that reaches for a global RNG is not a pure function, and a test over it cannot assert an exact value. So we inject the source of randomness — pass the RNG in — which makes the function deterministic under test while staying random in production.

def generate_code(rng, length=6):
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
return "".join(alphabet[rng.randrange(len(alphabet))] for _ in range(length))
# unit test — a seeded RNG makes the "random" output exact and repeatable
def test_generate_code_is_deterministic_under_a_seed():
rng = random.Random(42) # arrange: pin the randomness
code = generate_code(rng) # act
assert code == generate_code(random.Random(42)) # assert: same seed → same code
assert re.fullmatch(r"[a-z0-9]{6}", code) # assert: shape of the contract

Two assertions, two distinct claims: the code has the right shape (behaviour the caller depends on), and the generator is deterministic given its input (so the rest of the suite can rely on it). Neither touches I/O; both run in microseconds.

Validation answers is this acceptable?; normalization answers what is the canonical form we store? They are separate functions and get separate tests. Every boundary case from the design table becomes one assertion here.

def test_validate_url_rejects_by_class():
assert validate_url("https://example.com/a").ok # valid class
assert not validate_url("htp:/nope").ok # malformed
assert not validate_url("").ok # empty
assert not validate_url("ftp://example.com").ok # wrong scheme
assert not validate_url("h" * 2049).ok # over-length (2049)
assert validate_url("h" + "ttps://a.co/" + "x" * 2035).ok # 2048 exactly → valid
def test_normalize_is_idempotent_and_canonical():
n = normalize_url("HTTPS://Example.COM:443/a/../b?z=1&a=2")
assert n == "https://example.com/b?a=2&z=1" # lowercased host, default port
assert normalize_url(n) == n # applying twice changes nothing

The normalization test asserts a property worth its own line — idempotence: normalizing an already-normalized URL must return it unchanged. If it doesn’t, the same logical URL can be stored under two spellings, and the “return the existing code” idempotency guarantee quietly breaks. That is a behaviour, not an implementation detail, which is exactly what we want to pin.

Every assertion above checks what the function does for a caller, never how it does it. We assert the code matches a regex, not that generate_code called rng.randrange six times. We assert the normalized string equals a canonical value, not that it took a particular code path. This is the discipline that keeps a suite from becoming a cast of the current implementation: a test coupled to internals fails the moment you refactor, even when behaviour is unchanged — training the team to distrust and eventually ignore the suite. Assert on the observable contract and a green suite means something after a refactor.

Structure every test as arrange–act–assert

Section titled “Structure every test as arrange–act–assert”

Look back at the tests above and they all share one skeleton, whether pure or integration:

arrange set up the world: build inputs, seed fixtures, pin the clock/RNG
act perform exactly ONE action — the thing under test
assert check the observable result against the expected value

The value of the skeleton is not tidiness; it is diagnosis. When a test fails, the three-part shape tells you where to look: a surprise in arrange means your setup drifted from reality, a failure in assert means behaviour changed, and if you cannot point at a single act line, the test is doing too much and its failure won’t tell you which action broke. Keep act to one call. A test that shortens and redirects and deletes in one body, with assertions sprinkled between, is really three tests wearing a trench coat — when it goes red you won’t know which behaviour regressed.

The corollary: one behaviour per test. It is tempting to fold the six validation classes into a single loop, and for closely-related rejections that is fine (as test_validate_url_rejects_by_class does). But the moment two assertions check different behaviours — say, “a valid URL is accepted” and “a duplicate URL returns the existing code” — split them, so a failure names the exact behaviour that broke rather than “something in the validation test.”

Integration tests: drive the endpoint against a real store

Section titled “Integration tests: drive the endpoint against a real store”

Unit tests prove the core is correct in a vacuum. They cannot prove that POST /shorten actually writes a row a later GET /:code can read — because that claim is about the seam between your code and a real database: its SQL dialect, its uniqueness constraints, its transaction semantics. Fake the database and you test your fake. So the integration layer uses a real store and drives the app the way a client would: over its actual HTTP interface.

The pattern for standing up a real server in a test is worth copying exactly, because getting it wrong is the classic source of flakiness. The repo’s own kvlite server test shows the move: bind to port 0 so the OS hands you a free ephemeral port, read back the address, then connect. Never hard-code a port — a hard-coded 8080 collides with whatever else is running and makes two test runs unable to coexist.

// from rust/kvlite/tests/server.rs — the ephemeral-port pattern
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); // :0 → OS picks a free port
let addr = listener.local_addr().unwrap(); // read back the real port
thread::spawn(move || {
let _ = server::serve_on(listener, db); // real server, real store
});
let stream = TcpStream::connect(addr).unwrap(); // client talks over the wire

Snip’s integration test is the same shape one layer up — a real HTTP server over a real database, exercised end to end through its endpoints:

def test_shorten_then_redirect_roundtrip(pg): # `pg` fixture = a real, empty DB
app = make_app(db=pg, clock=FixedClock(T0), rng=random.Random(7))
client = app.test_client() # binds an ephemeral port
# act: create a short code for a URL
r = client.post("/shorten", json={"url": "https://example.com/a"})
assert r.status_code == 201 # assert: behaviour, not SQL
code = r.json["code"]
assert re.fullmatch(r"[a-z0-9]{6}", code)
# act: the code must resolve to the original URL, through the real store
r2 = client.get(f"/{code}", allow_redirects=False)
assert r2.status_code == 302
assert r2.headers["Location"] == "https://example.com/a"

That single test crosses every layer the unit tests skipped: JSON parsing, the validation call, a real INSERT, a real SELECT on the round trip, and the redirect response. The decision-table rules from the design page — duplicate URL returns the existing code, a taken alias returns 409 — each become one more integration test against the same real pg fixture.

Choose real vs. faked dependencies deliberately

Section titled “Choose real vs. faked dependencies deliberately”

The two sections above already made a choice on every dependency, and the choice was never “fake everything” or “fake nothing.” It was decided per dependency, on one question: does replacing this with a fake preserve the behaviour I am trying to test?

dependency test double why
───────────── ───────────────── ───────────────────────────────────────────
clock FixedClock(T0) time must be deterministic; a wall clock
makes "created_at" assertions flaky
randomness Random(seed) codes must be reproducible in a test; a real
RNG gives a different code every run
database REAL Postgres the storage contract (constraints, dialect,
transactions) IS the thing under test —
faking it tests the fake, not the seam

The rule underneath: fake a dependency when its real behaviour is irrelevant noise (a moving clock, unpredictable random bytes); use the real thing when its behaviour is the contract you are verifying. Faking the clock and RNG buys determinism at zero cost to what those tests check. Faking the database would delete the only reason the integration test exists. Reach for an in-memory SQLite substitute only knowing it can hide real-Postgres bugs — a UNIQUE violation SQLite handles differently, an ON CONFLICT clause it parses differently — and those are precisely the bugs this layer is meant to catch.

Under the hood — the pg fixture and test isolation

Section titled “Under the hood — the pg fixture and test isolation”

The pg fixture is doing quiet, load-bearing work: it must hand each test a real Postgres that is also clean, so that one test’s rows never leak into another’s assertions. Two tests that share a mutable database and run in an unspecified order are a flaky suite waiting to happen — count_rows("links") == 1 is only meaningful if the table started empty.

There are three common ways to get that isolation, in rising order of speed:

strategy isolation speed how
────────────────── ───────────── ───────── ────────────────────────────────
fresh database perfect slowest create+migrate a DB per test
truncate tables good medium one DB, wipe rows between tests
transaction rollback perfect fastest open a txn, run the test, ROLLBACK

The transaction trick is the one worth knowing: begin a transaction in the fixture’s setup, run the whole test inside it, and roll back in teardown instead of committing. The test sees all its own writes (so GET /:code finds the row POST just made), but nothing survives the rollback, so the next test starts pristine — no truncation, no rebuild. The kvlite server test uses the file-per-test equivalent: a temp_wal() path unique per run (std::process::id() plus an atomic counter), removed on the way out. Same principle, different store: every test owns its state and cleans up after itself, so the suite gives the same result no matter the order it runs in. One caveat — the rollback trick can’t be used for the concurrency test, because the racing threads need to see each other’s committed writes; there you truncate a real, shared table between runs instead.

Cover the concurrency edge: race two shortens

Section titled “Cover the concurrency edge: race two shortens”

One risk from the design page cannot be reached by any single-threaded test: two identical POST /shorten requests arriving at the same instant. If both check “does this URL exist?”, both see no, and both insert — you have issued two codes for one URL, or worse, violated a uniqueness invariant. A test that calls /shorten twice in sequence will never see this; the bug lives in the interleaving, so the test must create a real one.

The repo’s kvlite concurrency test is the template: spawn many threads against one shared store and assert an exact, race-free outcome — “if the final counts are exact, no update was lost.”

def test_racing_shortens_issue_no_duplicate(pg):
app = make_app(db=pg, clock=FixedClock(T0), rng=random.Random(1))
url = "https://example.com/same"
def shorten(): # each thread fires the same request
return app.test_client().post("/shorten", json={"url": url}).json["code"]
with ThreadPoolExecutor(max_workers=8) as ex: # arrange: 8 racers
codes = list(ex.map(lambda _: shorten(), range(8))) # act: fire together
assert len(set(codes)) == 1 # assert: exactly ONE code issued
assert pg.count_rows("links") == 1 # exactly ONE row written

The assertions encode the invariant, not the mechanism: one distinct code, one row — regardless of which thread won. How Snip enforces it (a UNIQUE constraint with an upsert, a transaction, an advisory lock) is deliberately not asserted, so the test survives a change of strategy. Run it with the threads and it must go green every time; delete the concurrency guard from the code and it must go red. A concurrency test that never fails on the unguarded code is testing nothing — before trusting it, confirm it fails when the protection is removed.

  • Why does it exist? Because “does the code work?” is really two different questions — is the logic correct in isolation? and does it work wired to a real database over its real interface? — and each needs a different kind of test. The unit/integration split exists to answer both without paying integration cost for logic checks.
  • What problem does it solve? It gives fast feedback where speed matters (a pure core you can test hundreds of times per second) and honest feedback where correctness is subtle (the seam to a real store), instead of forcing every test through slow, flaky I/O.
  • What are the trade-offs? Integration tests are slower, need real infrastructure (a Postgres to bind against), and are more work to set up and keep stable. You accept that cost for a small number of them because they catch the whole class of wiring bugs that unit tests, by construction, cannot.
  • When should I avoid it? When there is no meaningful pure core to isolate — a thin CRUD passthrough is almost all I/O, so an integration test is the unit test and inventing pure-core units adds ceremony without coverage. Don’t force the split where the code doesn’t have one.
  • What breaks if I remove it? Drop the unit layer and every logic bug costs a slow DB round trip to find, so the suite gets slow and stops being run. Drop the integration layer and your green units happily coexist with a broken INSERT, a wrong SQL dialect, or a redirect that never fires — you ship “tested” code that fails the first real request.
  1. Snip splits into a pure core and an I/O shell. Which pieces belong to each, and why does that split decide what gets a unit test versus an integration test?
  2. Code generation depends on randomness. How do you make it deterministic in a unit test without making it deterministic in production, and what two distinct things should the test assert?
  3. Why does the integration test use a real Postgres but a faked clock and RNG? State the single question you ask to decide real-vs-fake for any dependency.
  4. What does binding to port 0 (an ephemeral port) buy the integration test, and what breaks if you hard-code a port like 8080 instead?
  5. A sequential test that calls POST /shorten twice in a row passes. Why can it still miss the duplicate-code bug, and how must the concurrency test be structured — including one check that proves the test itself has teeth?
Show answers
  1. The pure core is code generation, URL validation, and normalization — string-in, string-out, no I/O — so it gets fast unit tests that run in milliseconds. The I/O shell reads the request, reads/writes the store, and writes the response; its correctness is about the seam to real infrastructure, so it gets integration tests. The split decides the test type because only I/O-free code can be tested at unit speed, and only a real store can verify the storage contract.
  2. Inject the RNG — pass the random source in as an argument. Production passes a real RNG; the test passes a seeded one (Random(42)), making output exact and repeatable while staying random in production. Assert two distinct things: the code has the right shape (^[a-z0-9]{6}$, the caller’s contract) and the generator is deterministic given its seed (so the rest of the suite can rely on it).
  3. The database is the contract under test — its constraints, SQL dialect, and transaction semantics are exactly what the integration test verifies, so faking it would test the fake, not the seam. The clock and RNG are irrelevant noise whose real behaviour (moving time, unpredictable bytes) only makes assertions flaky, so faking them buys determinism at no cost to coverage. The deciding question: does replacing this with a fake preserve the behaviour I am trying to test? Fake if yes, use the real thing if no.
  4. Binding to port 0 lets the OS hand back a guaranteed-free port, which the test reads via local_addr() before connecting — so multiple test runs (and other services) never collide, and the suite is reproducible on any machine. Hard-coding 8080 makes the test fail whenever that port is already in use and prevents two runs from coexisting, a classic source of flakiness.
  5. The duplicate-code bug lives in the interleaving of two simultaneous requests: both check “URL exists?”, both see no, both insert. Two sequential calls never overlap, so they never trigger it. The concurrency test must spawn several threads that fire the same request together (e.g. an 8-worker pool) against one shared real store, then assert the invariant — exactly one distinct code and exactly one row — not the mechanism. To prove it has teeth, remove the concurrency guard from the code and confirm the test goes red; a concurrency test that stays green on unguarded code is testing nothing.