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 withmsgunlesscondis 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 unlessgotandwantare withinepsof 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.