Unit test in OCaml with OUnit
It’s pretty easy to get overly confident about the correctness of your code when you program in OCaml. After all, the strict type checking eliminates whole classes of bugs at compile time; you never fight them because your code never builds with them. Even so, I still love running automated tests. The compiler can’t catch all boundary conditions and it certainly can’t verify any situation that is dynamically built at runtime.
At Skydeck, we use OUnit for our unit tests. It’s really straightforward to use because, not surprisingly, it follows the typical n-unit pattern: set-up and tear-down test cases, implement individual tests, and gather them all into suites for execution. Similar to other implementations — cppunit for example — you need to write your own test suites with OUnit. It’s repetitive work, ripe for automation and almost makes you miss reflection (but not quite). So we threw together a module to automate this and hooked it into ocamlbuild. Now adding a new test is simple: we just write new test functions and OCaml does the rest. I love being able to add tests quickly even more than I love that feeling when all the tests pass.
Below is the code that generates the test suite. If you’re using ocamlbuild and want to integrate with it, I’ve included the relevant sections from our myocamlbuild.ml too.
make_suite.ml:
(* This code is provided for the public domain without copyright. To build: ocamlfind ocamlc -g -package pcre -linkpkg -o make_suite \ make_suite.ml *) let output_tests ch tests = let os = output_string ch in os "(* This file is autogenerated. Do not modify *)\n"; os "open OUnit\n"; os "\n"; os ("let suite = \"Test Suite\" >::: [\n"); let print_testentry s = Printf.fprintf ch " \"%s\" >:: %s;\n" s s; in List.iter print_testentry tests; os "];;\n"; os "\n"; os "run_test_tt_main suite" let module_of_filename fn = let fn = Filename.chop_suffix fn ".ml" in let fn_parts = Pcre.split ~pat:"/" fn in let module_parts = List.map String.capitalize fn_parts in String.concat "." module_parts let with_file_in fn f = let ch = open_in fn in let r = f ch in close_in ch; r let find_tests filenames = let file tests fn = let mn = module_of_filename fn in let rec line tests ch = try let l = input_line ch in match Pcre.extract ~pat:"^let (test_[A-Za-z0-9-_]+)" l with [| _; tn |] -> let test = mn ^ "." ^ tn in line (tests @ [test]) ch _ -> line tests ch with Not_found -> line tests ch End_of_file -> close_in ch; tests in with_file_in fn (line tests) in List.fold_left file [] filenames let doit () = let output_file = ref None in let files = ref [] in Arg.parse [("-o", Arg.String (fun s -> output_file := Some s), "Name of output file (stdout if none given)")] (fun fn -> files := !files @ [fn]) "make_suite [-o outfile] infile [infile ...]"; let tests = find_tests !files in let ch = match !output_file with Some fn -> open_out fn None -> stdout in output_tests ch tests;; doit ()
myocamlbuild.ml:
open Ocamlbuild_plugin open Command module PN = Pathname let split path = let rec aux path = if path = Filename.current_dir_name then [] else (Filename.basename path) :: aux (Filename.dirname path) in List.rev (aux path) (* I tried to use Pcre here but I don't see how to specify libs for myocamlbuild.ml *) let ends_with ew s = let sl = String.length s in let ewl = String.length ew in sl >= ewl && String.sub s (sl - ewl) ewl = ew let starts_with sw s = let sl = String.length s in let swl = String.length sw in sl >= swl && String.sub s 0 swl = sw let rec find pred sofar fn = if starts_with "_build" fn || ends_with ".svn" fn then sofar else begin if PN.is_directory fn then let fns = PN.readdir fn in let fns = Array.map (PN.concat fn) fns in Array.fold_left (find pred) sofar fns else if pred fn then fn::sofar else sofar end (* relative to _build; maybe should install tools somewhere? *) let make_suite = A"../../tools/make_suite" let make_tests files = rule "make tests" ~prod:"tests.ml" ~deps:files begin fun _ _ -> Cmd (S ([make_suite; A"-o"; Px"tests.ml"] @ (List.map (fun s -> P s) files))); end dispatch begin function After_rules -> let is_test_fn fn = starts_with "test_" (Filename.basename fn) || List.mem "test" (split (Filename.dirname fn)) in let files = find is_test_fn [] "." in make_tests files; _ -> () end
This document was generated using caml2html
Can you give examples of input files to be used with your make_suite code?
Alfie Barr: the files in input of make_suite are regular ocaml source code, containing functions whose name begin with “test”, requiring no argument and containing any OUnit tests, like following example:
let test_my_condition () =
let s = “not empty string” in
OUnit.assert_string s
Thanks for this code, greatly appreciated!
I have been using some other plugin to build make_suite itself (to put make_suite in the source of my project, not as external tools).
But then, it was difficult to add rule in ocamlbuild to first build the tool, then use it to create the executable to run the suite.
Thus I merge both code (make_suite and your ocamlbuild plugin) to create run_test.ml … which does make_suite + actions of ocamlbuild plugin.
Not bad, but of course it doesn’t enable to share the make_suite tools between project … however we could share a library which ease to write the run_suite in each project.
Anyway, main point is: thanks for code and inspiration!
Ooops! my previous comment is not clear … my run_test do not run the test (no dynamic evaluation) but embed the code from myocamlbuild to search for files … that’s all!