SCUA

How-to

Test your code

SCUA has a built-in test runner. You write test functions in *_test.scua files, assert what you expect, and run scua test. It reports pass/fail and exits non-zero on failure, so it fits straight into CI.

#A first test

Put your tests in a file whose name ends in _test.scua, and write each test as a function named test_*:

-- inventory_test.scua
fn add_item(bag, item)
  bag.push(item)
  return bag
end

fn test_add_one()
  let bag = add_item([], "sword")
  assert_eq(len(bag), 1)
end

fn test_add_two()
  let bag = add_item(add_item([], "sword"), "shield")
  assert_eq(bag[1], "shield")
end

Run the tests from the directory that holds them:

$ scua test
PASS  ./inventory_test.scua  test_add_one
PASS  ./inventory_test.scua  test_add_two

2 passed, 0 failed (1 file)

scua test finds every *_test.scua file under the current directory (or a directory you name: scua test tests/, or a single file: scua test one_test.scua). You can name several — files, directories, or a mix — and all of them run: scua test tests/ extra_test.scua. Each test_* function runs on its own, so one failing test doesn't stop the others.

If your project has dependencies — a scua.toml and a vendored deps/ — your tests resolve them exactly as running the program does; there is nothing to configure. For a tree with no lock, --mod-path DIR adds a module search directory. A test that forks an actor (actors.revive(blob, { fork = true })) needs --allow-fork, as a run does. It refuses an option it does not understand, and lists the ones it takes, rather than ignore it.

#Assertions

The runner doesn't need a special assertion library; the assert family is built in.

  • assert(cond, msg?) faults with msg unless cond is truthy.
  • assert_eq(got, want) faults unless the two values are equal, and the message shows both.
  • assert_ne(a, b) faults if the two values are equal.
  • assert_near(got, want, eps) faults unless got and want are within eps of each other. Use it for floats, which aren't exact.

To check that something succeeded, don't pass the Result straight to assert: assert(parse(s)) is refused, because an Error would otherwise pass. Compare the whole value with assert_eq(parse("42"), Ok(42)), or just the outcome with assert(parse("42") matches Ok).

A failing comparison tells you what it saw:

fn test_math()
  assert_eq(2 + 2, 5)
end
$ scua test
FAIL  ./math_test.scua  test_math assert_eq failed: expected 5, got 4

0 passed, 1 failed (1 file)

#How a test fails

A test fails when it raises a fault that nothing catches: a failed assert, a runtime fault like an out-of-range index, or an explicit error(...). Anything that would stop a normal program stops the test and marks it failed, with the located message. A test also fails when it returns an Error value, whether by return Error(...) or by a ? that propagated one, and the report says returned Error: with the value. A test that runs to the end without faulting and returns anything else passes.

Every failure gets its own FAIL line naming the test, however long its message is. A message that spans several lines shows its later lines indented under the first, and one longer than 8 KiB is cut there, ending with … [message cut: N bytes in all] so you know there was more.

Because each test_* runs in isolation, a fault in one is reported and the rest keep going. That holds even for a test that stops the whole program: one that runs out of memory, or calls sys.exit, fails on its own and the tests after it still run. If a file has a syntax, type, or compile error, the whole file is reported as an error and its tests count as failures.

#Testing partitions

