← Back to DevBytes

Testing Strategies for OCaml Applications

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:

The OCaml Testing Ecosystem

The OCaml ecosystem offers several complementary tools. You will typically combine more than one in a real project.

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles