Introduction to Alcotest

2026-06-14 · 12 min

Alcotest is the test runner most OCaml projects reach for first. This wires one up, builds a full suite against a small library, then covers expect tests and dune promote, cram tests, property-based testing with QCheck, coverage, and running the whole thing in CI.

§ 01

Install and wire it in

Install.

$ opam install alcotest

A project laid out with the library under test separate from its test executable - the layout every example below assumes.

myproj/
├── lib/
│ ├── dune
│ └── stack.ml
├── test/
│ ├── dune
│ └── test_stack.ml
└── dune-project

The library, under test.

(* lib/stack.ml *)
type 'a t = 'a list ref
let create () : 'a t = ref []
let push x s = s := x :: !s
let pop s =
match !s with
| [] -> None
| x :: rest -> s := rest; Some x
let is_empty s = !s = []
let size s = List.length !s

lib/dune - an ordinary library, nothing test-specific here.

(library
(name stack))

test/dune - the test executable depends on the library and on alcotest, and (test) runs it automatically under dune test/dune runtest.

(test
(name test_stack)
(libraries stack alcotest))
§ 02

A first suite

Alcotest.test_case takes a name, a speed (`Quick or `Slow - dune test always runs Quick, and Slow only with --slow), and the test function itself, which takes unit and is expected to raise on failure.

