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-lwtandalcotest-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 LwtOr 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_userNow 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 dateWatch mode for continuous testing:
dune build --watch &
dune runtest --watchQCheck for Property Testing
QCheck provides property-based testing — generating hundreds of random inputs and checking invariants:
opam install qcheck qcheck-alcotestWriting 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 500Crowbar: 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