Testing Strategies for OCaml Applications
OCaml's strong static type system eliminates entire categories of bugs at compile time, but it does not guarantee that your application behaves correctly at runtime. A well-typed function can still return the wrong value, a parser can still reject valid input, and an asynchronous workflow can still deadlock. A deliberate testing strategy is what separates a type-checked codebase from a reliable one. This tutorial walks through the testing landscape in OCaml, from unit tests with OUnit to property-based testing with QCheck, integration testing, and CI integration.
Why Testing Matters in OCaml
Many developers coming from dynamically typed languages assume that OCaml's type system makes testing optional. This is a dangerous misconception. The type system proves that your code is internally consistent — that you do not pass a string where an integer is expected — but it cannot prove that your code satisfies its specification. Consider a function that sorts a list. The type checker confirms it takes 'a list and returns 'a list, but only tests can confirm the output is actually sorted and contains the same elements as the input.
Testing in OCaml is valuable for several reasons:
- Specification validation: Tests document and verify intended behavior beyond what types express.
- Regression protection: As refactors happen, tests catch unintended behavioral changes.
- Design feedback: Writing tests first forces you to design modular, dependency-injected code.
- Confidence in edge cases: Property-based testing can explore input spaces no human would manually cover.
The OCaml Testing Ecosystem
The OCaml ecosystem offers several complementary tools. You will typically combine more than one in a real project.
- Alcotest / OUnit2: Unit testing frameworks with assertions and test runners.
- QCheck: Property-based testing, inspired by Haskell's QuickCheck.
- expect-test: Snapshot-style testing where expected output is auto-updated, popular in the Jane Street ecosystem.
- MDX: Runs OCaml code embedded in Markdown documentation, ensuring examples stay correct.
- Bisect_ppx: Code coverage instrumentation.
Setting Up a Testable Project with Dune
Dune is the de facto build system for OCaml and has first-class support for test executables. A typical project layout separates library code from tests so the library can be linked into both the production binary and the test binary.
my_app/
├── dune-project
├── lib/
│ ├── dune
│ └── my_app.ml
└── test/
├── dune
└── test_my_app.ml
The library dune file exposes the module publicly:
(library
(name my_app)
(public_name my_app))
The test dune file declares a test executable that depends on the library and a testing framework. Here we use Alcotest:
(test
(name test_my_app)
(libraries my_app alcotest))
Run the tests with:
dune runtest
Unit Testing with Alcotest
Alcotest is a lightweight, fast unit testing framework with a clean API and colorful output. Let's start with a small module under test and a corresponding test file.
Suppose lib/my_app.ml contains a few pure functions:
(* lib/my_app.ml *)
let rec sum lst =
match lst with
| [] -> 0
| x :: xs -> x + sum xs
let unique lst =
let seen = Hashtbl.create 16 in
List.filter (fun x ->
if Hashtbl.mem seen x then false
else (Hashtbl.add seen x (); true)
) lst
let capitalize_words sentence =
String.split_on_char ' ' sentence
|> List.map String.capitalize_ascii
|> String.concat " "
Now the test file test/test_my_app.ml:
open My_app
let sum_tests =
[ "empty list sums to zero", `Quick, (fun () ->
Alcotest.(check int) "empty" 0 (sum []));
"single element", `Quick, (fun () ->
Alcotest.(check int) "single" 42 (sum [42]));
"multiple elements", `Quick, (fun () ->
Alcotest.(check int) "multi" 15 (sum [1; 2; 3; 4; 5]));
]
let unique_tests =
[ "removes duplicates", `Quick, (fun () ->
Alcotest.(check (list int))
"dedup" [1; 2; 3] (unique [1; 2; 1; 3; 2; 3]));
"preserves first occurrence order", `Quick, (fun () ->
Alcotest.(check (list int))
"order" [3; 1; 2] (unique [3; 1; 3; 2; 1]));
]
let capitalize_tests =
[ "capitalizes each word", `Quick, (fun () ->
Alcotest.(check string)
"cap" "Hello World" (capitalize_words "hello world"));
]
let () =
Alcotest.run "my_app tests" [
"sum", sum_tests;
"unique", unique_tests;
"capitalize", capitalize_tests;
]
Each test is a triple of name, tag (usually `Quick), and a thunk. Alcotest provides composable type-aware checkers via Alcotest.(check ...). The first argument is a value printer and comparator bundled together (like int, string, list int), the second is a label, the third is the expected value, and the fourth is the actual value.
Testing Functions with Side Effects
Pure functions are easy to test, but real applications interact with files, networks, and databases. The key strategy is dependency injection: pass side-effectful operations as function arguments or module functors, then substitute fakes during tests.
For example, instead of reading directly from Stdlib, define an interface:
(* lib/my_app.mli *)
module type IO = sig
val read_file : string -> string
val write_file : string -> string -> unit
end
module Make (Io : IO) : sig
val process : string -> string -> unit
end
(* lib/my_app.ml *)
module type IO = sig
val read_file : string -> string
val write_file : string -> string -> unit
end
module Make (Io : IO) = struct
let process input_path output_path =
let content = Io.read_file input_path in
let transformed = String.uppercase_ascii content in
Io.write_file output_path transformed
end
module Real_io : IO = struct
let read_file = Stdlib.input_line (* simplified *)
let write_file path content =
let oc = open_out path in
output_string oc content;
close_out oc
end
In tests, supply a fake IO module that records calls in a mutable reference:
open My_app
let () =
let written = ref [] in
let fake_io : My_app.IO = struct
let read_file _ = "hello world"
let write_file path content =
written := (path, content) :: !written
end
in
let module T = My_app.Make (struct
include fake_io
end) in
T.process "in.txt" "out.txt";
Alcotest.(check (pair string string))
"wrote uppercased content"
("out.txt", "HELLO WORLD")
(List.hd !written)
This pattern keeps business logic pure and testable while isolating impure interactions at the edges of your application.
Property-Based Testing with QCheck
Property-based testing shifts the focus from individual examples to universal properties. Instead of asserting sum [1;2;3] = 6, you assert that for any list l, sum l = sum (List.rev l). QCheck generates hundreds of random inputs to falsify your property.
Add QCheck to your test dependencies:
(test
(name test_my_app)
(libraries my_app alcotest qcheck qcheck-alcotest))
Now write properties:
open My_app
open QCheck
let prop_sum_commutative =
Test.make ~name:"sum is order-independent"
(list small_int)
(fun l -> sum l = sum (List.rev l))
let prop_sum_non_negative =
Test.make ~name:"sum of non-negative list is non-negative"
(list (fun _ -> Gen.int_bound 1000))
(fun l -> sum l >= 0 || List.exists (fun x -> x < 0) l)
let prop_unique_idempotent =
Test.make ~name:"unique applied twice equals once"
(list small_int)
(fun l -> unique (unique l) = unique l)
let prop_unique_preserves_membership =
Test.make ~name:"unique preserves set membership"
(list small_int)
(fun l ->
let u = unique l in
List.for_all (fun x -> List.mem x u) l
&& List.for_all (fun x -> List.mem x l) u)
let () =
QCheck_alcotest.run ~verbose:true [
prop_sum_commutative;
prop_sum_non_negative;
prop_unique_idempotent;
prop_unique_preserves_membership;
]
When a property fails, QCheck shrinks the counterexample to a minimal failing case. For instance, if unique had a bug, QCheck might report that [0; 0] fails rather than some enormous random list, making debugging far easier.
Writing Custom Generators
Built-in generators like list small_int cover many cases, but real domains often need structured data. QCheck lets you build custom generators with combinators.
open QCheck
open QCheck.Gen
type user = {
id : int;
name : string;
email : string;
}
let user_gen =
let+ id = int_bound 1_000_000
and+ name = small_string
and+ domain = oneofl ["example.com"; "test.org"; "dev.io"]
in
{ id; name; email = name ^ "@" ^ domain }
let prop_user_id_positive =
Test.make ~name:"generated user id is non-negative"
user_gen
(fun u -> u.id >= 0)
The let+ ... and+ ... applicative syntax composes generators cleanly. For more complex constraints, use Gen.filter or Gen.flat_map.
Expect Tests for Snapshot Testing
Expect tests, popularized by Jane Street's expect-test library, take a different approach. You write the expected output inline, and when behavior changes you review the diff and accept the new output with a single command. This is excellent for testing pretty-printers, parsers, and error messages.
(test
(name test_printer)
(libraries my_app expect-test-collector))
open Expect_test_collector
let () =
let module M = Expect_test_collector.Make (struct
let mutable_string = ref ""
end) in
M.print_string (My_app.capitalize_words "hello world");
M.print_newline ();
[%expect {|
HELLO World
|}]
If the output changes, running the test produces a diff. You then run dune runtest --auto-promote to update the expected blocks. This workflow keeps tests in sync with code without manual string maintenance.
Testing the Build with MDX
Documentation examples rot quickly. MDX executes OCaml snippets inside Markdown files and verifies they produce the documented output. Add it as a test stanza:
(mdx
(package my_app)
(files README.md))
Inside README.md:
ocaml
# #require "my_app";;
# My_app.sum [1; 2; 3];;
- : int = 6
Run dune runtest and MDX will execute the snippet, comparing actual output to the documented output. Promote with --auto-promote when intentional changes occur.
Measuring Coverage with Bisect_ppx
Knowing which lines your tests exercise helps prioritize effort. Bisect_ppx instruments your code at compile time and produces coverage reports.
(library
(name my_app)
(public_name my_app)
(instrumentation (backend bisect_ppx)))
(test
(name test_my_app)
(libraries my_app alcotest)
(instrumentation (backend bisect_ppx)))
After running tests, generate a report:
dune runtest --force
bisect-ppx-report html
Open _coverage/index.html to browse per-file coverage. Aim for high coverage on business logic, but do not chase 100% on trivial accessors or error branches that are hard to trigger.
Best Practices
- Test behavior, not implementation. Assert on outputs and observable effects, not on internal function call counts. This keeps tests resilient to refactoring.
- Keep tests fast. Unit tests should run in milliseconds. Move slow integration tests into a separate stanza gated behind an env variable or a separate
dune runtestalias. - Prefer property tests for pure logic. A handful of well-chosen properties often replace dozens of example-based tests and catch edge cases you would never think to write.
- Use example tests for regressions. When a bug report arrives, write a failing example test first, then fix the code. The test prevents recurrence.
- Inject dependencies. Functors and higher-order functions make side-effectful code testable. Resist the temptation to call
Stdlibdirectly inside business logic. - Keep test files close to source. Co-locating tests with the modules they cover improves discoverability. Dune's
(test ...)stanza makes this trivial. - Run tests in CI on every push. Use a matrix of OCaml versions to catch compiler-version-specific issues early.
- Do not test the type system. If a function's correctness is fully guaranteed by its type, a runtime test adds little value. Focus effort where types cannot help.
A CI Workflow Example
Here is a minimal GitHub Actions workflow that builds, tests, and reports coverage for an OCaml project:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ocaml/setup-ocaml@v2
with:
ocaml-compiler: 5.1.0
- run: opam install . --deps-only --with-test
- run: opam exec -- dune build
- run: opam exec -- dune runtest --force
- run: opam exec -- bisect-ppx-report html
- uses: actions/upload-artifact@v4
with:
name: coverage
path: _coverage/
Conclusion
Testing OCaml applications is not about compensating for a weak type system; it is about complementing a strong one. The type system gives you a floor of correctness, and a layered testing strategy raises that floor toward confidence. Unit tests with Alcotest verify specific behaviors, property tests with QCheck explore vast input spaces automatically, expect tests keep documentation and pretty-printers honest, MDX ensures README examples compile, and Bisect_ppx tells you where your safety net has holes. By injecting dependencies at the boundaries of your application, you keep the core logic pure and trivially testable. Adopt these tools incrementally — start with unit tests for the riskiest modules, add property tests where invariants matter most, and wire everything into CI so that testing becomes a continuous, automatic guarantee rather than an afterthought.