Skip to content

Testing Pure vs Impure Code

The FIRST properties told you what a good test feels like — fast, isolated, repeatable, self-validating, timely. This page tells you where those properties come from. They are not habits you bolt onto a test after the fact. They are consequences of the shape of the code you are testing.

Some code is trivially testable: you can’t write a slow, flaky, order-dependent test for it even if you try. Other code fights every one of the FIRST properties by its very nature — it reaches into the filesystem, the clock, the network, a random number generator. The difference between the two is purity, and once you can see it, you stop asking “how do I test this?” and start asking “how do I shape this so it barely needs testing at all?”

A function is pure when it satisfies two conditions:

  1. Its output is determined solely by its inputs. Call it with the same arguments and you get the same result, every time, forever. It does not consult a clock, a file, an environment variable, or a random source.
  2. It has no side effects. It does not write a file, mutate a global, send a packet, or print. Calling it changes nothing an outside observer could detect except the value it returns.

A pure function is, in effect, a mathematical mapping from inputs to outputs. It is a lookup table you happen to compute instead of store.

pure: inputs ──► [ function ]──► output
(no world)
impure: inputs ──► [ function ]──► output
▲ │
reads ───┘ └───► writes
clock/fs/net/rng fs/net/global

logwise’s core is the textbook case. Its analyze() takes an iterator of &str and returns a Report:

pub fn analyze<'a>(
lines: impl Iterator<Item = &'a str>,
filter: &Filter,
strict: bool,
) -> Result<Report, LogError> { /* parse, count, filter */ }

Look at what is absent. No File, no Path, no std::io. It does not know or care whether the lines came from a log file on disk, a network socket, a compressed archive, or a string literal typed into a test. It cannot know, because the only thing it is handed is an iterator of string slices. That absence is the whole point: the function’s testability is baked into its signature.

Re-read the FIRST properties against the definition of purity and watch them fall out automatically:

  • Fast — no I/O means no disk seek, no socket handshake, no waiting. Just CPU.
  • Isolated — output depends only on inputs, so no other test, no leftover file, no shared clock can perturb the result.
  • Repeatable — same input, same output, on your laptop and in CI, today and in a year. There is no environment to disagree about.
  • Self-validating — the return value is the thing you assert on. No side channel to inspect.

You do not achieve the FIRST properties for a pure function. You cannot avoid them. That is the payoff we are chasing.

Now the other side. kvlite’s Db::open is honest impurity:

pub fn open(path: impl AsRef<Path>) -> Result<(Db, usize)> {
let path = path.as_ref();
let (mem, replayed) = log::replay(path)?; // reads the real file
let wal = Wal::open(path)?; // opens the real file
/* ... */
}

To test this you cannot just pass a value and assert on a value. You have to arrange a world: a real path on a real filesystem that does not already exist, does not collide with a parallel test, and gets cleaned up afterward. kvlite’s own tests do exactly that — they build a unique temp path per call so concurrent tests never step on each other:

// from kvlite: a unique temp path per call, std only, no tempfile crate
fn temp_wal(tag: &str) -> std::path::PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("kvlite-{tag}-{n}.wal"))
}

Count the ways this fights FIRST. It is slower (real disk I/O). It is less isolated (two tests sharing a path corrupt each other — hence the uniqueness dance). It is less repeatable (a full disk, a read-only temp dir, a leftover file from a crashed prior run, and it fails for reasons that have nothing to do with your logic). And it needs teardown, because a test that leaves files behind is a test that poisons the next run.

None of this is a criticism of kvlite. A key-value store’s entire job is to touch the disk durably; you cannot make open pure, and pretending otherwise would be a lie. The lesson is not “never write impure code.” It is: impurity is unavoidable but contagious, so contain it.

The pattern: functional core, imperative shell

Section titled “The pattern: functional core, imperative shell”

