Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

OCaml  Module 10 – dune, testing, PPX and packaging

Unit tests and expect tests

Catch a lost paisa with an Alcotest suite, record printed output as expect tests with ppx_expect and dune promote, and test a command line with cram.

  • Intermediate
  • 15 minutes
  • Examples run with ocaml 5.5.1, opam 2.6.0, dune 3.24.2 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: An Alcotest unit test names the value it expects · Three kinds of test, and one command that runs them all · Practise: tests that catch a seeded bug. With a pass: 2 in-depth parts and 3 interview answers, about 10 minutes.

What you will learn

  • Write an Alcotest suite whose failures name the case, the expected value and the received one
  • Write ppx_expect tests and accept a diff with dune promote only after reading it
  • Test a command line, its output and its exit status with a dune cram test
  • Organise unit, expect and cram tests so that one dune test runs them all in CI

Before you start

What you’ll learn · Before you start

What you will learn

  • Write an Alcotest suite whose failures name the case, the expected value and the received one
  • Write ppx_expect tests and accept a diff with dune promote only after reading it
  • Test a command line, its output and its exit status with a dune cram test
  • Organise unit, expect and cram tests so that one dune test runs them all in CI

Before you start

On this page

Split Rs 10 three ways with integer division and each share comes out as 333 paise: the shares add up to 999, one paisa has gone, and the program reports nothing. Unit tests and expect tests are two ways of making that missing paisa fail the build. A unit test: A test that calls one function or a small piece of code directly and checks its result against a value the test names, so a wrong result fails the run. calls a function and checks its result against a value you name, here 1,000 paise; an expect test: A test that runs code which prints and compares the printout with a copy kept in the test file; a different printout is shown as a difference and fails the run. prints what the code produces and keeps that printout, in the test file, as the output to expect next time.

Below, an Alcotest suite catches the missing paisa in a recorded run, and a table compares the three kinds of test used here. Two later parts go further: expect tests with ppx_expect, where a hasty dune promote freezes a bug, and a cram test of the ledger’s command line, run with every other test by one dune test.

An Alcotest unit test names the value it expects

The ledger library of your first dune project gains a split function. Amounts are whole paise, 100 to the rupee, and split amount n returns n shares computed with /, which drops any remainder:

lib/ledger.ml: a total, and a split with a bug ML · split/lib/ledger.ml
(* Amounts are whole paise: 100 paise make one rupee. *)

(* The sum of a list of amounts. *)
let total entries = List.fold_left ( + ) 0 entries

(* Splits a bill into n equal shares. *)
let split amount n = List.init n (fun _ -> amount / n)

The test file holds three cases in two groups; each calls the library and hands the result to a check. Alcotest.(check int) "Rs 10 three ways" 1000 actual takes four things in this order: a testable, here int, that says how to compare and print two values; a message for the report; the expected value; and the value the code computed. Alcotest.test_case gives each case a name and a speed, `Quick for one that runs every time, and Alcotest.run "ledger" runs every group:

test/test_ledger.ml: three Alcotest cases ML · split/test/test_ledger.ml
(* Unit tests of the ledger library: each test case checks one result. *)

let test_total () =
  Alcotest.(check int) "Rs 16.25 in all" 1625 (Ledger.total [ 1200; 350; 75 ])

let test_split_even () =
  Alcotest.(check (list int)) "Rs 9 three ways" [ 300; 300; 300 ] (Ledger.split 900 3)

let test_split_keeps_every_paisa () =
  Alcotest.(check int) "Rs 10 three ways" 1000 (Ledger.total (Ledger.split 1000 3))

let () =
  Alcotest.run "ledger"
    [
      ("total", [ Alcotest.test_case "three entries" `Quick test_total ]);
      ( "split",
        [
          Alcotest.test_case "an even split" `Quick test_split_even;
          Alcotest.test_case "no paisa lost" `Quick test_split_keeps_every_paisa;
        ] );
    ]

Step through the run case by case, then read it as it was recorded:

