Skip to content
Riot Docs

Search is only available in production builds. Try building and previewing the site to test it out locally.

Install Riot GitHub

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.

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:

  1. Test.case creates a named test case
  2. the callback returns (unit, string) result
  3. Test.Cli.main turns 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.

Every case receives a ctx value:

val case: string -> (ctx -> (unit, string) result) -> test_case

Many 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_name
  • test_name
  • test_index
  • source_file
  • binary_path
  • workspace_root
  • package_name
  • fixture

That means the API stays small without becoming blind.

The core assertion helpers are:

  • Test.assert_equal
  • Test.assert_true
  • Test.assert_false
  • Test.assert_ok
  • Test.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.

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 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.property records that a case is property-like and how many examples it ran
  • Propane.property actually generates values, shrinks failures, and returns a Std.Test.test_case

Two constructors help when the suite is under construction:

  • Test.skip
  • Test.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.

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 .expected and .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_fixture

This is the right abstraction because it keeps fixture discovery standardized across packages.

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)
~run

That keeps package-local file-selection logic out of the common path.

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.

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.