let test_push_pop () =
let s = Stack.create () in
Stack.push 1 s;
Stack.push 2 s;
Alcotest.(check int) "pop returns most recent" 2 (Option.get (Stack.pop s));
Alcotest.(check int) "pop returns first" 1 (Option.get (Stack.pop s))
let test_empty () =
let s = Stack.create () in
Alcotest.(check bool) "starts empty" true (Stack.is_empty s);
Stack.push 1 s;
Alcotest.(check bool) "not empty after push" false (Stack.is_empty s)
let test_pop_empty () =
let s = Stack.create () in
Alcotest.(check (option int)) "pop on empty is None" None (Stack.pop s)
() =
Alcotest.run "stack"
[
( "basics",
[
Alcotest.test_case "push and pop" `Quick test_push_pop;
Alcotest.test_case "is_empty" `Quick test_empty;
Alcotest.test_case "pop empty" `Quick test_pop_empty;
] );
]

That last let () binding is missing its let - the full file needs it at the top.

let () =
Alcotest.run "stack" [ (* ... *) ]

Run the suite.

$ dune test

What passing output looks like - Alcotest prints a line per case, grouped by the string you gave each group.

Testing stack.
This run has ID `XJ3K9F2A'.
[OK] basics 0 push and pop.
[OK] basics 1 is_empty.
[OK] basics 2 pop empty.
Test Successful in 0.002s. 3 tests run.
§ 03

Failures, and the checkers

A deliberately broken case - forgetting Stack is LIFO.

let test_broken () =
let s = Stack.create () in
Stack.push 1 s;
Stack.push 2 s;
Alcotest.(check int) "wrong order" 1 (Option.get (Stack.pop s))

Alcotest reports the exact expected/actual pair and the file:line of the check, not just which test failed.

[FAIL] basics 3 wrong order.
-- basics.wrong order [FAIL] --
Expected: `1'
Actual: `2'
test/test_stack.ml:23
Full test results in `_build/_tests/stack'.
Test Failure in 0.001s. 4 tests run.

The exit code is nonzero on failure, which is what CI actually keys off - the printed report is for a human.

$ dune test; echo "exit: $?"
exit: 1

check needs a Testable, alcotest\'s name for a type plus its equality and its pretty-printer bundled together. The common ones are built in.

Alcotest.int Alcotest.bool Alcotest.string
Alcotest.float ?eps:... Alcotest.char
Alcotest.unit Alcotest.list t Alcotest.array t
Alcotest.option t Alcotest.result t1 t2
Alcotest.pair t1 t2 Alcotest.of_pp pp

A Testable for your own type, built from the pieces above rather than by hand - pp is a Format printer, equal is the comparison.

let record : Config.t Alcotest.testable =
Alcotest.testable Config.pp Config.equal
let test_config () =
Alcotest.(check record) "same config"
{ Config.host = "x"; port = 1 }
(Config.parse "x:1")

Config.pp and Config.equal, both derivable rather than hand-written if you already reach for ppx_deriving.

type t = { host : string; port : int }
[@@deriving eq, show]

Testing that something raises, rather than checking a return value.

let test_raises () =
Alcotest.check_raises "division by zero" Division_by_zero
(fun () -> ignore (1 / 0))

A weaker assertion than check_raises, for when you only care that it failed, not with which exception.

let test_fails () =
match Stack.pop (Stack.create ()) with
| None -> ()
| Some _ -> Alcotest.fail "expected None on empty stack"
§ 04

Running a subset

One group only.

$ dune exec test/test_stack.exe -- test basics

One case within a group, by its index in the list.

$ dune exec test/test_stack.exe -- test basics 1

Include the Slow-speed cases too.

$ dune exec test/test_stack.exe -- test --slow

List every case without running any of them - useful for scripting against the suite.

$ dune exec test/test_stack.exe -- list

Stop at the first failure instead of running the whole suite.

$ dune exec test/test_stack.exe -- test -e
§ 05

Test-only helper modules

Generators for the type under test, kept in the test directory so the library itself stays free of test-only code.

(* test/gen.ml *)
let random_stack n =
let s = Stack.create () in
for _ = 1 to n do Stack.push (Random.int 1000) s done;
s

A fixture helper for setup/teardown that alcotest itself does not provide directly - wrap it around the test body.

let with_tmp_file f =
let path = Filename.temp_file "test" ".txt" in
Fun.protect ~finally:(fun () -> Sys.remove path) (fun () -> f path)
let test_file_roundtrip () =
with_tmp_file (fun path ->
Out_channel.with_open_text path (fun oc -> output_string oc "hello");
Alcotest.(check string) "roundtrip" "hello"
(In_channel.with_open_text path In_channel.input_all))
§ 06

Expect tests and dune promote

An expect test checks output against a block of text sitting right in the source file, rather than a value asserted in code. When the output is correct but the expected block is stale, you do not hand-edit it - you run the program, look at what it actually produced, and promote that as the new expectation.

ppx_expect wires this up. It needs its own preprocessing on the test executable.

(test
(name test_stack_expect)
(libraries stack)
(preprocess (pps ppx_expect)))

let%expect_test names the test; [%expect {| ... |}] is the block dune checks stdout against, byte for byte after whitespace normalization.

let%expect_test "pop order" =
let s = Stack.create () in
Stack.push 1 s;
Stack.push 2 s;
Printf.printf "%d\n" (Option.get (Stack.pop s));
Printf.printf "%d\n" (Option.get (Stack.pop s));
[%expect {|
2
1
|}]

Run it like any other test.

$ dune test

Write the test with an empty, or deliberately wrong, expect block first - this is the normal workflow, not a fallback.

let%expect_test "pop order" =
let s = Stack.create () in
Stack.push 1 s;
Stack.push 2 s;
Printf.printf "%d\n" (Option.get (Stack.pop s));
Printf.printf "%d\n" (Option.get (Stack.pop s));
[%expect {| |}]

Running it against the empty block fails, and dune records what the block should have been.

File "test/test_stack_expect.ml", line 7, characters 0-14:
error:
(* expect_test: This test expectation appears incorrect. Run `dune promote'... *)
--- expected
+++ actual
@@ -1 +1,3 @@
-
+2
+1

dune promote copies dune\'s corrected version over the source file - this is the whole workflow, not just for expect tests but for anything dune knows how to correct.

$ dune promote

Diff the file before committing, the same as any other generated change - promote writes to the working tree, it does not stage or commit anything.

$ git diff test/test_stack_expect.ml

A change to Stack.pop\'s ordering would now make this expect test fail loudly, which is exactly the point - it pins the behavior.

[%expect {|
2
1
|}]

[%expect_exact] instead of [%expect] when leading whitespace and blank lines inside the block genuinely matter - the default form normalizes indentation to keep the source file readable.

let%expect_test "exact spacing" =
print_string " leading spaces matter";
[%expect_exact " leading spaces matter"]
§ 07

Cram tests

A cram test checks the literal stdout of a shell command against an inline block, the same promote workflow as an expect test but for a CLI rather than a function call. It is the natural fit for testing an executable\'s output directly.

dune-project needs the cram extension enabled once, project-wide.

(cram enable)

A .t file. Lines starting with two spaces and a $ are commands; the lines under them, indented two spaces with no $, are the expected output.

$ echo hello
hello
$ dune exec ../bin/main.exe -- --version
myproj 1.0.0

dune runs every .t file it finds automatically - no separate registration needed, unlike alcotest\'s own test_case list.

$ dune test

A stale cram test, same failure shape as the expect test above - a diff between what the block says and what the command actually printed.

File "test/cli.t", line 1, characters 0-0:
Error: Files _build/.sandbox/.../cli.t and
_build/.sandbox/.../cli.t.corrected differ.
$ dune exec ../bin/main.exe -- --version
- myproj 1.0.0
+ myproj 1.0.1

The same promote command fixes it, regardless of whether the source was a cram .t or an [%expect].

$ dune promote

A multi-command cram test walking through a small CLI session, which is where this format earns its keep over an alcotest case - it reads like a terminal transcript.

$ dune exec ../bin/main.exe -- push 1
ok
$ dune exec ../bin/main.exe -- push 2
ok
$ dune exec ../bin/main.exe -- pop
2
$ dune exec ../bin/main.exe -- pop
1
$ dune exec ../bin/main.exe -- pop
error: stack is empty
§ 08

Property-based testing with QCheck

Install, plus the alcotest bridge so QCheck properties run inside an ordinary alcotest suite.

$ opam install qcheck qcheck-alcotest

test/dune, extended.

(test
(name test_stack)
(libraries stack alcotest qcheck-alcotest))

A property: for any list of ints pushed in order, popping the same number of times returns them reversed. QCheck.make builds the test from a generator, a name, and the property function; QCheck_alcotest.to_alcotest converts it into an ordinary Alcotest.test_case.

let push_pop_reverses =
QCheck.Test.make ~name:"push then pop reverses" ~count:1000
QCheck.(list small_int)
(fun xs ->
let s = Stack.create () in
List.iter (fun x -> Stack.push x s) xs;
let popped = List.init (List.length xs) (fun _ -> Option.get (Stack.pop s)) in
popped = List.rev xs)
let () =
Alcotest.run "stack"
[
( "properties",
[ QCheck_alcotest.to_alcotest push_pop_reverses ] );
]

Run it exactly like any other alcotest case.

$ dune test

The generators worth knowing, composed the way you would compose parser combinators.

QCheck.int QCheck.small_int QCheck.pos_int
QCheck.float QCheck.string QCheck.bool
QCheck.list g QCheck.array g QCheck.option g
QCheck.pair g1 g2 QCheck.triple g1 g2 g3
QCheck.oneof [g1; g2] QCheck.map f g QCheck.(--) int range

A deliberately broken property - checking that pop always returns the most recently pushed element, but written to only look at the first pop, which fails to catch a reordering bug two pops deep. Included to show what a real failure looks like.

let broken =
QCheck.Test.make ~name:"first pop is last push" ~count:100
QCheck.(list small_int)
(fun xs ->
xs = [] ||
(let s = Stack.create () in
List.iter (fun x -> Stack.push x s) xs;
Stack.pop s = Some (List.hd (List.rev xs)) |> not))

QCheck reports the smallest failing input it could shrink to, not the first random one it happened to hit - this is the property that makes a failure actually readable.

test `first pop is last push' failed on ≥ 1 cases:
[0; 1]
(after 12 shrink steps)

Reproduce a specific failure deterministically by seed, rather than re-running the whole random suite and hoping it recurs.

$ dune exec test/test_stack.exe -- test properties -- --seed 284755652
§ 09

Coverage with bisect_ppx

Install.

$ opam install bisect_ppx

Instrument the library under test, not the test executable itself - only lib needs the ppx.

(library
(name stack)
(instrumentation (backend bisect_ppx)))

Run the suite with coverage enabled and clear out any stale .coverage files from a previous run first.

$ rm -f *.coverage
$ dune test --instrument-with bisect_ppx --force

Generate an HTML report from the .coverage files the run produced.

$ bisect-ppx-report html
writes _coverage/index.html

Or a one-line summary, for a CI log rather than a browser.

$ bisect-ppx-report summary

What the summary looks like - the line that catches a genuinely untested branch, like the empty-stack case in pop if the suite forgot to exercise it.

Coverage: 91.30% (21/23)

Fail CI outright below a threshold, rather than just reporting a number nobody reads.

$ bisect-ppx-report summary --expect 90

Clean up the raw coverage files once the report is generated - they are build artifacts, not something to commit.

$ rm -f *.coverage
§ 10

CI

A GitHub Actions job running the full suite plus coverage, using the ocaml/setup-ocaml action to get opam and a pinned compiler version.

name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: "5.2"
- run: opam install . --deps-only --with-test
- run: opam exec -- dune build
- run: opam exec -- dune test --instrument-with bisect_ppx --force
- run: opam exec -- bisect-ppx-report summary --expect 85

A matrix over compiler versions, when the library claims to support more than one - each cell runs the job above independently.

strategy:
matrix:
ocaml-compiler: ["4.14", "5.1", "5.2"]
steps:
- uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: ${{ matrix.ocaml-compiler }}

Caching the opam switch is the single biggest speedup available - without it, every run reinstalls the compiler from source.

- uses: actions/cache@v4
with:
path: ~/.opam
key: opam-${{ runner.os }}-${{ matrix.ocaml-compiler }}-${{ hashFiles('**/*.opam') }}

Locally, the equivalent single command that CI is really just wrapping.

$ dune build @runtest

The loop: Alcotest.check plus a testable for anything with equality and a printer; an empty [%expect] block or a fresh .t file followed by dune promote rather than hand-writing expected output; QCheck once a property should hold for every input, not just the ones you thought to write down; and bisect-ppx-report summary --expect in CI as the actual gate, not just a number in a log.