Test Purity: You Cannot Forget to Mock an Effect

· 6 min read

Most languages default to trusting you about effects. A function is assumed fine until something goes wrong at runtime, and purity — if the language has the concept at all — is a promise you write down and the compiler files.

Koru inverts the default. A proc is impure until you say otherwise.

tor log { message: string }

proc log|zig {
    std.debug.print("LOG: {s}\n", .{message});
}

There is no annotation here and none is needed. Not because the compiler read the body and saw a print — it did not, and it cannot. A |zig proc is host code, opaque to Koru’s analysis, so Koru assumes the worst. Impurity is the default; [pure] is the thing you have to declare, and it is a promise the compiler takes on trust exactly the way other languages take yours.

That is the whole trick, and it is a choice about defaults rather than a feat of analysis. The unsafe direction requires a signature; the safe one is free.

Above the host boundary it really does analyse. A flow is Koru, not opaque, so purity composes: a subflow of pure steps is pure, one impure step makes the whole subflow impure, and that propagates up through every caller. There is no level of indirection at which an effect stops counting — which is the property worth having, because the interesting impurity is never in the function you are looking at.

A test is where the rule bites

A test is a program that must not touch the world. In Koru that is not a convention, it is the same purity walk pointed at a test(...) body: an impure tor reached from a test is a compile error unless the test says what it should do instead.

tor fetch-user { id: u32 } -> string

proc fetch-user|zig {
    // This is impure - does I/O (simulated)
    return "Alice";
}

test(Impure events should error) {
    fetch-user(id: 1): u |> save-log(message: u.name)
        | saved |> assert.ok()
}

This does not compile. Not because the test failed, and not because fetch-user did something bad at runtime — it never ran. The test reaches two tors whose implementations touch the world and does not say what they should pretend to do, so there is no program to build:

[TEST] Purity walk complete, 2 impure events
panic: Test 'Impure events should error' has impure events without mocks: fetch-user, save-log

Both, named, in one pass. Not the first one, then a recompile, then the next.

“So you just mock everything?”

The usual objection, and it is a good one: if a test fakes every effect, it is testing the fakes. Worth answering precisely, because Koru’s answer is narrower than it first looks.

You can mock anything. Pure tors included — ~double -> 999 substitutes a pure function just as happily as an impure one. Koru does not police what you choose to fake, and a post claiming otherwise would be wrong.

What it polices is the other direction: you cannot mock less than the effects. The set of things a test must declare is computed from the code, not chosen by the author, and the compiler hands you the list.

That inverts where the judgement lives. The complaint “you are just testing mocks” assumes the author drew the boundary and drew it badly — mocked the awkward collaborator, mocked the logic behind the interface, mocked until the test was green. In Koru the minimum boundary is derived. Anything past it is a choice you made, visible in the test, with nothing forcing it.

So the honest guarantee is not “your mocks are minimal”. It is:

  • a test cannot silently touch the world, and
  • a test cannot silently fake it either — every effect it stands in for is written down, because the compiler refused to build until it was.

Read a Koru test and its effect surface is enumerable, top of file. Whether that surface is too large is a question about the design under test, and it is now a question you can actually ask, because the answer is a list rather than an afternoon with a debugger.

The level you mock at is a lens

The list is not a list of tors you must name. It is a list of effects the test still reaches — and reaching is something you can cut off higher up.

Nine impure tors behind one subflow do not cost nine mocks. Mock the subflow and the walk never descends:

import std/testing
import app/effects

tor load-user { id: u32 } -> string
load-user = app/effects:read-cache(id)
    |> app/effects:read-db(id): d
    |> app/effects:audit(msg: d) -> d

test(Mock the subflow, not its three effects) {
    load-user -> "Mocked"
    load-user(id: 1): u |> assert(u.len == 6)
}

No ~ anywhere — the effects live in a .kz beside it and this file is pure Koru, so there is no host to switch away from.

read-cache, read-db and audit are all impure — they are the |zig procs in the companion module. The compiler’s verdict:

[TEST] Purity walk complete, 0 impure events

Zero — not “three, satisfied”. They were never reached, so they were never counted, and the emitted test is a single substitution with the assertion. Point the lens lower and you mock three things and test the composition between them; point it higher and you mock one and test what sits above it. The demand adapts to where you cut.

Which is what keeps “you cannot forget” from turning into “you must enumerate your whole I/O layer in every test”. The floor is not the number of effects in the program. It is the number of effects this test still touches, and that is a number you control by choosing the seam.

This is the enforcement half of a story the ergonomics half already told. Testing Without Ceremony (January) is about how easy the seam is — mock anything, no interfaces, no injection, pure logic runs for real. It assumes you know which calls are the impure ones. This post is about the compiler deciding that for you, and refusing to build until you have answered.

What this does not claim

Purity here means effects reachable from Koru, and the boundary is the host proc. A ~proc … |zig body is read structurally; if it prints or writes, that is visible and inherited. The compiler is not proving termination, not proving determinism, and cannot see through an opaque call into a C library that decides to open a socket. [pure] exists for exactly that gap — the escape hatch where you assert what the compiler cannot read, and own it.

The claim is narrower than “Koru knows your function is pure” — it does not, and [pure] is a load-bearing promise you can lie in. It is also more useful than an annotation alone: an effect you can write in Koru is an effect the test harness can see, and a test that reaches one without saying what it should do instead is not a failing test. It is not a program.