Writing Tests with Std.Test
Std.Test is Riot’s core test-writing API. It is intentionally small:
- define cases
- make assertions
- optionally use property-style tests
- optionally attach fixtures and snapshots
- expose the suite through
Std.Test.Cli.main
That small surface is a strength. It means most test binaries can be understood quickly.
A minimal suite
Section titled “A minimal suite”open Std
let tests = Test.[ case "2 + 2 = 4" (fun _ctx -> Test.assert_equal ~expected:4 ~actual:(2 + 2); Ok ());]
let () = Actors.run ~args:Env.args () ~main:(fun ~args -> Test.Cli.main ~name:"math" ~tests ~args)Three things matter here:
Test.casecreates a named test case- the callback returns
(unit, string) result Test.Cli.mainturns the case list into a standard Riot test binary
That last point is crucial. Riot’s workspace runner depends on the shared test-binary contract instead of package-local CLIs.
The callback contract
Section titled “The callback contract”Every case receives a ctx value:
val case: string -> (ctx -> (unit, string) result) -> test_caseMany tests can ignore it:
Test.case "simple test" (fun _ctx -> Ok ())But the context becomes important once you care about:
- suite and test identity
- fixture-backed cases
- snapshot storage
- source-file and workspace metadata
The context carries:
suite_nametest_nametest_indexsource_filebinary_pathworkspace_rootpackage_namefixture
That means the API stays small without becoming blind.
Assertions
Section titled “Assertions”The core assertion helpers are:
Test.assert_equalTest.assert_trueTest.assert_falseTest.assert_okTest.assert_error
Typical examples:
open Std
let tests = Test.[ case "insert then lookup returns the value" (fun _ctx -> let map = HashMap.create () in HashMap.insert map "language" "ocaml"; Test.assert_equal ~expected:(Some "ocaml") ~actual:(HashMap.get map "language"); Ok ());
case "parse rejects malformed json" (fun _ctx -> let parsed = Data.Json.parse "{not valid json}" in Test.assert_error parsed; Ok ());
case "predicate holds" (fun _ctx -> Test.assert_true (String.length "riot" = 4); Test.assert_false (String.length "riot" = 0); Ok ());]Use direct assertions whenever they tell the story clearly. The simpler the contract, the more direct the test should be.
Returning Error
Section titled “Returning Error”Assertions raise on failure. But the callback returns a result because sometimes you want a
domain-specific failure message:
Test.case "custom validation" (fun _ctx -> let actual = some_rendered_output () in if String.contains actual "ok" then Ok () else Error ("expected rendered output to contain ok, got: " ^ actual))Use this when the failure text itself is part of the value of the test.
Std.Test.property
Section titled “Std.Test.property”Std.Test.property exists for property-flavored tests where you are controlling the repeated logic:
open Std
let tests = Test.[ property "custom repeated sampling" ~examples:1000 (fun _ctx -> (* your own sampling logic here *) Ok ());]This is not the same thing as full property-based testing with generation and shrinking. For that, use Property Testing with Propane.
The distinction is important:
Std.Test.propertyrecords that a case is property-like and how many examples it ranPropane.propertyactually generates values, shrinks failures, and returns aStd.Test.test_case
Skips and todos
Section titled “Skips and todos”Two constructors help when the suite is under construction:
Test.skipTest.todo
Example:
open Std
let tests = Test.[ skip "windows-only scenario not wired yet" (fun _ctx -> Ok ()); todo "property test for cache eviction semantics";]This is better than deleting intent or burying it in comments.
Fixture-backed suites
Section titled “Fixture-backed suites”When test inputs are better represented as files than inline strings, use Std.Test.FixtureRunner.
Its job is to:
- recursively discover fixture inputs
- skip snapshot artifacts like
.expectedand.expected.new - turn each input into a regular
Std.Test.case
Typical shape:
open Std
let run_fixture ({ Test.FixtureRunner.test; fixture_path; _ } : Test.FixtureRunner.ctx) = let input = Fs.read fixture_path |> Result.expect ~msg:"fixture should read" in let rendered = String.uppercase_ascii input in Test.Snapshot.assert_text ~ctx:test ~actual:rendered
let tests = Test.FixtureRunner.cases () ~dir:(Path.v "packages/my-package/tests/fixtures") ~run:run_fixtureThis is the right abstraction because it keeps fixture discovery standardized across packages.
Filtering fixture discovery
Section titled “Filtering fixture discovery”If the fixture directory contains helper files or several families of inputs, use ~filter:
open Std
let tests = Test.FixtureRunner.cases () ~dir:(Path.v "packages/my-package/tests/fixtures") ~filter:(fun path -> let name = Path.basename path in if String.ends_with ~suffix:".input" name then `keep else `skip) ~runThat keeps package-local file-selection logic out of the common path.
Snapshots in ordinary tests
Section titled “Snapshots in ordinary tests”When the output artifact itself is what you want to review, use Std.Test.Snapshot:
open Std
let tests = Test.[ case "pretty printer output" (fun ctx -> let actual = render_document () in Test.Snapshot.assert_text ~ctx ~actual);]For JSON:
open Std
let tests = Test.[ case "json protocol output" (fun ctx -> let actual = encode_message () in Test.Snapshot.assert_json ~ctx ~actual);]Snapshots are for artifact review, not for replacing every simple assertion.
A realistic suite
Section titled “A realistic suite”open Std
let tests = Test.[ case "parses an object" (fun _ctx -> let actual = Data.Json.parse {|{"a":1}|} in Test.assert_ok actual; Ok ());
case "rejects malformed input" (fun _ctx -> let actual = Data.Json.parse {|{"a":|} in Test.assert_error actual; Ok ());
case "pretty output stays stable" (fun ctx -> let actual = Data.Json.Object [ ("name", Data.Json.String "riot"); ("stars", Data.Json.Int 1) ] |> Data.Json.to_string_pretty in Test.Snapshot.assert_text ~ctx ~actual);]
let () = Actors.run ~args:Env.args () ~main:(fun ~args -> Test.Cli.main ~name:"json parser" ~tests ~args)That one file already gives you:
- named cases
- direct assertions
- snapshot support
- a suite binary Riot can discover and run
That is the core value of Std.Test: the common path is small, but it scales.