Testing Without Ceremony: How Koru Makes Every Event a Mockable Seam

· 8 min read

What if testing was this simple?

~test(Successful payment) {
    ~db.get_user = found { id: 42, balance: 100 }
    ~payment.charge = success { transaction_id: "tx123" }

    ~process_payment(user_id: 42, amount: 50)
    | success s |> assert(s.transaction_id.len == 5)
}

That’s it. No mock frameworks. No dependency injection. No interfaces. No restructuring your code “for testability.”

You declare what the impure events return, call your flow, and assert on the result. The pure business logic runs for real. The I/O boundaries are mocked.

The Syntax

Let’s break down what’s happening:

~import "$std/testing"

// Your actual production code
~event db.get_user { id: u32 }
| found { id: u32, balance: u32 }
| not_found

~event payment.charge { user_id: u32, amount: u32 }
| success { transaction_id: []const u8 }
| failed { reason: []const u8 }

~event validate.amount { amount: u32, balance: u32 }
| valid
| insufficient

~[pure] proc validate.amount {
    if (amount <= balance) {
        return .{ .valid = .{} };
    } else {
        return .{ .insufficient = .{} };
    }
}

~process_payment = db.get_user(id: user_id)
| found u |> validate.amount(amount: amount, balance: u.balance)
    | valid |> payment.charge(user_id: u.id, amount: amount)
        | success tx |> success { transaction_id: tx.transaction_id }
        | failed |> insufficient_funds
    | insufficient |> insufficient_funds
| not_found |> user_not_found

This is real production code. A payment flow that:

  1. Looks up the user (impure - database)
  2. Validates the amount (pure - just logic)
  3. Charges the payment (impure - external API)

Now the tests:

~test(Successful payment) {
    ~db.get_user = found { id: 42, balance: 100 }
    ~payment.charge = success { transaction_id: "tx123" }

    ~process_payment(user_id: 42, amount: 50)
    | success s |> assert(s.transaction_id.len == 5)
}

~test(Insufficient funds rejected) {
    ~db.get_user = found { id: 42, balance: 30 }

    ~process_payment(user_id: 42, amount: 50)
    | insufficient_funds |> assert.ok()
}

~test(User not found) {
    ~db.get_user = not_found

    ~process_payment(user_id: 999, amount: 50)
    | user_not_found |> assert.ok()
}

Notice what’s NOT there:

  • No MockDbService implements IDbService
  • No container.register(IPaymentService, MockPaymentService)
  • No restructuring to pass dependencies as parameters
  • No when(db.getUser(any())).thenReturn(...)

You just say ~db.get_user = found { ... } and that’s what it returns.

The Reality Sandwich

Here’s what makes this different from traditional mocking:

┌─────────────────────────────────────────┐
│  MOCK ──→ db.get_user (impure)          │
│           │                             │
│  REAL ──→ validate.amount (pure)        │
│           │                             │
│  MOCK ──→ payment.charge (impure)       │
│           │                             │
│  REAL ──→ flow composition              │
└─────────────────────────────────────────┘

The pure business logic runs for real. validate.amount actually executes. Your flow composition actually executes. Only the I/O boundaries are mocked.

This isn’t unit testing (too compositional). It isn’t integration testing (too surgical). It’s something in between - you test real flows with type-safe mocks at the natural seams.

Cross-Module Mocking

It gets better. You can mock events from imported modules:

~import "$std/fs"

~process_config = std.fs:read_lines(path: path)
| lines l |> ok { line_count: l.len }
| failed msg |> failed { reason: msg }

~test(Cross-module mock) {
    ~std.fs:read_lines = failed "mocked file read error"

    ~process_config(path: "/nonexistent/file.txt")
    | failed result |> assert(result.reason.len > 0)
}

The mock overrides the standard library’s read_lines event. For this test only. With type safety - you can only mock with valid branches.

How It Actually Works

Here’s where it gets interesting. This is not a compiler feature. It’s a user-space library that uses Koru’s own toolchain.

1. AST Cloning

Each test gets its own “parallel reality” - a cloned subset of the program AST:

Main Module                    Test Module (cloned)
┌──────────────────┐          ┌──────────────────┐
│ process_payment  │    ──→   │ process_payment  │
│ db.get_user      │          │ db.get_user      │ ← mock injected
│ payment.charge   │          │ payment.charge   │ ← mock injected
│ validate.amount  │          │ validate.amount  │ ← runs real
└──────────────────┘          └──────────────────┘

The test module contains only the events needed for that test, with mocks substituted.

2. Mock Injection via Branch Constructors

When you write ~db.get_user = found { id: 42, balance: 100 }, the testing framework:

  1. Parses the mock declaration
  2. Creates a SubflowImpl with an immediate body (a branch constructor)
  3. Substitutes it into the cloned AST

The emitter sees this and generates:

const result: db_get_user_event.Output = .{ .found = .{ .id = 42, .balance = 100 } };

Type-annotated, so Zig can switch on it. The mock becomes a constant - zero overhead.

3. Purity Tracking

The compiler tracks purity through the entire flow graph. When the testing framework sees you call an impure event without a mock, it errors:

Error: Test calls impure event 'db.get_user' without mock
  Add: ~db.get_user = <branch> { ... }

This is compiler-enforced. You cannot accidentally call a database in a test.

But pure events? They just work. validate.amount doesn’t need a mock because it has no side effects. The test calls the real implementation.

4. Custom Emission

The testing framework uses Koru’s standard emitter to generate Zig test blocks:

test "Successful payment" {
    const result = test_module.process_payment_event.handler(.{
        .user_id = 42,
        .amount = 50
    });
    switch (result) {
        .success => |s| {
            try @import("std").testing.expect(s.transaction_id.len == 5);
        },
        // ...
    }
}

Same emitter that generates production code. Same type safety. Same optimization.

The assert() Transform

Even assert() is clever:

| success s |> assert(s.transaction_id.len == 5)

This isn’t a function call. It’s a compile-time transform that becomes idiomatic Zig:

try @import("std").testing.expect(s.transaction_id.len == 5);

Uses Zig’s standard testing library. Proper error returns. No runtime assertion library.

This Is A User-Space Library

Let me emphasize this: the testing framework is not built into the compiler.

It’s implemented in koru_std/testing.kz using the same tools available to any Koru library:

  • [comptime] events for compile-time processing
  • AST access via automatic program injection
  • Source block parsing for test body analysis
  • Standard emitter for code generation

The compiler provides the mechanisms. The testing library uses them.

This matters because:

  1. You could write your own testing framework
  2. The patterns are reusable for other compile-time tools
  3. We’re not special-casing tests in the compiler

What Category Is This?

Traditional testing has a spectrum:

  • Unit tests: Mock everything, test one function
  • Integration tests: Mock nothing, test the whole system

Koru offers a third option:

  • Flow tests: Mock impure boundaries, run real pure logic

The boundaries aren’t arbitrary. They’re determined by purity - a semantic property the compiler already tracks. You don’t design for testability. The architecture IS testable.

Some might call this “Seam Testing” or “Boundary-Isolated Testing.” We just call it testing. It’s how testing should have always worked.

Zero Ceremony

Let’s count what you DON’T need:

ApproachCeremony Required
OO/JavaInterfaces, DI containers, mock frameworks
C#Same, or expensive IL-rewriting tools
FunctionalRestructure code to pass dependencies
KoruNothing

Every event is already a mockable seam. Not because you designed it that way - because that’s what events ARE.

Try It

~import "$std/testing"

~event greet { name: []const u8 }
| greeting { message: []const u8 }

~test(Greeting works) {
    ~greet = greeting { message: "Hello!" }

    ~greet(name: "World")
    | greeting g |> assert(g.message.len > 0)
}

Run with koru build and zig test output_emitted.zig. Your tests execute as native Zig tests - fast, parallel, with full stack traces on failure.


The testing framework is available now in $std/testing. Nine regression tests verify everything from simple mocks to cross-module mocking with chained flows. All post-validated - actually running Zig tests, not just compiling.