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.
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 reflet create () : 'a t = ref []let push x s = s := x :: !slet pop s =match !s with| [] -> None| x :: rest -> s := rest; Some xlet 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))
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 () inStack.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 () inAlcotest.(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 () inAlcotest.(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.
Failures, and the checkers
A deliberately broken case - forgetting Stack is LIFO.
let test_broken () =let s = Stack.create () inStack.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:23Full 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.stringAlcotest.float ?eps:... Alcotest.charAlcotest.unit Alcotest.list t Alcotest.array tAlcotest.option t Alcotest.result t1 t2Alcotest.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.equallet 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"
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
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 () infor _ = 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" inFun.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))
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 () inStack.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 {|21|}]
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 () inStack.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 {|21|}]
[%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"]
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 hellohello$ dune exec ../bin/main.exe -- --versionmyproj 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 1ok$ dune exec ../bin/main.exe -- push 2ok$ dune exec ../bin/main.exe -- pop2$ dune exec ../bin/main.exe -- pop1$ dune exec ../bin/main.exe -- poperror: stack is empty
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:1000QCheck.(list small_int)(fun xs ->let s = Stack.create () inList.iter (fun x -> Stack.push x s) xs;let popped = List.init (List.length xs) (fun _ -> Option.get (Stack.pop s)) inpopped = 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_intQCheck.float QCheck.string QCheck.boolQCheck.list g QCheck.array g QCheck.option gQCheck.pair g1 g2 QCheck.triple g1 g2 g3QCheck.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:100QCheck.(list small_int)(fun xs ->xs = [] ||(let s = Stack.create () inList.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
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 htmlwrites _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
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: teston: [push, pull_request]jobs:test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: ocaml/setup-ocaml@v3with: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@v3with: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@v4with:path: ~/.opamkey: 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.