# Inline tests: `ppx_expect_light` + `ppx_optcomp_light` 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 {| … |}]`. ## 1. Declaring a test library (dune) ```dune (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_light` to `(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 uses `Js.*` values cannot run `best`, 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. ## 2. Writing a test ```ocaml 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 `; if the output changed and the new output is correct, accept it with `dune promote`. ## 3. Gating: two mechanisms, two purposes 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. ### 3.1 Compile-time gating — `[@@@if]` and `[@@if]` `[@@@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: ```ocaml (* 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: ```ocaml let%expect_test "float16 round-trip" = … [%expect {| … |}] [@@if ocaml_version >= (5, 2, 0)] ``` ### 3.2 Run-time gating — `[@when]` and `[@tags]` `[@when COND]` on the test description skips the whole test at run time when `COND` is false: ```ocaml 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: ```ocaml let%expect_test "float32 marshalling" = print_string (Marshal.to_string 1.0s []); [%expect ({| |} [@when js])]; [%expect {| |}] (* 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`. ```ocaml let%expect_test ("JS calling convention" [@tags "js-only"]) = … ``` (`[@when]` and `[@tags]` can be combined on the same test.) ### 3.3 Predicate grammar 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` (shorthands `js` / `wasm` / `native`), `host_engine` (shorthands `node` / `quickjs` / `wasi`), `target_engine`, `os_type` (shorthands `win32` / `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]`). ## 4. Running tests ```bash dune runtest # 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. ## 5. Running a single file or test Each file is its own partition (keyed by basename). The inline-test runner is built next to the library, e.g. `_build/default//..inline-tests/inline-test-runner.bc.js` (and `.exe` for native, `.bc.wasm.js` for wasm). Invoke it directly: ```bash R=_build/default/compiler/tests-jsoo/.jsoo_testsuite.inline-tests/inline-test-runner.bc.js node "$R" inline-test-runner -list-partitions # list partitions node "$R" inline-test-runner -partition test_foo.ml -verbose node "$R" inline-test-runner -only-test test_foo.ml:42 # a file, or file:line node "$R" inline-test-runner -matching "substring" # by description ``` Useful flags: `-verbose` (list every test, incl. `(skipped)` ones), `-partition `, `-only-test `, `-matching `, `-drop-tag ` / `-require-tag `, `-list-partitions`. When iterating on one test, run just its directory (`dune runtest `) or the runner with `-only-test`, not the whole suite. ## 6. Choosing a gate — worked decisions - *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 `Random` changed 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.wait` blocks forever natively but the test should run on js/wasm* → `[@when not native]`. ## See also - `compiler/tests-jsoo/` — the largest collection of real examples. - `compiler/tests-ocaml/REAME.md` — the imported OCaml testsuite and the `tools/sync_testsuite.exe` tool.