When building preprocessor extensions (ppx) in OCaml, testing is crucial. You want to ensure your ppx works correctly and continues to work as you make changes. After experimenting with different approaches, I've found that cram tests fit well for the task.
As explained in my previous post, cram tests are essentially command-line snapshot test sessions.
They're simple text files that contain commands and their expected output, which makes them particularly valuable for ppx development:
I would assume that just the fact that you are reading this, you are already convinced that’s a good idea and if there was something left, with the previous 3 items packed with good arguments, I count it as done.
Let’s start with the how to... you need an opam switch, a dune project, a ppx and almost ready to go. If you miss some of those, check this repository https://github.com/ml-in-barcelona/ppxlib-simple-example to have a minimum setup.
Unrelated to testing, but if you want to learn more about ppxes check ppx-by-example.
The goal here is to make our ppx transformations accessible to our tests via an executable, but wait!
It might be benefitial to understand a bit of the dune's model. before writing any tests. Before writing any tests, it's helpful to understand how Dune organizes build artifacts and executables.
dune scans your project looking for dune files. When it finds one, it generates build artifacts in the _build directory. The contents of these dune files determine exactly what gets built.
For example, when you specify a ppx rewriter:
; a library that uses a ppx rewriter
(library
(name my_program)
(preprocess (pps my-ppx)))
; a library that uses a ppx rewriter
(library
(name my_program)
(preprocess (pps my-ppx)))
dune looks for a library marked as a ppx rewriter:
(library
(name my_ppx)
(public_name my-ppx)
(kind ppx_rewriter))
(library
(name my_ppx)
(public_name my-ppx)
(kind ppx_rewriter))
From this, it creates a "driver" - a single executable that can contain multiple ppx transformations and is optimised to run across your codebase.
When dune finds an executable and run the build it will generate an executable in the _build/default/src/
folder, called my_program.exe
.
(executable
(name my_program))
(executable
(name my_program))
If you add a public name:
(executable
(name my_program)
(public_name my-program))
(executable
(name my_program)
(public_name my-program))
The executable becomes available at _build/install/default/bin/my-program
. This makes it accessible both within your project and to users who install your package. Similar to adding a install stanza to your dune file.
Here's are the steps to achieve this:
I would keep the executable inside the tests folder, but you can put it anywhere you want.
(executable
; name of your executable _build/default/.../standalone.exe
(name standalone)
; tell dune to only use "standalone.ml" to generate the executable
; this isn't strictly necessary, but it's a good practice to keep only the modules
; that are part of the target and avoid "Multiple rules generated for ..."
(modules standalone)
(libraries ppxlib your_ppx))
(executable
; name of your executable _build/default/.../standalone.exe
(name standalone)
; tell dune to only use "standalone.ml" to generate the executable
; this isn't strictly necessary, but it's a good practice to keep only the modules
; that are part of the target and avoid "Multiple rules generated for ..."
(modules standalone)
(libraries ppxlib your_ppx))
standalone.ml
filelet () = Ppxlib.Driver.standalone ()
let () = Ppxlib.Driver.standalone ()
This exposes your ppx transfomrations with ppxlib's Driver.standalone
which defines a CLI to run your ppx transformations directly, and simulates what dune exposes to your users when your ppx is part of a build-step in a project.
There are a few other ways to make the executable available, explained in my previous post: https://sancho.dev/blog/cram-tests-a-hidden-gem-of-dune#make-your-executable-available-under-cram-tests, but this is the simplest one.
(cram
; Tells dune to depend on the standalone exe,
; so it will be part of the cram target
(deps standalone.exe))
(cram
; Tells dune to depend on the standalone exe,
; so it will be part of the cram target
(deps standalone.exe))
./standalone.exe
is part of the cramLet's create a simple test file called first_step.t
and run it with dune runtest
to ensure it's working.
$ ls
first_step.t
standalone.exe
$ ls
first_step.t
standalone.exe
Usually I keep a Makefile with the following commands: test
, test-watch
and test-promote
to feel like I’m typing the minimum of the minimum to get the job done. As you can see in this Makefile.
Say we have a ppx that does the minimum possible transformation (a foobar-kind example), which is to change the extension [%yay]
into a string "Hello future compiler engineer!"
.
Here's what a cram test for it might look like:
Creates an file with some OCaml code using bash heredoc
https://linuxize.com/post/bash-heredoc
$ cat > input.ml <<EOF
> let () = print_endline [%yay]
> EOF
Run the executable from input.ml and print the output to stdout
$ ./standalone.exe --impl input.ml
let () = print_endline "Hello future compiler engineer!"
Creates an file with some OCaml code using bash heredoc
https://linuxize.com/post/bash-heredoc
$ cat > input.ml <<EOF
> let () = print_endline [%yay]
> EOF
Run the executable from input.ml and print the output to stdout
$ ./standalone.exe --impl input.ml
let () = print_endline "Hello future compiler engineer!"
ocamlc
A real-example of a ppx, is browser_ppx which handles browser-specific code by stripping it out when not targeting JavaScript. Very useful when sharing code between native and JavaScript. The documentation lives here and comes from server-reason-react.
Here's what a cram test for this might look like
$ cat > input.ml << EOF
> let%browser_only pstr_value_binding = Webapi.Dom.getElementById "foo"
> EOF
Output goes into output.ml
$ ./standalone.exe -impl input.ml -o output.ml
Format the output with ocamlformat
$ ocamlformat --enable-outside-detected-project --impl output.ml
let pstr_value_binding =
[%ocaml.error
"[browser_ppx] browser_only works on function definitions. For other \
cases, use switch%platform or feel free to open an issue in \
https://github.com/ml-in-barcelona/server-reason-react."]
let make () =
let pstr_value_binding_2 =
[%ocaml.error
"[browser_ppx] browser_only works on function definitions. For other \
cases, use switch%platform or feel free to open an issue in \
https://github.com/ml-in-barcelona/server-reason-react."]
in
()
Run the compiler
$ ocamlc -c output.ml
File "output.ml", line 2, characters 4-15:
2 | [%ocaml.error
^^^^^^^^^^^
Error: [browser_ppx] browser_only works on function definitions. For other
cases, use switch%platform or feel free to open an issue in
https://github.com/ml-in-barcelona/server-reason-react.
[2]
$ cat > input.ml << EOF
> let%browser_only pstr_value_binding = Webapi.Dom.getElementById "foo"
> EOF
Output goes into output.ml
$ ./standalone.exe -impl input.ml -o output.ml
Format the output with ocamlformat
$ ocamlformat --enable-outside-detected-project --impl output.ml
let pstr_value_binding =
[%ocaml.error
"[browser_ppx] browser_only works on function definitions. For other \
cases, use switch%platform or feel free to open an issue in \
https://github.com/ml-in-barcelona/server-reason-react."]
let make () =
let pstr_value_binding_2 =
[%ocaml.error
"[browser_ppx] browser_only works on function definitions. For other \
cases, use switch%platform or feel free to open an issue in \
https://github.com/ml-in-barcelona/server-reason-react."]
in
()
Run the compiler
$ ocamlc -c output.ml
File "output.ml", line 2, characters 4-15:
2 | [%ocaml.error
^^^^^^^^^^^
Error: [browser_ppx] browser_only works on function definitions. For other
cases, use switch%platform or feel free to open an issue in
https://github.com/ml-in-barcelona/server-reason-react.
[2]
The test verifies that the ppx transforms our browser-specific code into an [%ocaml.error ...]
and raises the error defined in the ppx, runs the compiler and checks the output. Note that the ocamlc
command returns the error code 2 defined in the last line of the output [2]
.
ocamlmerlin
In a real world scenario like reason-react-ppx, we want to ensure that the ppx transformations respect the AST location of the original code. We want the generated code to have the same location as the original code, so the editor can highlight the correct line, compiler errors report the correct line and column, etc.
For those tests we use ocamlmerlin
to inspect the AST and ensure the location is correct. Here's a piece of the test:
Test some locations in reason-react components
Create a dune project file, with "using melange"
$ cat > dune-project <<EOF
> (lang dune 3.8)
> (using melange 0.1)
> EOF
Create a dune file, with a melange.emit stanza
$ cat > dune <<EOF
> (melange.emit
> (alias foo)
> (target foo)
> (libraries reason-react)
> (preprocess
> (pps melange.ppx reason-react-ppx)))
> EOF
Run the build (this runs the reason-react-ppx)
$ dune build
Let's test hovering over parts of the component
key={author.Author.name}
_^
Use ocamlmerlin to query the type of the expression at the cursor
$ ocamlmerlin single type-enclosing -position 10:7 -verbosity 0 \
> -filename component.re < component.re | jq '.value[0]'
{
"start": {
"line": 10,
"col": 2
},
"end": {
"line": 10,
"col": 85
},
"type": "string",
"tail": "no"
}
Test some locations in reason-react components
Create a dune project file, with "using melange"
$ cat > dune-project <<EOF
> (lang dune 3.8)
> (using melange 0.1)
> EOF
Create a dune file, with a melange.emit stanza
$ cat > dune <<EOF
> (melange.emit
> (alias foo)
> (target foo)
> (libraries reason-react)
> (preprocess
> (pps melange.ppx reason-react-ppx)))
> EOF
Run the build (this runs the reason-react-ppx)
$ dune build
Let's test hovering over parts of the component
key={author.Author.name}
_^
Use ocamlmerlin to query the type of the expression at the cursor
$ ocamlmerlin single type-enclosing -position 10:7 -verbosity 0 \
> -filename component.re < component.re | jq '.value[0]'
{
"start": {
"line": 10,
"col": 2
},
"end": {
"line": 10,
"col": 85
},
"type": "string",
"tail": "no"
}
Here the key learning is to use all the tools you have at your disposal (such as ocamlmerlin
) to ensure your ppx works as expected. This is incredibly useful since many tools in OCaml are just CLIs, such as:
ocamlmerlin
a LSP library used by many editors to provide code analysisocamlc
the bytecode OCaml compilerocamlopt
the native OCaml compilerocamldep
the dependency resolverocamldoc
the OCaml documentation generatorocamlformat
formatter for OCaml codeMore tools are available, check the OCaml documentation for more.
If you're building a ppx, I highly recommend giving cram tests a try. They might just make your development process a bit more enjoyable, but not only that, it will make a easy way to add all cases that you want to keep supporting on the future, or ensure edge-cases of the language remain working.
Check the ppxlib-simple-example repository for a complete example.
Happy testing folks!
Thanks for reaching the end. Let me know if you have any feedback, corrections or questions. Always happy to chat about any topic mentioned in this post, feel free to reach out.