Interactive · message by message One dune test, three test cases, one paisa missing Step through the recorded run case by case, and watch where the missing paisa turns into a failure.
One dune test, three test cases, one paisa missing, step 6 of 6. Messages so far: dune test to test_ledger.exe: run test_ledger.exe; test_ledger.exe to Ledger: total [1200; 350; 75]; test_ledger.exe to Ledger: split 900 3; Ledger to test_ledger.exe: 333 + 333 + 333 = 999; test_ledger.exe to dune test: exit status 1.dune teststatus 1test_ledger.exesplit 1 [FAIL]Ledgerone paisa lostrun test_ledger.exetotal [1200; 350; 75]split 900 3333 + 333 + 333 = 999exit status 1

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. dune starts the suite. dune test builds test_ledger.exe from the (test) stanza in test/dune, linked with the ledger and alcotest libraries, and runs it. The program is the suite: two groups, total and split.
  2. total, case 0. The case three entries calls Ledger.total [1200; 350; 75] and gives the result to Alcotest.(check int) with 1625 as the expected value. They are equal, so its line reads [OK].
  3. split, case 0. An even split compares [300; 300; 300] with Ledger.split 900 3 through check (list int). 900 divides by 3, so every share is 300 and the case passes.
  4. split, case 1. No paisa lost splits 1000 paise three ways. Integer division gives three shares of 333, so their total is 999, and check int was told to expect 1000. The check raises, and the case is marked [FAIL].
  5. The report. Alcotest prints the failed case in a box with its message, Rs 10 three ways, then Expected 1000 and Received 999, keeps the case output in a log file and ends with 1 failure and 3 tests run.
  6. dune test fails too. dune points at the stanza that declared the test, line 2 of test/dune, and dune test itself ends with status 1. A script or a CI job needs nothing more than that status to stop.

dune test with the split that drops a paisa Python · split_fail_session.py
from session import files, run

# The ledger project with a split that drops the paise left over, and three Alcotest cases.
files(
    "split/dune-project=split/dune-project",
    "split/lib/dune=split/lib/dune",
    "split/lib/ledger.ml=split/lib/ledger.ml",
    "split/test/dune=split/test/dune",
    "split/test/test_ledger.ml=split/test/test_ledger.ml",
)
run(
    """
cd split
!dune test
""",
    mask=[
        (r"This run has ID `[A-Z0-9]+'", "This run has ID `(new on every run)'"),
        (r"Logs saved to `[^']*'", "Logs saved to `(a file under _build)'"),
        (r"Full test results in `[^']*'", "Full test results in `(a folder under _build)'"),
        (r"in \d+\.\d+s", "in (time)s"),
    ],
)

Output

$ cd split
$ dune test
File "test/dune", line 2, characters 7-18:
2 |  (name test_ledger)
           ^^^^^^^^^^^
Testing `ledger'.
This run has ID `(new on every run)'.

  [OK]          total          0   three entries.
  [OK]          split          0   an even split.
> [FAIL]        split          1   no paisa lost.

┌──────────────────────────────────────────────────────────────────────────────┐
│ [FAIL]        split          1   no paisa lost.                              │
└──────────────────────────────────────────────────────────────────────────────┘
ASSERT Rs 10 three ways
FAIL Rs 10 three ways

   Expected: `1000'
   Received: `999'

Raised at Alcotest_engine__Test.check in file "src/alcotest-engine/test.ml", lines 216-226, characters 4-19
Called from Alcotest_engine__Core.Make.protect_test.(fun) in file "src/alcotest-engine/core.ml", line 186, characters 17-23
Called from Alcotest_engine__Monad.Identity.catch in file "src/alcotest-engine/monad.ml", line 24, characters 31-35

Logs saved to `(a file under _build)'.
 ──────────────────────────────────────────────────────────────────────────────

Full test results in `(a folder under _build)'.
1 failure! in (time)s. 3 tests run.
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_fail_session.py

Two cases passed and one failed. The box names the failed case by group, number and name, split 1 no paisa lost, then its message and both values: expected 1000, received 999. The Raised at lines are Alcotest’s own. dune’s first lines point at the stanza that declared the test, and dune test ended with status 1. The fix gives the remainder, 1000 mod 3, to the first shares, one paisa each, and the same suite passes:

lib/ledger.ml, fixed: the first shares take the remainder ML · split/fixed/ledger.ml
(* Amounts are whole paise: 100 paise make one rupee. *)

(* The sum of a list of amounts. *)
let total entries = List.fold_left ( + ) 0 entries

(* Splits a bill into n shares that differ by at most one paisa: the first
   [amount mod n] shares take one paisa more, so the shares add up to [amount]. *)
let split amount n =
  let share = amount / n and extra = amount mod n in
  List.init n (fun i -> if i < extra then share + 1 else share)
The same three cases after the fix Python · split_fixed_session.py
from session import files, run

# The same project and tests, with the fixed split: the first (amount mod n) shares take one paisa more.
files(
    "split/dune-project=split/dune-project",
    "split/lib/dune=split/lib/dune",
    "split/fixed/ledger.ml=split/lib/ledger.ml",
    "split/test/dune=split/test/dune",
    "split/test/test_ledger.ml=split/test/test_ledger.ml",
)
run(
    """
cd split
dune test
""",
    mask=[
        (r"This run has ID `[A-Z0-9]+'", "This run has ID `(new on every run)'"),
        (r"Full test results in `[^']*'", "Full test results in `(a folder under _build)'"),
        (r"in \d+\.\d+s", "in (time)s"),
    ],
)