A test drives a partition by telling it something. A tell returns before the handler runs, so after each test the runner waits for the handlers it told (up to 2 seconds of the test's clock) before it reports the test. If one of them faults, or ends with an Error that no ask was waiting for (a ? in a told handler, say), that test fails with the handler's message:

partition Worker
  on Check(n)
    assert_eq(n, 42)
  end
end

fn test_the_worker_accepts_42()
  tell Worker().Check(42)
end

fn test_the_worker_accepts_41()
  tell Worker().Check(41)
end
$ scua test
PASS  ./worker_test.scua  test_the_worker_accepts_42
FAIL  ./worker_test.scua  test_the_worker_accepts_41 ./worker_test.scua:3: actor fault: assert_eq failed: expected 42, got 41

1 passed, 1 failed (1 file)

To check that a handler refuses a message, ask it: the Error comes back to the test as the reply, and the test can assert on it.

What the file's top level tells runs before the first test, so a handler it drove that faults is reported as the file's error, not as the first test's. (A handler there that ends with an Error is a refused message, as in a normal run, and fails nothing.) A partition that is still waiting on a timer when those 2 seconds are up is left to it: its later faults are reported for the file.

#Checking typed data all the way down

A typed parameter or return checks the value one level deep as it arrives: a record's own fields must be their declared kinds, a field declared as a record must hold a table, and a field declared as a list must hold an array. It does not look inside the nested records or at the list's elements, so a call costs the same however much data the value holds.

To check everything below as well, run with --check=deep, or set SCUA_CHECK=deep in the environment. Every typed parameter and return then checks the whole value: nested records, list elements and their where rules. It only adds faults. A program that passes without it either passes with it or stops sooner, at the call that was handed the bad value. It is slower, so use it for test runs and CI rather than for shipping.

Here a function with no types corrupts a value that show later receives:

record Item { id: string }
record Big { items: { Item } = [], n: int = 0 }
fn show(b: Big) -> string return b.items[0].id end
fn loose(x) x.items[0].id = 5 end
let b: Big = { items = [{ id = "a" }] }
print(show(b))
loose(b)
print(show(b))
$ scua --check=deep bag.scua
a
scua: bag.scua:3: parameter 'b' of 'show': b.items[0].id is int, expected string
  at show (bag.scua:3)
  at main (bag.scua:8)

Without the flag, b passes show's parameter, because only b's own fields are checked there, and the bad value is caught one step later by the declared return type: return value of 'show' is int, expected string. Without -> string it would print 5.

scua test --check=deep runs your tests the same way.

#Files without test functions

If a *_test.scua file has no test_* functions, the whole file is treated as a single test: it passes if it runs without an uncaught fault. This is handy for a quick check or a script-style test.

-- smoke_test.scua
assert(1 < 2, "basic sanity")
assert_near(0.1 + 0.2, 0.3, 0.001)

#Testing code that reads the program's data

A test reads res: the way the program does. With a res/ folder at the project root, a test file can import fs and read fs.read_text("res:maps/keep.json") with no flag. Host files stay out of reach: reading one fails the test, because scua test grants no --allow-fs.

To test against built packs, pass --mount and --res as a run takes them. A relative path is from the project root, whichever folder the test file is in:

$ scua test --mount=build/patch1.pak tests
$ scua test --res=build/base.pak tests     # the built pack in place of res/

Each test file gets its own view of the data, so a fs.mount in one file does not reach the next.

#Testing code that saves

Every test file gets a per-user folder of its own, so code that writes user: paths, such as a game's saves and settings, runs under scua test as it does in a shipped run. The folder is new and empty for each file, made private in your temporary folder (TMPDIR, or TEMP on Windows), and never the real one, whatever --allow-user the program ships with. fs.mounts().user.id is the project's [app] id from scua.toml, or "" without one, so code that checks it sees what a shipped run sees.

import fs

fn test_a_save_reads_back()
  fs.mkdir("user:saves")?
  fs.write("user:saves/slot1.json", "{\"level\":\"keep\"}")?
  assert_eq(fs.read_text("user:saves/slot1.json")?, "{\"level\":\"keep\"}")
end
$ scua test save_test.scua
PASS  save_test.scua  test_a_save_reads_back

1 passed, 0 failed (1 file)

When every test in the file passes, the folder is removed. When one fails and the file wrote something there, it is kept and the run prints where, so you can read what the test wrote. Had the test expected lost instead of keep:

FAIL  save_test.scua  test_a_save_reads_back assert_eq failed: expected {"level":"lost"}, got {"level":"keep"}
scua test: kept save_test.scua's per-user folder: /tmp/scua-test-319018b9dc33b0b3

0 passed, 1 failed (1 file)

An actor the test creates holds the same folder, and an actor snapshot revives into it, so a test of a save actor follows the path the game takes. Host paths stay out of reach here too.

#Testing code that needs a capability

scua test cannot be handed a capability grant. A file that says import net (or http) is therefore unreachable from tests — including the functions in it that only shuffle bytes and touch nothing. Splitting the file in two gets the pure half back, but then the language has chosen your architecture for you. (fs is the exception: tests can import fs to read the program's res: data and to write its per-user folder, as the sections above say, but never host files.)

The way out is to take the capability as a parameter instead of importing it. The code under test never names the real thing, so it needs no grant, and a test hands it a stand-in you wrote by hand:

-- transport_test.scua
import str

-- The network is a parameter, not an import: `send` is fn(url) -> Ok(body) | Error(e).
-- Nothing in this file imports `http`, so `scua test` needs no grant to reach it.
fn shout_page(send, url)
  let body = send(url)?
  return Ok(str.upper(body))
end

fn test_body_is_shouted()
  let fake = fn(u) return Ok(`hello from {u}`) end
  match shout_page(fake, "example.com")
    Ok(b)    -> assert_eq(b, "HELLO FROM EXAMPLE.COM")
    Error(e) -> assert(false, `unexpected error: {e}`)
  end
end

fn test_a_refused_connection_propagates()
  let broken = fn(u) return Error("connection refused") end
  match shout_page(broken, "example.com")
    Ok(b)    -> assert(false, `expected an error, got {b}`)
    Error(e) -> assert_eq(e, "connection refused")
  end
end
$ scua test transport_test.scua
PASS  transport_test.scua  test_body_is_shouted
PASS  transport_test.scua  test_a_refused_connection_propagates

2 passed, 0 failed (1 file)

No grant on that command line, and the error path is as easy to test as the happy one — a refused connection is a function that returns Error, not a network you have to arrange.

The real capability is bound in an adapter: the one small place that names it.

import http

-- The only file that names the capability. Pure delegation, and the only part
-- a hand-written stand-in cannot replace.
fn http_transport()
  return fn(url)
    let resp = http.get(url)?
    return Ok(resp.body)
  end
end

print(shout_page(http_transport(), "http://example.com"))

Be honest about what this does and doesn't buy you. The adapter itself stays untested — it needs the grant, so scua test cannot reach it. That is a real gap, but it is a handful of lines of delegation with no branching, rather than your whole library.

The pattern pays for itself a second time, which is the reason to reach for it even where testing isn't the motive: a host embedding SCUA can supply its own transport too. The same parameter that takes your stand-in in a test takes the host's HTTP client in production. A library written this way works under a host that has no http capability to grant, because it never asked for one.

#A note on actors

The runner shines for synchronous logic: pure functions, data transforms, validation, anything you can call and assert on directly. Testing a partition's message handling is harder, because a partition processes its messages after your test function has already returned, so you can't tell an actor and assert on the result in the same test. For now, test the plain functions your handlers call (keep the logic out of the handler where you can), and check end-to-end actor behaviour by running a small program and looking at its output.