js_of_ocaml ships its own, self-contained inline-test toolchain (a small
ppx_expect replacement) so the test suite can run unmodified across the
JavaScript, WebAssembly and native backends and across the OCaml versions we
support. It lives in three sibling directories:
| directory | what it provides |
|---|---|
compiler/ppx-expect-light/ |
the let%expect_test / [%expect] rewriter + a runner |
compiler/ppx-optcomp-light/ |
compile-time gating with [@@@if] / [@@if] |
compiler/ppx-light-predicate/ |
the predicate grammar shared by both |
Tests are predominantly inline expect tests (not cram), so most of what you
write looks like let%expect_test … [%expect {| … |}].
(library
(name my_tests)
(inline_tests
(modes js wasm best)) ; run under js_of_ocaml, wasm_of_ocaml, and native
(preprocess
(pps ppx_optcomp_light ppx_expect_light)))- Add
ppx_optcomp_lightto(pps …)only if the module uses[@@if]/[@@@if](it is harmless otherwise, but keep the list minimal). (modes …):js,wasm,best(native). Pick what makes sense; a test that usesJs.*values cannot runbest, so use(modes js wasm).- Each source file is its own inline-test partition, so grouping many modules into one library does not cost test parallelism — prefer one library over a library-per-module.
let%expect_test "addition" =
Printf.printf "%d" (1 + 2);
[%expect {| 3 |}]- The description is a string literal, or
_for an anonymous test. [%expect {| … |}]checks everything printed to stdout/stderr since the previous assertion point. Consecutive[%expect]s are coalesced into one group (see conditional snapshots below).[%expect.output]returns the captured output as a string instead of checking it, e.g.let s = [%expect.output] in ….
Run dune runtest <dir>; if the output changed and the new output is correct,
accept it with dune promote.
There are two independent ways to make a test behave differently across configurations. Choosing the right one matters:
[@@if] / [@@@if] (ppx_optcomp_light) |
[@when] / [@tags] (ppx_expect_light) |
|
|---|---|---|
| when | compile time — the gated code is removed | run time — the code is compiled, the test is skipped (or a snapshot variant chosen) |
| use when | the code wouldn't compile on the excluded config (uses an API/syntax that doesn't exist there) | the code compiles everywhere but should be skipped or produce different output on some backend/engine/version |
| atoms | compile-time only (see §3.3) | runtime axes (see §3.3) |
Rule of thumb: reach for [@when]; use [@@if] only to keep code compiling
on a version/arch that lacks an API.
[@@@if COND] is a floating attribute: when COND is false, every
following item in the file is dropped; when true, the marker is removed. Use it
to gate a whole module on an OCaml version:
(* In_channel.input_all is OCaml >= 5.1 only *)
[@@@if ocaml_version >= (5, 1, 0)]
let%expect_test _ = …[@@if COND] gates a single item (a let, a let%expect_test, an
external, a module …). Prefer it to a whole-file [@@@if] when only a few
items need the gate, so the rest of the file still builds on older compilers:
let%expect_test "float16 round-trip" =
…
[%expect {| … |}]
[@@if ocaml_version >= (5, 2, 0)][@when COND] on the test description skips the whole test at run time when
COND is false:
let%expect_test ("uses process.env.TZ" [@when not quickjs]) = …
let%expect_test (_ [@when ocaml_version >= (5, 0, 0)]) = …[@when COND] on a [%expect] selects a conditional snapshot: list one
variant per condition plus a plain default. The matching variant is checked;
the plain one is the default. Use this to run one test everywhere when only
its output differs:
let%expect_test "float32 marshalling" =
print_string (Marshal.to_string 1.0s []);
[%expect ({| <js bytes> |} [@when js])];
[%expect {| <native/wasm bytes> |}] (* default *)[@tags "a", "b"] attaches inline-test tags that the runner drops by config.
Built-in tags:
disabled, js-only / no-js, wasm-only / no-wasm, native-only,
quickjs-only / no-quickjs, wasi-only / no-wasi,
64-bits-only / 32-bits-only.
let%expect_test ("JS calling convention" [@tags "js-only"]) = …([@when] and [@tags] can be combined on the same test.)
Both ppxs parse the same expression grammar
(Ppx_light_predicate.Predicate): boolean not, &&, ||; comparisons =,
<>, <, <=, >, >=; integer/string literals; and version tuples like
(5, 2, 0) compared against ocaml_version.
The atoms differ because one is evaluated at compile time and the other at run time:
- Compile time (
[@@if]/[@@@if]):ocaml_version,ast_version,oxcaml,os_type,arch_sixtyfour. - Run time (
[@when]):backend(shorthandsjs/wasm/native),host_engine(shorthandsnode/quickjs/wasi),target_engine,os_type(shorthandswin32/unix/cygwin),ocaml_version,effects("disabled"/"cps"/ …),oxcaml.
So [@when js && not quickjs], [@when effects <> "cps"],
[@when not native], [@@if os_type <> "Win32"],
[@@if arch_sixtyfour] are all valid; but a backend atom like js is only
available at run time ([@when]), and arch_sixtyfour only at compile time
([@@if]).
dune runtest <dir> # js + native, default profile
dune promote # accept changed output
WASM_OF_OCAML=true dune build @runtest-wasm # wasm
dune build @runtest --profile quickjs # run JS under QuickJS (qjs)
make tests # the whole suite (needs a recent Node)A failing snapshot prints a patdiff and writes a *.corrected file; dune promote copies it back over the source.
Each file is its own partition (keyed by basename). The inline-test runner is
built next to the library, e.g.
_build/default/<dir>/.<lib>.inline-tests/inline-test-runner.bc.js (and
.exe for native, .bc.wasm.js for wasm). Invoke it directly:
R=_build/default/compiler/tests-jsoo/.jsoo_testsuite.inline-tests/inline-test-runner.bc.js
node "$R" inline-test-runner <lib> -list-partitions # list partitions
node "$R" inline-test-runner <lib> -partition test_foo.ml -verbose
node "$R" inline-test-runner <lib> -only-test test_foo.ml:42 # a file, or file:line
node "$R" inline-test-runner <lib> -matching "substring" # by descriptionUseful flags: -verbose (list every test, incl. (skipped) ones),
-partition <file.ml>, -only-test <file.ml[:line]>, -matching <substr>,
-drop-tag <t> / -require-tag <t>, -list-partitions.
When iterating on one test, run just its directory (dune runtest <dir>) or the
runner with -only-test, not the whole suite.
- Test uses
In_channel.input_all(5.1+) → won't compile on 5.0 →[@@if ocaml_version >= (5, 1, 0)]. - Test compiles everywhere but its digest differs because
Randomchanged in 5.0 → conditional snapshot[%expect ({| … |} [@when ocaml_version >= (5, 0, 0)])]- a default.
- Test exercises JS calling conventions, meaningless on wasm/native →
[@tags "js-only"](or[@when js]). - Codegen snapshot whose stdlib field indices shift at 5.5 → promote to the
5.5 output and
[@@@if ocaml_version >= (5, 5, 0)](older jobs skip it). Condition.waitblocks forever natively but the test should run on js/wasm →[@when not native].
compiler/tests-jsoo/— the largest collection of real examples.compiler/tests-ocaml/REAME.md— the imported OCaml testsuite and thetools/sync_testsuite.exetool.