Testing OCaml with Alcotest and QCheck

Testing OCaml with Alcotest and QCheck

OCaml has a strong type system that catches many bugs at compile time, but types can't verify that your sorting algorithm actually sorts or that your parser handles malformed input gracefully. For that you need tests. Alcotest is OCaml's most popular lightweight test framework — it's fast, colorful, and minimal. Paired with QCheck for property-based testing and Dune for builds, you get a modern, productive testing setup.

This guide covers Alcotest from installation through CI, including QCheck integration, async testing with Lwt, and a practical example with Result types.

Alcotest Overview

Alcotest is inspired by the simplicity of testing frameworks like Jest and pytest, adapted for OCaml's functional style. Key features:

  • Colorful terminal output with clear pass/fail reporting
  • Named test cases and suites
  • Built-in equality checking for common types, extensible for custom types
  • Works with Dune's test stanza out of the box
  • Integrates with QCheck for property-based testing
  • Async support via alcotest-lwt and alcotest-async

Installation via opam

opam install alcotest
opam install qcheck qcheck-alcotest  # for property testing
opam install alcotest-lwt            <span class="hljs-comment"># for async tests with Lwt

Or add to your opam file (managed project):

depends: [
  "alcotest" {with-test}
  "qcheck" {with-test}
  "qcheck-alcotest" {with-test}
]

Writing Your First Tests

Create a module to test and a corresponding test file:

(* lib/calculator.ml *)
let add a b = a + b
let subtract a b = a - b
let multiply a b = a * b
let divide a b =
  if b = 0 then Error "Division by zero"
  else Ok (a / b)
(* test/test_calculator.ml *)
let () =
  let open Alcotest in
  run "Calculator" [
    "add", [
      test_case "adds two positive numbers" `Quick (fun () ->
        check int "result" 4 (Calculator.add 2 2));
      test_case "adds negative numbers" `Quick (fun () ->
        check int "result" (-1) (Calculator.add 1 (-2)));
      test_case "adds zero" `Quick (fun () ->
        check int "result" 0 (Calculator.add 0 0));
    ];
    "subtract", [
      test_case "subtracts correctly" `Quick (fun () ->
        check int "result" 1 (Calculator.subtract 3 2));
    ];
    "divide", [
      test_case "divides evenly" `Quick (fun () ->
        check (result int string) "result"
          (Ok 5) (Calculator.divide 10 2));
      test_case "returns error on division by zero" `Quick (fun () ->
        check (result int string) "result"
          (Error "Division by zero") (Calculator.divide 10 0));
    ];
  ]

The structure is: run "Suite Name" [("group name", [test_case ...])]. The \Quicktag means the test runs in normal mode;`Slowskips it by default unless you pass--slow`.

Testable Types

Alcotest's check function takes a "testable" — a module that knows how to print and compare values. Built-in testables:

Alcotest.int           (* int *)
Alcotest.int32         (* int32 *)
Alcotest.int64         (* int64 *)
Alcotest.float 0.001   (* float with epsilon *)
Alcotest.string        (* string *)
Alcotest.bool          (* bool *)
Alcotest.char          (* char *)
Alcotest.bytes         (* bytes *)
Alcotest.unit          (* unit *)

(* Composed testables *)
Alcotest.(list int)          (* int list *)
Alcotest.(option string)     (* string option *)
Alcotest.(result int string) (* (int, string) result *)
Alcotest.(pair int string)   (* int * string *)
Alcotest.(array float)       (* float array *)

For custom types, define a testable using Alcotest.testable:

type user = {
  id: int;
  name: string;
  email: string;
}

let pp_user fmt u =
  Format.fprintf fmt "User{id=%d; name=%s; email=%s}" u.id u.name u.email

let equal_user a b =
  a.id = b.id && a.name = b.name && a.email = b.email

let user_testable = Alcotest.testable pp_user equal_user

Now use it in tests:

