Sealing OCaml Libraries and Sharing Test Code with Dune Without Bloating Your Binary
A practical guide to using Dune's wrapped libraries, .mli sealing, and inline tests to share code between executables and test suites while keeping production binaries lean.
18 Jul 2026, 13:46 UTC

The problem: one codebase, two consumers
When you split an OCaml project into a command-line tool and a test suite, the easy path is to duplicate helper functions or expose internal modules globally. Both choices hurt later: duplication drifts, and a flat module namespace makes it hard to tell which functions are public API and which are implementation details. Dune offers a middle path: wrap a library, seal it with an interface file, and share that single library between your executable and your tests. The trade-off is that inline tests require careful build configuration, and sealing changes how dependent code names your modules.
Sealing the API with wrapped libraries and .mli files
Dune's (library ...) stanza with (wrapped true) gives every library its own namespace. If your library is named parser, dependent modules access it as Parser.Module rather than a bare Module. This prevents collisions but does not by itself hide internals. To expose only a curated surface, add an .mli file to each module you want to seal. The compiler then enforces the contract: anything not declared in the .mli is invisible to dependents. This is a compile-time boundary, not a security boundary—determined callers can still use runtime reflection or external linking hacks—but it is strong enough to prevent accidental API drift.
Consider a project with a lib/ directory for shared logic and a bin/ directory for the executable. Start by pinning the language version in dune-project so a Dune upgrade does not silently change defaults:
(lang dune 3.0)
Then declare the wrapped library in lib/dune:
(library
(name parser)
(wrapped true))
If lib/lexer.ml contains internal helpers, add lib/lexer.mli exposing only the functions you intend callers to use:
val tokenize : string -> token list
The executable stanza in bin/dune lists the library:
(executable
(name main)
(libraries parser))
Run dune build from the project root. The compiler will reject any attempt to use unexported helpers from main.ml, confirming the seal works.
Inline tests and the preprocessing trap
OCaml's ppx_inline_test lets you write let%test blocks directly inside implementation files. Dune activates this with two settings in the library stanza:
(library
(name parser)
(inline_tests)
(preprocess (pps ppx_inline_test)))
The (preprocess ...) field is easy to forget. Without it, the compiler encounters let%test as a syntax error rather than a clear diagnostic about a missing preprocessor. After installing ppx_inline_test and adding both fields, run dune runtest from the project root to execute the inline tests.
Sharing one library between executable and tests
Rather than copying code into a test directory, declare the shared logic once as a library and list it in the (libraries ...) field of both the executable and the test stanza. Dune builds the library once and links it into each target. For inline tests, the test runner lives inside the library itself, so dune runtest exercises the same code path that the executable uses.
A minimal test stanza in test/dune looks like this:
(test
(name test_main)
(libraries parser))
If you need platform-specific tests, (enabled_if ...) inside the test stanza lets Dune skip them on incompatible systems without external scripting.
Trade-offs: binary size and module naming
Inline tests are compiled into the library by default. If your executable links against a library that contains let%test blocks, those blocks become part of the production binary unless the library is carefully separated or guarded by a separate test target. For shipped binaries, measure the impact before committing to inline tests everywhere.
Another limitation: wrapped libraries rename modules internally. A module Internal inside library parser becomes Parser__Internal in the build directory. External code that expects flat names will break when you switch from an unwrapped to a wrapped setup.
A practical checklist
- Pin
(lang dune ...)indune-project. - Create a wrapped library with
.mliseals for every public module. - Add
(preprocess (pps ppx_inline_test))and(inline_tests)only after the ppx is available. - Share the library across
(executable ...)and(test ...)stanzas instead of duplicating code. - Run
dune buildto verify the seal, thendune runtestto verify the runner. - Inspect
_build/defaultif module names surprise you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.