Testing Without Ceremony: How Koru Makes Every Event a Mockable Seam
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:
- Looks up the user (impure - database)
- Validates the amount (pure - just logic)
- 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:
- Parses the mock declaration
- Creates a
SubflowImplwith animmediatebody (a branch constructor) - 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:
- You could write your own testing framework
- The patterns are reusable for other compile-time tools
- 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:
| Approach | Ceremony Required |
|---|---|
| OO/Java | Interfaces, DI containers, mock frameworks |
| C# | Same, or expensive IL-rewriting tools |
| Functional | Restructure code to pass dependencies |
| Koru | Nothing |
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.