Testing
Saga has a built-in test runner that discovers and executes tests automatically.
Tests are ordinary modules that export a tests function using the Testing
effect. The effect system handles test isolation naturally: swap a handler and
your function runs against a different implementation with zero mocking
libraries.
Writing Tests
Test files live in the tests/ directory and are regular saga modules. Each
test module exports a pub fun tests function that registers tests using
functions from Std.Test:
module MathTest
import Math
import Std.Test (Testing, describe, test, assert_eq)
pub fun tests : Unit -> Unit needs {Testing}
tests () = {
describe "Math" (fun () -> {
test "addition" (fun () -> {
assert_eq (Math.add 2 3) 5
})
test "subtraction" (fun () -> {
assert_eq (Math.sub 5 3) 2
})
})
}test, describe, skip, and only are ordinary functions imported from
Std.Test. test and skip take a name and a lambda. describe groups
related tests and can be nested:
pub fun tests : Unit -> Unit needs {Testing}
tests () = {
describe "User" (fun () -> {
describe "validation" (fun () -> {
test "rejects empty name" (fun () -> {
assert_eq (validate "") (Err "name required")
})
test "accepts valid input" (fun () -> {
assert_eq (validate "Alice") (Ok "Alice")
})
})
})
}Assertions
Std.Test provides assertion functions that all use the Test effect
internally. The test runner handles this effect automatically:
| Function | Checks |
|---|---|
assert_eq | a == b |
assert_neq | a != b |
assert_true | value is True |
assert_false | value is False |
assert_gt | a > b |
assert_gte | a >= b |
assert_lt | a < b |
assert_lte | a <= b |
assert_some | value is Just _ |
assert_none | value is Nothing |
assert_ok | value is Ok _ |
assert_err | value is Err _ |
On failure, assertions produce a descriptive message:
Expected 5, got 3
Expected True, got False
Expected Ok(_), got Err("not found")
You can also write your own assertion helpers by calling assert! directly:
import Std.Test (Test)
fun assert_positive : Int -> Unit needs {Test}
assert_positive n = assert! (n > 0) $"Expected positive, got {n}"Shared Setup
Since tests are registered from a regular function, shared setup is just
ordinary let bindings before your test registrations:
pub fun tests : Unit -> Unit needs {Testing}
tests () = {
describe "User" (fun () -> {
let user = User { name: "Alice", age: 30 }
test "has name" (fun () -> {
assert_eq user.name "Alice"
})
test "has age" (fun () -> {
assert_eq user.age 30
})
})
}Testing with Effects
The effect system makes test isolation natural. Functions that use effects are testable by providing different handlers:
pub effect Database {
fun query : String -> List String
}
pub fun get_users : Unit -> List String needs {Database}
get_users () = query! "SELECT name FROM users"test "returns query results" (fun () -> {
let result = {
get_users ()
} with {
query sql = resume ["alice", "bob"],
}
assert_eq result ["alice", "bob"]
})No mocking library, no dependency injection setup. The test provides a handler and the function runs against it.
Testing Panics
assert_panics verifies that a function panics when it should:
test "head of empty list panics" (fun () -> {
assert_panics (fun () -> List.head [])
})only and skip
skip marks a test to be reported but not executed. only is global across
the selected suite: if any only exists anywhere, every non-only test in
every selected module is reported as skipped.
skip "not ready yet" (fun () -> {
assert_eq 1 2
})
only "focus on this" (fun () -> {
assert_eq 1 1
})Running Tests
saga test # run all tests
saga test math # filter by name
Test files are regular modules that import the code under test and only have
access to pub items. The describe/test hierarchy maps directly to
indented output:
math_test
✓ addition
✓ subtraction
user_test
User
validation
✓ rejects empty name
✓ accepts valid input
age
✗ must be positive (Expected 5 to be greater than 0)
Tests: 4 passed, 1 failed, 5 total
saga test exits with code 0 if all tests pass, code 1 if any test fails,
so it works directly in CI pipelines.
Under the Hood
The entire test framework is built on saga's own effect system. The Test
effect exposes a single assert operation:
effect Test {
fun assert : (ok: Bool) -> (msg: String) -> Unit
}All assertion helpers (assert_eq, assert_true, etc.) are ordinary functions
that call assert!. For example:
fun assert_eq : a -> a -> Unit needs {Test} where {a: Debug + Eq}
assert_eq a b =
if a == b then assert! True ""
else assert! False $"Expected {debug b}, got {debug a}"The runner handles each test body with a handler that resumes on success and aborts on failure:
body () with {
assert ok msg =
if ok then resume ()
else Failed msg
return _ = Passed
}When an assertion passes, the handler calls resume to continue the test.
When one fails, it simply doesn't resume, and the test stops at that point and records a failure. No
special control flow, no exceptions, no catching panics, no macros. Fail-fast is just what happens
when a handler chooses not to resume. The same mechanism that powers
application logic powers the test framework too.