Output

$ cd split
$ dune test
Testing `ledger'.
This run has ID `(new on every run)'.

  [OK]          total          0   three entries.
  [OK]          split          0   an even split.
  [OK]          split          1   no paisa lost.

Full test results in `(a folder under _build)'.
Test Successful in (time)s. 3 tests run.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_fixed_session.py

The expected value comes before the computed one

Wrong: Alcotest.(check int) "Rs 10 three ways" (Ledger.total shares) 1000, with the computed total first. The case still fails, but the report then says expected 999, received 1000, and sends the reader after the wrong number. Right: the value you name, then the value the code returned.

Checkpoint

Check: Reading an Alcotest check and its failure

  1. Question 1 of 2 In the recorded run with the split that drops a paisa, which line of the failure report gives the total that the code actually produced?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The Received line, which shows 999

    The box printed Expected 1000 and Received 999. Expected is what the test named and Received is what the code returned, here the 333 + 333 + 333 of the buggy split.

  2. Question 2 of 2 A teammate writes Alcotest.(check int) "Rs 10 three ways" (Ledger.total (Ledger.split 1000 3)) 1000 and runs it with the split that drops a paisa. What does the failure report say?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Expected 999, received 1000, which points the reader at the wrong value

    Alcotest.check takes a testable, a message, the expected value and the actual value. With the computed total in the third position, the report reads Expected 999 and Received 1000: the case still fails, but it names the wrong value as the goal.

Three kinds of test, and one command that runs them all

The suite above is an assertion test: you compute a value and state what it must equal, as most unit tests do. The three kinds of test in this lesson compare different things:

Kind You write It compares
Assertion test, with Alcotest a call and the value it must return one value with the value you named
Expect test, with ppx_expect code that prints, then [%expect] the printout with the one kept in the file
Cram test, a .t file shell commands, as a user types them each command’s output and exit status

Expect tests are written by running them: they start with an empty expectation, dune test shows the printout as a difference, and dune promote copies it into the test file, a step dune calls promotion: Copying the output a test run produced over the expected output kept in the source tree, the step dune performs with dune promote once you accept the difference it showed.. A cram test: A test that records a shell session, each command with the output and exit status it should produce, in a file whose name ends in .t; dune runs the commands and compares what they print with what the file holds. More in “Command-line tools with Arg and Cmdliner” runs the built program as a user does, so it sees what the other two cannot: the options, the messages and the exit status. The command-line tools lesson tests a word counter with one. All three kinds run with one command, dune test, which fails when any test of the project fails.

Checkpoint

Check: Three kinds of test and one dune test

  1. One question You want a test that runs ledger split 10 0 the way a user would, and fails if its refusal message or its exit status of 2 ever changes. Which kind of test from the table records both?

    Choose one answer.

    Show the answer: One question

    Answer: A cram test

    Only a cram test runs the program as a user does, so only it sees the options, the messages and the exit status. The other two call OCaml functions directly.

Practise: tests that catch a seeded bug

Turn the lesson round: take a date parser that is correct, copy it, seed one bug into the copy, and keep adding test cases until your suite fails on the copy and passes on the original. A suite that cannot tell the two apart is not testing the behaviour you care about.

The self-check below covers the kinds of test and how each fails; the weakest idea comes first.

Practice that adapts to you

Practise this lesson

7 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 7 In Alcotest.(check int) "Rs 16.25 in all" 1625 (Ledger.total [ 1200; 350; 75 ]), which argument is the expected value?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: 1625

    check takes a testable, a message, the expected value and the actual value, in that order, so 1625 is what the test expects and the call to Ledger.total is what it gets.

  2. Question 2 of 7 With which exit status did dune test end in the recorded run where one of the three Alcotest cases failed?

    Type a number.

    A hint

    Look at the last line of the recorded session.

    Show the answer: Question 2 of 7

    Answer: 1

    Alcotest ends the suite with status 1 when any case fails, and dune test then fails as well: the recorded session ends with [exit status 1]. A script or a CI job needs only that status to stop.

  3. Question 3 of 7 The buggy split is still in place. Which new Alcotest case would fail, and so catch the bug?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: Ledger.total (Ledger.split 700 3) checked against 700

    A split loses paise only when the amount does not divide by the number of shares. 700 divided by 3 gives 233 three times, 699 in all, so a total checked against 700 fails, while every case with an amount that divides evenly passes with the bug.

  4. Question 4 of 7 Which kind of test compares the output and the exit status of each shell command with what its file records?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: A cram test

    The table lists what each kind compares: a value for an assertion, a printout for an expect test, and the output and exit status of each command for a cram test.

  5. Question 5 of 7 You changed how amounts are printed, on purpose, and dune test now shows the new printout of an expect test as a difference. Which command copies the new printout into the test file?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: dune promote

    An expect test shows a changed printout as a difference, and dune promote accepts it by copying it into the test file, a step dune calls promotion.

  6. Question 6 of 7 A project has an Alcotest suite, expect tests and a cram test. Which single command runs all three?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: dune test

    All three kinds of test run with one dune test, which fails when any test of the project fails; that single status is what a CI job reads.

  7. Question 7 of 7 In a new project, no command of the program calls the split function yet. Which kinds of test from the table can check split already?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: Assertion tests and expect tests, because both call library code directly

    Assertion tests and expect tests call OCaml functions, so they can test a library on its own. A cram test runs commands, so it can only test what some command does with the library.

Expect tests and accepting a diff with dune promote Pro: opens with a Pro, Premium or Ultimate pass

An expect test keeps what the code printed inside the test file, and fails as soon as the printout changes. This part wires ppx_expect into a library, finds a rupee-formatting bug in the first diff, and shows what accepting that diff unread costs.

In this part:

  • Two fields in a dune file make a library run its expect tests
  • Reading the first diff before dune promote accepts it
  • Fixing the parser, then promoting the diff that proves the fix

1 diagram · 7 runnable examples (Code, ML, Python) · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Cram tests for the CLI, and one dune test in CI Pro: opens with a Pro, Premium or Ultimate pass

A cram test runs the built program the way a user does and records everything it prints, exit status included. This part adds one for the ledger command, watches a single dune test catch one regression twice, and sets the run up so that a CI job reports every failure in full.

In this part:

  • A cram test that pins the ledger command’s shares and its refusal
  • One regression, caught twice by one dune test
  • Settings that make a CI failure quick to read and hard to fake
  • What to keep from these two parts

3 runnable examples (ML, Python) · about 9 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

A warm-up for freshers and 2–5 years: what does Alcotest.check take, and what does a failed check print?

It takes four arguments, in this order: a testable such as int, string or list int, which says how to compare two values and how to print them; a message; the expected value; and the actual value the code computed. When the two differ, the check raises, Alcotest marks that case [FAIL] and goes on with the others, and at the end it prints the failed case by group, number and name, with the message and both values, expected and received. The suite then exits with status 1, which makes dune test fail.

Three harder questions follow, on a printer’s field-by-field checks, a teammate’s diff to promote, and a CI run to keep quick and stable. Pick your first move before opening each.

  1. Mid to senior

    A billing service's receipt printer is tested by twelve Alcotest string checks, one per printed field. Every format change breaks several of them, and the team now pastes the new output into the checks without reading it. Would you move these checks to expect tests? Say what you would keep as assertions, and why.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to senior

    A teammate's change makes an expect test fail, and the pull request says: just run dune promote, the diff is only formatting. What do you check before you agree, and what would make you refuse the promote?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A project's dune test takes 14 minutes in CI, and about one run in ten fails, then passes on a rerun with no code change. The team wants every commit checked. How do you make the run quick and stable while keeping its result trusted?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does Alcotest.(check int) take, in order?

    A testable such as int, which says how to compare and print values; a message; the expected value; and the value the code computed.

  2. What goes wrong when the computed value is passed before the expected one?

    The case still fails, but the report swaps expected and received, which sends the reader after the wrong number.

  3. How does Alcotest report a failed case, and what happens to dune test?

    It names the case by group, number and name, with the message and both values; the suite exits with status 1, so dune test fails too.

  4. What does splitting 1,000 three ways with integer division silently lose?

    One unit: each share is 333 and the shares add up to 999, and nothing is reported. A unit test that names the expected total makes it fail.

  5. Which of a project's tests does one dune test run?

    All of them, unit tests, expect tests and cram tests alike, and it fails when any test fails.

Key takeaways

  • A unit test calls a function and checks its result against a value you name, and Alcotest.(check int) takes a testable, a message, the expected value and the computed one, in that order.
  • A failed Alcotest case is reported by group, number and name with its expected and received values, and the suite exits with status 1, so dune test fails too.
  • An expect test keeps the printout as its expectation and dune promote accepts a changed one, while a cram test records shell commands with their output and exit status.
  • All three kinds of test run with one dune test, which fails when any test of the project fails.

Terms in this lesson

expect test
A test that runs code which prints and compares the printout with a copy kept in the test file; a different printout is shown as a difference and fails the run.
inline test
A test written in an OCaml source file with a syntax extension such as let%test or let%expect_test, which dune gathers into a test runner and runs with the project's other tests.
promotion
Copying the output a test run produced over the expected output kept in the source tree, the step dune performs with dune promote once you accept the difference it showed.
unit test
A test that calls one function or a small piece of code directly and checks its result against a value the test names, so a wrong result fails the run.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.