Here is the design move that resolves the tension. You cannot delete impurity, but you can push it to the edges and keep the middle pure. This is the functional core, imperative shell pattern.

  • The functional core is all your real logic — parsing, validation, aggregation, decision-making — written as pure functions over plain data. This is where the interesting behavior and the interesting bugs live, and it is trivially testable.
  • The imperative shell is a thin outer layer that does the dirty work: read the bytes, take the clock reading, open the socket, hand the resulting plain data to the core, and write the core’s plain result back out.
┌─────────────────────────── imperative shell ───────────────────────────┐
│ read file / accept socket / read clock ──► &str, bytes, structs │
│ │ │
│ ▼ │
│ ┌──────── functional core ────────┐ │
│ │ analyze(lines, filter, strict) │ pure │
│ │ → Report │ │
│ └──────────────┬──────────────────┘ │
│ │ plain data out │
│ ▼ │
│ print report / write response / persist ◄── format(Report) │
└─────────────────────────────────────────────────────────────────────────┘

logwise is built exactly this way. The impurity lives in main.rs (open the file, read lines, print the summary) and in the server. The logic — parse_line, Filter::matches, the level histogram, analyze — lives in the pure core. When you want to test “does a WARN line get counted and does a malformed line get skipped in lenient mode?”, you never touch a file. You feed the core a string.

The SAMPLE const: rich behavior from a string literal

Section titled “The SAMPLE const: rich behavior from a string literal”

Because analyze is pure over an iterator of &str, its tests hand it a literal and assert on the returned Report. logwise’s own test module does precisely this:

const SAMPLE: &str = "\
2026-06-26T09:00:01Z INFO server starting up
2026-06-26T09:00:02Z DEBUG loaded config
2026-06-26T09:00:05Z WARN cache miss rate high
2026-06-26T09:00:12Z ERROR failed to open blob
this line is not valid
2026-06-26T09:00:25Z INFO request GET /health 200";
fn analyze_sample(filter: &Filter) -> Report {
analyze(SAMPLE.lines(), filter, false).unwrap()
}
#[test]
fn counts_levels_and_skips_blanks_and_malformed() {
let report = analyze_sample(&Filter::default());
let s = &report.summary;
assert_eq!(s.total_lines, 6); // 7 lines minus 1 blank
assert_eq!(s.parsed, 5); // the "not valid" line is malformed
}

That one string encodes a startup sequence, a warning, an error, a blank line (does it get skipped?), a malformed line (does lenient mode tally it and continue?), and multiple levels (is the histogram right?). Every behavioral edge is expressed as text you can read at a glance — and SAMPLE.lines() is an iterator of &str, the exact input analyze wants. No temp directory, no cleanup, no flakiness. The richness of the test comes for free because the core is pure. An impure analyze that opened a file would have forced every one of these cases into a fixture file on disk.

The move is almost mechanical. When a function mixes logic with I/O, split it:

BEFORE (mixed — hard to test):
fn process():
data = read_file("input.txt") # impurity
result = ...compute over data... # logic, trapped inside
write_file("out.txt", result) # impurity
AFTER (split):
fn compute(data) -> result: # PURE — test this exhaustively
...compute over data...
fn process(): # thin shell — a couple of lines
write_file("out.txt", compute(read_file("input.txt")))

The shell shrinks until there is almost nothing left to test in it — it just wires reads to compute to writes. The core swells to hold all the logic, and all the logic is pure. You test the core with fifty cheap, in-memory cases and the shell with one or two “does the wiring work” integration tests.

To apply the pattern you first have to see the impurity. It comes in exactly four flavors. Learn to spot them, because each is a value your test cannot hold still — and each must be pushed into the shell or injected as an input.

  1. Time — anything that reads the wall clock: now(), Date.now(), timestamps, timeouts, “expires in 30 days.” The clock moves while your test runs, so a function that reads it directly can never be repeatable. Fix: pass the current time in as a parameter.
  2. Randomnessrand(), UUID generation, shuffles, jitter, random sampling. Same input, different output — the exact negation of purity. Fix: inject the random source (or a fixed seed) so the test controls it.
  3. I/O — files, network, database, stdout, environment variables. Slow, order-of-magnitude expensive, and dependent on a world outside the test. This is kvlite’s Db::open. Fix: read/write in the shell; hand the core plain data.
  4. Global mutable state — a shared cache, a singleton, a static counter, a module-level variable. It couples tests through a back channel: test A mutates it, test B fails, and the failure moves when you reorder. Fix: pass state in explicitly; do not reach for ambient globals.
