SagaSaga
Guide

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:

FunctionChecks
assert_eqa == b
assert_neqa != b
assert_truevalue is True
assert_falsevalue is False
assert_gta > b
assert_gtea >= b
assert_lta < b
assert_ltea <= b
assert_somevalue is Just _
assert_nonevalue is Nothing
assert_okvalue is Ok _
assert_errvalue 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.