let test_create_user () =
  let expected = { id = 1; name = "Alice"; email = "alice@example.com" } in
  let actual = User.create 1 "Alice" "alice@example.com" in
  Alcotest.(check user_testable "created user" expected actual)

Grouping Tests into Suites

Large test files organize tests hierarchically:

(* test/test_user.ml *)

let test_create_valid () =
  let user = User.create 1 "Alice" "alice@example.com" in
  Alcotest.(check string "name" "Alice" user.name);
  Alcotest.(check string "email" "alice@example.com" user.email)

let test_validate_email () =
  Alcotest.(check bool "valid email" true
    (User.valid_email "user@example.com"));
  Alcotest.(check bool "invalid email" false
    (User.valid_email "not-an-email"))

let test_to_json () =
  let user = User.create 1 "Bob" "bob@example.com" in
  let json = User.to_json user in
  Alcotest.(check string "has name field"
    {|"name":"Bob"|} (extract_field json "name"))

let create_suite = [
  Alcotest.test_case "creates user with correct fields" `Quick test_create_valid;
]

let validate_suite = [
  Alcotest.test_case "validates email format" `Quick test_validate_email;
]

let serialization_suite = [
  Alcotest.test_case "serializes to JSON" `Quick test_to_json;
]

let () =
  Alcotest.run "User" [
    "create",      create_suite;
    "validate",    validate_suite;
    "serialize",   serialization_suite;
  ]

Dune Integration

Dune's test stanza connects Alcotest to the build system:

;; test/dune
(test
 (name test_calculator)
 (libraries calculator alcotest))

(test
 (name test_user)
 (libraries mylib alcotest alcotest-lwt))

For multiple test files, use tests:

(tests
 (names test_calculator test_user test_parser)
 (libraries mylib alcotest qcheck-alcotest))

Running tests:

dune runtest            # run all tests
dune <span class="hljs-built_in">test               <span class="hljs-comment"># alias for runtest
dune runtest <span class="hljs-built_in">test/      <span class="hljs-comment"># run tests in a specific directory
dune <span class="hljs-built_in">test --force       <span class="hljs-comment"># force re-run even if up to date

Watch mode for continuous testing:

dune build --watch &
dune runtest --watch

QCheck for Property Testing

QCheck provides property-based testing — generating hundreds of random inputs and checking invariants:

opam install qcheck qcheck-alcotest

Writing a QCheck property:

open QCheck

(* Test that sort is idempotent *)
let sort_idempotent =
  Test.make
    ~name:"sort is idempotent"
    ~count:200
    (list int)
    (fun xs ->
      let sorted = List.sort compare xs in
      List.sort compare sorted = sorted)

(* Test that sort preserves length *)
let sort_preserves_length =
  Test.make
    ~name:"sort preserves length"
    (list int)
    (fun xs ->
      List.length (List.sort compare xs) = List.length xs)

(* Test roundtrip property *)
let json_roundtrip =
  Test.make
    ~name:"JSON encode/decode roundtrip"
    ~count:100
    (pair string int)  (* generates (string, int) pairs *)
    (fun (k, v) ->
      let json = Json.encode [(k, v)] in
      match Json.decode json with
      | Ok decoded -> List.assoc k decoded = v
      | Error _ -> false)

Combining QCheck with Alcotest

QCheck_alcotest.to_alcotest converts QCheck tests to Alcotest test cases:

(* test/test_properties.ml *)
open QCheck

let sort_idempotent =
  Test.make
    ~name:"sort idempotent"
    ~count:200
    (list int)
    (fun xs ->
      let s = List.sort compare xs in
      List.sort compare s = s)

let sort_length =
  Test.make
    ~name:"sort preserves length"
    (list int)
    (fun xs ->
      List.length (List.sort compare xs) = List.length xs)

let sort_contains_all =
  Test.make
    ~name:"sort contains all original elements"
    (list int)
    (fun xs ->
      let sorted = List.sort compare xs in
      List.for_all (fun x -> List.mem x sorted) xs)

let () =
  let qcheck_tests =
    List.map QCheck_alcotest.to_alcotest
      [sort_idempotent; sort_length; sort_contains_all]
  in
  Alcotest.run "List properties" [
    "sort", qcheck_tests;
  ]

When a property fails, QCheck shrinks the input to the smallest failing case automatically.

Custom Generators

QCheck's Gen module builds generators for domain types:

open QCheck

(* Generator for valid usernames: 3-20 alphanumeric chars *)
let gen_username =
  Gen.(map
    (fun chars -> String.concat "" (List.map (String.make 1) chars))
    (list_size (int_range 3 20) (char_range 'a' 'z')))

(* Generator for email addresses *)
let gen_email =
  Gen.(map2
    (fun local domain -> local ^ "@" ^ domain ^ ".com")
    (string_size (int_range 3 10) ~gen:(char_range 'a' 'z'))
    (string_size (int_range 3 8) ~gen:(char_range 'a' 'z')))

(* Generator for a user record *)
type user_input = { username: string; email: string; age: int }

let gen_user_input =
  Gen.(map3
    (fun username email age -> { username; email; age })
    gen_username
    gen_email
    (int_range 0 120))

(* Use in a property *)
let user_validates =
  Test.make
    ~name:"valid user inputs always validate"
    (make gen_user_input)  (* wrap generator in Arbitrary *)
    (fun input ->
      match User.validate input.username input.email input.age with
      | Ok _ -> true
      | Error e ->
        QCheck.Test.fail_reportf "Unexpected validation error: %s" e)

Testing with Result Types

OCaml's Result type is ubiquitous. Alcotest handles it cleanly:

(* The module under test *)
module Parser = struct
  type parse_error = 
    | Empty
    | InvalidChar of char
    | TooLong

  let parse_name s =
    if String.length s = 0 then Error Empty
    else if String.length s > 50 then Error TooLong
    else
      match String.to_seq s |> Seq.find (fun c -> not (Char.code c |> fun n -> 
          (n >= 65 && n <= 90) || (n >= 97 && n <= 122) || c = ' ')) with
      | Some c -> Error (InvalidChar c)
      | None -> Ok s
end

(* Testable for parse_error *)
let pp_parse_error fmt = function
  | Parser.Empty -> Format.fprintf fmt "Empty"
  | Parser.InvalidChar c -> Format.fprintf fmt "InvalidChar(%c)" c
  | Parser.TooLong -> Format.fprintf fmt "TooLong"

let equal_parse_error a b = match a, b with
  | Parser.Empty, Parser.Empty -> true
  | Parser.InvalidChar a, Parser.InvalidChar b -> Char.equal a b
  | Parser.TooLong, Parser.TooLong -> true
  | _ -> false

let parse_error_t = Alcotest.testable pp_parse_error equal_parse_error
let result_t = Alcotest.(result string parse_error_t)

(* Tests *)
let test_valid_name () =
  Alcotest.(check result_t "valid name"
    (Ok "Alice") (Parser.parse_name "Alice"))

let test_empty_name () =
  Alcotest.(check result_t "empty name"
    (Error Parser.Empty) (Parser.parse_name ""))

let test_invalid_char () =
  Alcotest.(check result_t "name with digit"
    (Error (Parser.InvalidChar '1')) (Parser.parse_name "Alice1"))

let test_too_long () =
  let long_name = String.make 51 'a' in
  Alcotest.(check result_t "too long"
    (Error Parser.TooLong) (Parser.parse_name long_name))

let () =
  Alcotest.run "Parser" [
    "parse_name", [
      Alcotest.test_case "accepts valid names" `Quick test_valid_name;
      Alcotest.test_case "rejects empty string" `Quick test_empty_name;
      Alcotest.test_case "rejects invalid chars" `Quick test_invalid_char;
      Alcotest.test_case "rejects overlong names" `Quick test_too_long;
    ]
  ]

Async Testing with Lwt

alcotest-lwt provides Alcotest_lwt.test_case and Alcotest_lwt.run for Lwt-based tests:

opam install alcotest-lwt
(* test/test_http.ml *)
open Lwt.Syntax

let test_fetch_user _switch () =
  let* response = Http_client.get "http://localhost:8080/users/1" in
  let* body = Cohttp_lwt.Body.to_string response.body in
  let user = Json.parse body in
  Alcotest.(check string "user name" "Alice" (Json.get_string "name" user));
  Lwt.return_unit

let test_create_user _switch () =
  let* response = Http_client.post
    "http://localhost:8080/users"
    {|{"name": "Bob", "email": "bob@example.com"}|} in
  Alcotest.(check int "status code" 201 response.status);
  Lwt.return_unit

let () =
  Lwt_main.run (
    Alcotest_lwt.run "HTTP API" [
      "users", [
        Alcotest_lwt.test_case "fetch existing user" `Quick test_fetch_user;
        Alcotest_lwt.test_case "create new user" `Quick test_create_user;
      ]
    ]
  )

The _switch parameter is a Lwt switch for resource cleanup — useful for HTTP servers or connections that should be torn down after each test.

CI with GitHub Actions

# .github/workflows/test.yml
name: OCaml Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up OCaml
        uses: ocaml/setup-ocaml@v2
        with:
          ocaml-compiler: 5.1.x

      - name: Cache opam
        uses: actions/cache@v3
        with:
          path: ~/.opam
          key: ${{ runner.os }}-opam-${{ hashFiles('*.opam', '.opam') }}

      - name: Install dependencies
        run: opam install . --deps-only --with-test -y

      - name: Build
        run: opam exec -- dune build

      - name: Run tests
        run: opam exec -- dune runtest

      - name: Run property tests with more iterations
        run: opam exec -- dune exec test/test_properties.exe -- --count 500

Crowbar: Fuzzing for OCaml

For deeper fuzz testing beyond QCheck's generated inputs, Crowbar integrates with AFL (American Fuzzy Lop):

opam install crowbar
(* test/fuzz_parser.ml *)
let () =
  Crowbar.(add_test ~name:"parse never crashes" [bytes] (fun input ->
    match My_parser.parse input with
    | Ok _ | Error _ -> ()
    (* If parse raises an exception, Crowbar reports it *)
  ))

Run with AFL:

# Normal mode (quick check without AFL)
dune <span class="hljs-built_in">exec <span class="hljs-built_in">test/fuzz_parser.exe

<span class="hljs-comment"># With AFL fuzzing (finds more edge cases)
afl-fuzz -i <span class="hljs-built_in">test/corpus/ -o <span class="hljs-built_in">test/findings/ ./test/fuzz_parser.exe @@

Crowbar tests are particularly useful for parsers, decoders, and any code that processes external input.

Common Patterns

Grouping related tests with helpers:

let check_ok what expected actual =
  Alcotest.(check (result int string) what (Ok expected) actual)

let check_error what msg actual =
  Alcotest.(check (result int string) what (Error msg) actual)

let test_parse_int () =
  check_ok "parses 42" 42 (parse_int "42");
  check_ok "parses 0" 0 (parse_int "0");
  check_error "rejects letters" "not a number" (parse_int "abc");
  check_error "rejects empty" "empty input" (parse_int "")

Parametric tests:

let make_roundtrip_test (name, value) =
  Alcotest.test_case name `Quick (fun () ->
    let encoded = encode value in
    let decoded = decode encoded in
    Alcotest.(check (option int) "roundtrip" (Some value) decoded))

let roundtrip_cases = [
  ("encodes zero", 0);
  ("encodes positive", 42);
  ("encodes negative", -7);
  ("encodes max_int", max_int);
]

let () =
  Alcotest.run "Encoding" [
    "roundtrip", List.map make_roundtrip_test roundtrip_cases;
  ]

This table-driven style keeps test code DRY without sacrificing clarity.

HelpMeTest adds production monitoring and AI test generation for OCaml services — start free at helpmetest.com

Read more