Testo is a test framework for OCaml. Like with OUnit or Alcotest, the user writes a collection of tests. A test consists of a name and an OCaml test function to run, and supports many options. If the test function returns, the test is considered successful but if it raises an exception, it is considered failed.
The test suite is compiled into a test executable with a command-line interface provided by the Testo library. The test executable is called manually or by CI jobs to run tests and review the results.
unit -> unit.XFAIL outcomes and snapshot files are two features borrowed from Pytest that would have required massive changes in Alcotest and led to the creation of a new project.
Testo was designed to support older OCaml versions starting from 4.08 and to be maintained by the community of users. We’ve been using it to test Semgrep which has about 7000 OCaml tests, many of which were originally migrated from Alcotest. Check out the known missing features to see if anything critical to you is missing.
testo
libraryInstalling testo with Opam using
opam install testo.
At this stage, you need an OCaml project that uses Dune and Git. First, clone our template:
$ git clone https://github.com/semgrep/testo-template.git
$ cd testo-template
Then, check that it works:
$ dune build
$ ./test
This will run the test suite, showing output similar to this:
$ ./test
Legend:
• [PASS]: a successful test that was expected to succeed (good);
• [FAIL]: a failing test that was expected to succeed (needs fixing);
• [XFAIL]: a failing test that was expected to fail (tolerated failure);
• [XPASS]: a successful test that was expected to fail (progress?).
• [MISS]: a test that never ran;
• [SKIP]: a test that is always skipped but kept around for some reason;
• [xxxx*]: a new test for which there's no expected output yet.
In this case, you should review the test output and run the 'approve'
subcommand once you're satisfied with the output.
Try '--help' for options.
[RUN] 5d41402abc4b hello
[PASS] 5d41402abc4b hello
• Path to captured log: _build/testo/status/my_project/5d41402abc4b/log
1/1 selected test:
1 successful (1 pass, 0 xfail)
0 unsuccessful (0 fail, 0 xpass)
overall status: success
tests/test.ml is the entry point for the test suite.
Inspect it and you’ll see:
hello.Testo.interpret_argv. It is in charge
of interpreting the command line and performing the requested
actions.Copy the existing hello test and call it
"welcome message". Rebuild and rerun the test suite. You
should now see two successful tests.
Make the “welcome message” test fail. For example, you can use this code:
let test_welcome_message =
Testo.create "welcome message"
(fun () ->
let expected = "welcome" in
let result = "hello" in
Testo.(check string) expected result
)
Run the tests. You should now see a failing test:
...
┌──────────────────────────────────────────────────────────────────────────────┐
│ [FAIL] 504541c02761 welcome message │
└──────────────────────────────────────────────────────────────────────────────┘
• Path to captured log: _build/testo-template/status/my_project/504541c02761/log
• Log (stdout, stderr) is empty.
• Exception raised by the test:
Test failed: not equal:
expected (left): "welcome"
actual (right): "hello"
differences:
--- expected
+++ actual
@@ -1,1 +1,1 @@
-"welcome"
+"hello"
────────────────────────────────────────────────────────────────────────────────
[FAIL] 504541c02761 welcome message
2/2 selected tests:
1 successful (1 pass, 0 xfail)
1 unsuccessful (1 fail, 0 xpass)
overall status: failure
Now instead of comparing strings, let’s compare the output of some code that writes to stdout.
Modify the “welcome message” test to print welcome on
stdout:
let test_welcome_message =
Testo.create "welcome message"
(fun () ->
print_endline "welcome"
)
To check what’s printed on stdout, use
Testo.with_capture:
let test_hello =
Testo.create "hello"
(fun () ->
let (), out =
Testo.with_capture stdout
(fun () -> print_endline "welcome")
in
Testo.(check string) "welcome" out
)
Try it.
Assume you want to check the help page printed by a program. As an
exercise, use dune --help. The output takes multiple
screens and is cumbersome to copy-paste and escape correctly due to the
presence of special characters:
$ dune --help
DUNE(1) Dune Manual DUNE(1)
NAME
dune - composable build system for OCaml
SYNOPSIS
...
It wouldn’t be convenient to store this as a double-quoted string in
our OCaml file test.ml. Testo allows capturing stdout or
stderr as a file or “snapshot” that will serve as a reference for future
runs. Do this with the ~checked_output option as
follows:
let test_dune_help =
Testo.create "dune help"
~checked_output:(Testo.stdout ())
(fun () -> Sys.command "dune --help" |> ignore)
Add it to your test suite.
Run ./test with the updated code. It almost works but
reports a failure and tells you that something’s missing:
...
┌──────────────────────────────────────────────────────────────────────────────┐
│ [PASS*] 06e03989d7ca dune help │
└──────────────────────────────────────────────────────────────────────────────┘
• Checked output: stdout
• Missing file containing the expected output: tests/snapshots/my_project/06e03989d7ca/stdout
• Path to captured stdout: _build/testo-template/status/my_project/06e03989d7ca/stdout
• Path to captured log: _build/testo-template/status/my_project/06e03989d7ca/log
• Log (stderr) is empty.
...
1 test whose output needs first-time approval
...
The status PASS* indicates that the test passed but some
user action is needed. This was expected since we don’t have a reference
output for our test. First, we’re going to check that the captured
output is what we were expecting:
$ less _build/testo-template/status/my_project/06e03989d7ca/stdout
DUNE(1) Dune Manual DUNE(1)
NAME
dune - composable build system for OCaml
...
To troubleshoot just one test, select it with the -s
(“select”) filter. This will hide the status of any other failing test
while you work on this one:
$ ./test status -s dune
[PASS*] 06e03989d7ca dune help
The test ID can also be used to select a test. Use -a
(“all”) to list the successful tests. Check that the following command
lists the desired test:
$ ./test status -a -s 5d41402abc4b
[PASS] 5d41402abc4b hello
Check out details with the -l (“long output”)
option:
$ ./test status -l
┌──────────────────────────────────────────────────────────────────────────────┐
│ [PASS*] 06e03989d7ca dune help │
└──────────────────────────────────────────────────────────────────────────────┘
• Checked output: stdout
• Missing file containing the expected output: tests/snapshots/my_project/06e03989d7ca/stdout
• Path to captured stdout: _build/testo-template/status/my_project/06e03989d7ca/stdout
• Path to captured log: _build/testo-template/status/my_project/06e03989d7ca/log
• Log (stderr) is empty.
────────────────────────────────────────────────────────────────────────────────
2/2 selected tests:
2 successful (2 pass, 0 xfail)
0 unsuccessful (0 fail, 0 xpass)
1 test whose output needs first-time approval
overall status: failure
Approve the output of “dune help” and make it the reference snapshot
with ./test approve:
$ ./test approve
Expected output changed for 1 test.
In practice, you might have several tests requiring approval. To
approve a specific test rather than all of them, use
-s:
$ ./test approve -s 06e03989d7ca
Expected output changed for 1 test.
Check the new status:
$ ./test status
$ echo $? # check the process exit status
0
An exit status of 0 indicates a full success. This is confirmed by listing all the tests:
$ ./test status -a
[PASS] 5d41402abc4b hello
[PASS] 504541c02761 welcome message
[PASS] 06e03989d7ca dune help
Now, there should be a snapshot file somewhere in our file system.
Git shows you that tests/snapshots was created:
$ git status
...
Untracked files:
(use "git add <file>..." to include in what will be committed)
tests/snapshots/
The test files are organized as follows:
$ tree tests/
tests/
├── dune
├── snapshots
│ └── my_project
│ └── 06e03989d7ca
│ ├── name
│ └── stdout
└── test.ml
3 directories, 4 files
The path to the captured output for our test is
tests/snapshots/my_project/06e03989d7ca/stdout, as shown in
the original test output.
It would be nicer to have the snapshot file with a good name, say
dune-help.txt next to the test code. Do it by passing the
relevant option to Testo.stdout:
let test_dune_help =
Testo.create "dune help"
~checked_output:
(Testo.stdout
~expected_stdout_path:(Fpath.v "tests/dune-help.txt") ())
(fun () -> Sys.command "dune --help" |> ignore)
Re-run everything to get the following file tree:
tests/
├── dune
├── dune-help.txt
├── snapshots
│ └── my_project
└── test.ml
Add all these files including the snapshots to your Git repo:
$ git add tests/
$ git commit -m 'Add tests'
Check what happens if you replace the command
dune --help with dune build --help in
test.ml. The “dune help” test should fail and you should
see a diff against the expected output.
You’re now ready to use Testo. To discover more functionality, explore our how-tos and consult the reference API for all technical details.