TIME ──┐
RANDOM ─┤
I/O ────┼──► these are the ONLY reasons a pure-looking test flakes.
GLOBAL ─┘ Find them, push them to the shell, inject what remains.

A useful diagnostic: if a test fails only sometimes, or only on some machines, or only when run after another test, one of these four is hiding in your “logic.” The cure is always the same — move it to the shell, or turn it into an input.

  • Why does it exist? Because testability is a property of code shape, not of test-writing effort. Splitting pure logic from impure effects exists to make the FIRST properties automatic instead of aspirational.
  • What problem does it solve? It ends the “I can’t test this without a real database/clock/network” complaint by moving the interesting logic somewhere that needs none of them, and shrinking the part that does to a trivial, barely-tested shell.
  • What are the trade-offs? More functions and more explicit parameter passing — you thread inputs (the clock, the data, the RNG) that a quick script would just grab ambiently. You trade a little ceremony for determinism, speed, and isolation.
  • When should I avoid it? When the code is all effect and no logic — a two-line function that copies a file has no core worth extracting. Don’t manufacture a “pure core” for glue code; the shell is allowed to exist.
  • What breaks if I remove it? Logic and I/O re-entangle. Tests need temp files, fixed clocks, and network stubs to reach any real behavior; they slow down, flake, and couple to each other — and developers quietly stop writing them.
  1. State the two conditions a function must meet to be pure, and explain why meeting them makes the FIRST properties automatic rather than something you work to achieve.
  2. logwise’s analyze takes an impl Iterator<Item = &str> rather than a file path. How does that single signature choice make the function testable, and what would be lost if it took a &Path instead?
  3. kvlite’s Db::open touches the real filesystem. Name two distinct FIRST properties it strains, and explain the temp_wal uniqueness trick in terms of the one it protects.
  4. In the “functional core, imperative shell” pattern, describe what belongs in each layer, and explain why the shell ends up needing almost no tests of its own.
  5. List the four horsemen of impurity. For a function that “marks an account expired 30 days after signup,” which horseman is hiding, and what is the standard fix?
Show answers
  1. (a) Output is determined solely by inputs — same arguments always yield the same result; (b) no side effects — nothing observable changes but the return value. With no I/O it is fast; with output depending only on inputs it is isolated and repeatable; and because the return value is the assertion target it is self-validating. You cannot write a slow or flaky test for such a function, so the properties come free rather than by effort.
  2. An iterator of &str is plain in-memory data, so a test can supply a string literal (SAMPLE.lines()) and assert on the returned Report with no filesystem, no temp path, and no cleanup. If it took a &Path, every test case would have to become a file on disk — slower, needing arrangement and teardown, and flaky on a full or read-only disk — and the logic would be entangled with I/O.
  3. It strains Fast (real disk I/O costs orders of magnitude more than a RAM operation) and Isolated/Repeatable (a shared path or a leftover file lets one run corrupt another). temp_wal hands each call a unique path so parallel tests never share a file — protecting isolation so concurrent tests can’t step on each other.
  4. The functional core holds all real logic — parsing, validation, aggregation, decisions — as pure functions over plain data. The imperative shell does the effects: read bytes/clock/socket, pass plain data to the core, write the core’s result out. The shell needs almost no tests because it contains almost no logic — it is just wiring, covered by one or two integration checks — while the exhaustive testing targets the pure core.
  5. Time, randomness, I/O, and global mutable state. The hiding horseman is time — the function reads the wall clock to decide whether 30 days have passed, so it behaves differently every day. The standard fix is to inject the current time as a parameter (pass now) so the test controls it and the function becomes pure and repeatable.