Tests in Refingere are ordinary functions with an attribute, written next to the code they test. There's no assertion library and no mocking library: a test fails by ending in an error, and it replaces the clock, the file system or the output through the context. This page covers writing, organising and running tests.
@test makes a function a test. It passes if it ends normally, and fails if it ends in an error. So a check is an if that reaches error when something's wrong, and with attaches what was actually found, which the report shows. The name in backticks is the description, so it can be a whole sentence.
Tests can go in any file, and any number of them - usually right after the code they test, in the same file.
Some tests are slower, or need more around them, than the ones run on every change - end to end through a running service, against a real database. Stage names after @test say which kinds of test it is; they're names of your own, as many as it needs. A test in no stage always runs, and one in stages runs only when asked for:
!! works in a test like in any function: an error passes up and ends the test, which fails with that error. A test that uses a function that can fail doesn't need to handle each error - only to check the results.
When the error is the expected result, match on it. Error(e) with value reads the message and the attached value, and a type like IOError checks the kind of error. The other case is the failure: a value where an error should have been. See Pattern Matching.
An HTTP route is an ordinary function that reads the request from &request and gives a Response, so a test injects the request it wants, calls the route, and checks the response's status, headers and body - no server, no network. Request's fields all have defaults, so the test gives only the parts that matter. See Servers and Browser Apps.
Code reaches the terminal, files, time, randomness, environment and arguments only through context values, so a test can replace each of them with inject, and the code under test doesn't change. Every injection lasts until the end of the test. Ready-made replacements are under #builtin.implementation:
| Value | Replacement | Lets the test |
|---|---|---|
&stdout, &stderr | MemoryOutputStream, NullOutputStream | read what was printed, or silence it |
&stdin | MemoryInputStream | script the input |
&fs | MemoryFileSystem | start with given files, and check what was written |
&clock | FixedClock, OffsetClock | fix the time, and move it forward |
&random | SeededRandom | get the same random numbers every run |
&env | MemoryEnvironment | set environment variables |
&logger | MemoryLogger | check what was logged |
&snapshots | MemorySnapshots | check snapshots without writing files |
&heapAllocator | TrackingAllocator | check for leaks, and simulate running out of memory |
&args | any Array<String> | pass arguments |
Your own dependencies work the same way - see Dependency Injection. The Context reference lists every replacement with its methods.
A FixedClock only moves when told to. Its sleep returns at once, moving the time forward instead of waiting, so code that waits between retries is tested instantly. See Retrying with backoff.
A snapshot test compares a value's text with the text kept for it last time. &snapshots.check(name, text) passes when the two are the same. The first time there's nothing kept, so it keeps text and passes. After that, a different text fails, showing the first line that differs:
When the change is the one you meant, run the tests again with --update-snapshots: every check whose text differs keeps the new one instead of failing. Review what changed in the files before committing them - an updated snapshot passes whatever it holds.
The texts are files in the program's folder, snapshots/<name>.snap, wherever the tests are run from, so they're reviewed and committed with the code. A character a file name can't hold (/ \ : * ? " < > |) becomes _, so give each snapshot a name of its own, even when two would differ only there. The files are the program's own, written even when a test replaces &fs with files in memory. Where files can't be written - in a web page - the texts are kept for the rest of the run instead.
&snapshots is a context value like the others, so a test of code that checks snapshots injects #builtin.implementation.MemorySnapshots.new(), as the example does to keep the docs from writing files. It can start with texts by name, and texts() shows what it keeps after. To keep snapshots somewhere else - a database, one file for the whole suite - implement SnapshotStore's read, write and updating, and inject that; check comes with the interface.
A property test checks a rule that should hold for every value, not one example. A Gen makes the values: Gen.integers, floats, booleans, strings, arrays and oneOf, combined with map, filter and andThen to build your own types. check runs the property on a hundred values, small ones first. When one fails, it tries smaller values and reports the smallest one that fails too - had dedupe forgotten to look at what it kept, the first property would fail like this:
The values come from &random, so injecting a SeededRandom makes the same values on every run - to repeat a failure while you fix it, or to keep a slow property's runs fixed in CI.
@testHelper marks support code for tests - sample data, shared checks. It isn't run as a test itself, and using it outside a test or another helper is a warning, so test code doesn't leak into the program. A helper that checks something returns an error, and the test passes it up with !!.
An HTTP route is an ordinary function, so a test calls it directly - no server, no requests - and checks its result or its error's status. A browser component is an ordinary struct: a test calls its methods, and reads what it renders with render().toString(). See HTTP Server and Webapp.
--test runs the project's tests, after checking that it compiles - those in no stage, and with --test=stage, those in it (see Stages). Each test prints PASS or FAIL with its name, and a failure its error - --test-name= that name runs just that test. --watch runs them again on every save. In VS Code, the Refingere extension lists every test in the Testing view, with a run button by each. When any test fails, the command exits with status 1, so it can gate a CI pipeline. See Command Line.
On Bun and Node, tests run side by side, one per core. Each one then runs on a fresh copy of the program, so no test sees what another changed. The report comes out in the same order, with each test's output before its line, whichever test finishes first. A test that panics fails, and the others still run. REFINGERE_THREADS=1 runs them one after another, as a WASI runtime does. A test can use threads of its own too, so a test that checks many files can check them all at once. See Threads.
@bench marks a function to be timed rather than checked, and lives beside the tests the same way - with stages of its own, and --bench-whole to time the tests too. See Benchmarking.