\( \newcommand\D{\mathrm{d}} \newcommand\E{\mathrm{e}} \newcommand\I{\mathrm{i}} \newcommand\bigOh{\mathcal{O}} \newcommand{\cat}[1]{\mathbf{#1}} \newcommand\curl{\vec{\nabla}\times} \newcommand{\CC}{\mathbb{C}} \newcommand{\NN}{\mathbb{N}} \newcommand{\QQ}{\mathbb{Q}} \newcommand{\RR}{\mathbb{R}} \newcommand{\ZZ}{\mathbb{Z}} \)
UP | HOME

XUnit Framework - SML

Table of Contents

1. Overview

I'm going to try to iteratively construct an xUnit testing framework in Standard ML, just to get acquainted with programming in Standard ML.

We will have one test suite per file, which consists of many test cases (or just "tests"). A test case consists of one or more assertions.

2. First Draft

We will just have an assert function which reports failure by means of raising an AssertionFailure exception. Since Standard ML is not lazy, we need to make an assert function checks if a given condition has been satisfied and, if not, raises an AssertionFailure exception with a user-given message for details.

exception AssertionFailure of string;

fun assert (msg : string) (is_success : bool) : unit =
    if is_success then () else raise AssertionFailure msg;

Now for Test, which is a composite pattern of test cases. A test case has a name encoded as a string, and a function which encodes the various assertions.

A TestSuite is just a list of Test instances, with some name (also encoded as a string).

datatype Test = TestCase of string*(unit -> unit)
              | TestSuite of string*(Test list);

Great, now we need to run through the tests.

For test cases, TestCase (name, assertion), we call assertion() then handle any exceptions raised. There are two types of exceptions we expect: first, AssertionFailure exceptions reflect an assert failed; second, any other exceptions that may occur which the user did not adequately handle.

For TestSuite instances, we simply call run recursively on its tests.

fun run (TestCase (name, assertion))
    = (assertion()
       handle AssertionFailure msg => print (concat ["Test ",
                                                     name,
                                                     " failed: ",
                                                     msg])
            | e => print (concat ["Unhandled exception in test",
                                  name,
                                  ": ",
                                  exnMessage e]))
  | run (TestSuite (name, tests)) = app run tests;

Now we could leave it here, and we have a nifty little testing framework. But I'd like to add two convenience functions to make writing test cases (and test suites) a little easier.

fun test (name:string) (assertion:unit -> unit) : Test =
    TestCase (name, assertion);

fun suite (name:string) (tests : Test list) : Test=
    TestSuite (name, tests);

Now we could run this on some example tests:

val ex_tests = suite "Arithmetic Tests"
                     [test "Arithmetic test 1" (fn () => assert "1+1=2" (1+1=2)),
                      test "Arithmetic test 2" (fn () => assert "1+1=3" (1+1=3))];

run ex_tests;

3. Refactoring Test Results and Test Reporting

Now, xUnit testing wants to produce an "artifact" from running through the tests which reports the results. We did this by simply having run print whether the test succeeded, failed, or had an unexpected exception raised.

A better approach would be to create a TestResult datatype, have run : Test -> TestResult, and introduce a new function report : TestResult -> unit which produces some kind of artifact.

We have a TestResult keep track of the TestOutcome and some metadata (like the amount of time it took to execute the test). Like the Test datatype, the TestResult is a composite pattern (since we want to keep track of possibly nested test suites).

(* Extend the framework to handle printing test outcomes. *)
datatype TestOutcome = TestSuccess
                     | TestFail of string
                     | TestException of exn;

datatype TestResult = ResultCase of string*Time.time*TestOutcome
                    | ResultSuite of string*Time.time*(TestResult list);

We can now modify our run function to produce a TestResult instance.

fun run_test (test : Test) : TestResult =
let
  val start = Time.now()
in
  case test of
      (TestCase (case_name, assertion)) =>
      ((assertion();
        ResultCase (case_name, (Time.-)(Time.now(), start), TestSuccess))
       handle AssertionFailure msg => ResultCase (case_name,
                                                  (Time.-)(Time.now(), start),
                                                  TestFail msg)
            | e => ResultCase (case_name,
                               (Time.-)(Time.now(), start),
                               TestException e))
    | (TestSuite (suite_name, tests)) =>
      (let
          val results = map run_test tests
          val dt = (Time.-)(Time.now(), start)
      in
          ResultSuite (suite_name, dt, results)
      end)
end;

Now the first stab to report these results will be simply to print them to the screen. Superficially, this will appear no different than before. Later, we can abstract away reporting to a signature REPORTER, and have different formats and reports implemented accordingly.

We will modify our printed output a little to include the number of seconds it took to execute the test (and the suite). For our toy test suite, this will be so fast it will probably print "0.000" seconds. If we want to know exactly how long the test took, we could print the microseconds instead.

(* `Time.fmt n interval` will produce a string representation of the
number of seconds in the interval, to `n` digits after the decimal
point. *)
fun intervalToString (dt : Time.time) : string =
    Time.fmt 3 dt;
    (* (LargeInt.toString (Time.toMicroseconds dt))^"ms"; *)

(* Print the results to the terminal *)
fun report_results (ResultCase (name,dt,outcome)) =
    (case outcome of
         TestSuccess => print (concat ["Test ",
                                       name,
                                       " (",
                                       intervalToString dt,
                                       ")",
                                       ": SUCCESS\n"])
       | (TestFail msg) => print (concat ["Test ",
                                          name,
                                          " (",
                                          intervalToString dt,
                                          "): FAIL -- ",
                                          msg,
                                          "\n"])
       | (TestException e) => print (concat ["Test ",
                                             name,
                                             " (",
                                             intervalToString dt,
                                             "): UNHANDLED EXCEPTION ",
                                             exnMessage e,
                                             "\n"]))
  | report_results (ResultSuite (name,dt,outcomes)) =
    (print (concat ["Suite ", name, " (", intervalToString dt, ")\n"]);
     app report_results outcomes);

Now we could run this on the example tests by doing something like:

val ex_results = run_test ex_tests;

report_results ex_results;

(* Prints:

Suite Arithmetic Tests (0.000)
Test Arithmetic test 1 (0.000): SUCCESS
Test Arithmetic test 2 (0.000): FAIL -- 1+1=3
*)

So far, so good!

4. Summarize results

It's not terribly useful to print out every test case which succeeds, since that clutters up the screen. We will then only print out failures, and produce a one line summary of the number of tests run, the number of failures, and the number of unhandled errors. We will simply use a record type to track the number of successes, failures, and errors.

type ResultSummary = {success : int, fail : int, errors : int};

val no_results = {success=0, fail=0, errors=0};

Now, we could create a function which transforms a Test into a ResultSummary, but we need to collapse them into a single summary instance. Towards this end, we have a merge_summaries function which will be used to fold them all together.

fun merge_summaries (({success=s1, fail=f1, errors=e1},
                     {success=s2, fail=f2, errors=e2}) : ResultSummary*ResultSummary)
    : ResultSummary
    = {success = s1+s2,
       fail = f1 + f2,
       errors = e1 + e2};

Now we summarize each TestCase by its outcome, and TestSuite by summing over the summaries of its constituents.

(* summarize : TestRest -> ResultSummary *)
fun summarize (ResultCase (_, _, outcome)) =
    (case outcome of
         TestSuccess => {success=1, fail=0, errors=0}
       | (TestFail _) => {success=0, fail=1, errors=0}
       | (TestException _) => {success=0, fail=0, errors=1})
  | summarize (ResultSuite (_, _, outcomes)) =
    foldl merge_summaries no_results (map summarize outcomes);

Now we could check the summary of our toy test suite:

summarize ex_results;

(* SML/NJ produces:

val it = {errors=0,fail=1,success=1} : {errors:int, fail:int, success:int}
*)

5. Quality of life helper functions

Right now, it's rather tedious to write a test suite. The conventions I'm loosely following (xUnit, influenced by elements of JUnit) has each test suite be contained in its own file. So I would love to write something like:

suite "FooBarTests";

test "BaazTest1" fn () => (* ... *);

test "BaazTest2" fn () => (* ... *);

(* etc. *)

I'd like the library to simply accumulate the test cases as they are defined into the current test suite. This requires side effects, specifically uses ref instances.

val current_suite : (string*(Test list)) ref = ref ("", []);
val all_suites : (Test list) ref = ref [];

The test runner will simply iterate through all_suites to produce a corresponding list of test results, which will be iteratively processed by a reporter.

Now, we revisit our suite and test functions, to make them behave as we would like. Specifically, our suite function will append whatever the current_suite's contents are to all_suites (after storing them in a TestSuite instance). I'm not sure if I will need suite to do anything else, so I will place this procedure to append the current suite to the running list of all suites in a helper function:

fun append_suite (name : string) =
    let
        val (name, tests) = !current_suite
    in
        all_suites := (TestSuite (name, tests)) :: (!all_suites);
        current_suite := (name, [])
    end;

Now, we have our redefinition for suite:

fun suite (name : string) =
    append_suite(name);

The test will update current_suite to add a new TestCase to its list of tests.

fun test (name : string) (assertion : unit -> unit) : unit =
    let
        val (suite_name, tests) = !current_suite
    in
        current_suite := (suite_name, (TestCase (name, assertion))::tests)
    end;

6. Reporter Module

We can now refactor the reporter routines into their own modules. The first example of this will be a terse summary of failed tests and a summary of each test suite's results.

First, we abstract away the signature we'd expect for a reporter. It has a single function, report, which will produce an artifact for a given test result. Sometimes we just print a summary of the results to the screen, in which case the artifact has type unit. Other times, we may produce an XML snippet for each test suite, which are then written to a file.

signature REPORTER = sig
    type t; (* type of the artifact produced *)
    val report : TestResult -> t;
end;

We will begin with a summary of the test results along the lines of JUnit, namely just printing if a test case has failed or experienced an error, then print a summary of the test suite. These can be done in two helper functions, but I'm lazy, so I'm going to write it all at once.

The output for a test suite which has no errors or failures would consist of two lines:

Running <file path>
Tests run: <number>, Failures: <number>, Errors: <number>, Skipped: <number>, Time elapsed: <interval s - in <file path>

For now, I will simply use the test suite name instead of the path. We package this together in JUnitTt, a structure writing to the terminal (hence the Tt suffix) a summary imitating JUnit's output.

fun count_tests_run ({success=s1, fail=f1, errors=e1} : ResultSummary)
    = s1 + f1 + e1;

structure JUnitTt : REPORTER = struct
  type t = unit;

  fun report (ResultCase (name, dt, outcome)) =
      (case outcome of
           (TestFail msg) => print (name^" FAIL: "^msg^"\n")
         | (TestException e) => print (name^" ERROR: "^(exnMessage e)^"\n")
         | _ => ())
  | report (r as (ResultSuite (name, dt, results))) =
    let
        val summary = summarize r
    in
        (print ("Running "^name^"\n");
         app report results;
         print (concat ["Tests run: ", Int.toString (count_tests_run summary),
                        ", ",
                        "Failures: ", Int.toString (#fail summary),
                        ", ",
                        "Errors: ", Int.toString (#errors summary),
                        ", ",
                        (* "Skipped: ", #skipped summary, " ", *)
                        "Time elapsed: ", intervalToString dt,
                        " - in ", name,
                        "\n"]))
    end
end;

Now we can run this on our example test results, which will produce something like the following:

- JUnitTt.report ex_results;
Running Arithmetic Tests
Arithmetic test 2 FAIL: 1+1=3
Tests run: 2, Failures: 1, Errors: 0, Time elapsed: 0.000 - in Arithmetic Tests
val it = () : JUnitTt.t

7. XML Output

This is just to have a Jenkins-compatible artifact, so I could have a continuous integration framework test whatever I'm working on (at least, in theory). The best summary of JUnit's schema seems to be found here. Actually, with our work done so far, this amounts to be just an exercise in writing some SML code.

Schematically, the XML output from JUnit looks like:

<?xml version="1.0" encoding="UTF-8"?>
<!-- a description of the JUnit XML format and how Jenkins parses it. See also junit.xsd -->

<!-- if only a single testsuite element is present, the testsuites
     element can be omitted. All attributes are optional. -->
<testsuites disabled="" <!-- total number of disabled tests from all testsuites. -->
            errors=""   <!-- total number of tests with error result from all testsuites. -->
            failures="" <!-- total number of failed tests from all testsuites. -->
            name=""
            tests=""    <!-- total number of tests from all testsuites. Some software may expect to only see the number of successful tests from all testsuites though. -->
            time=""     <!-- time in seconds to execute all test suites. -->
	    >
  <!-- testsuite can appear multiple times, if contained in a testsuites element.
       It can also be the root element. -->
  <testsuite name=""      <!-- Full (class) name of the test for non-aggregated testsuite documents.
                               Class name without the package for aggregated testsuites documents. Required -->
         tests=""     <!-- The total number of tests in the suite, required. -->
         disabled=""  <!-- the total number of disabled tests in the suite. optional -->
         errors=""    <!-- The total number of tests in the suite that errored. An errored test is one that had an unanticipated problem,
                           for example an unchecked throwable; or a problem with the implementation of the test. optional -->
         failures=""  <!-- The total number of tests in the suite that failed. A failure is a test which the code has explicitly failed
                           by using the mechanisms for that purpose. e.g., via an assertEquals. optional -->
         hostname=""  <!-- Host on which the tests were executed. 'localhost' should be used if the hostname cannot be determined. optional -->
         id=""        <!-- Starts at 0 for the first testsuite and is incremented by 1 for each following testsuite -->
         package=""   <!-- Derived from testsuite/@name in the non-aggregated documents. optional -->
         skipped=""   <!-- The total number of skipped tests. optional -->
         time=""      <!-- Time taken (in seconds) to execute the tests in the suite. optional -->
         timestamp="" <!-- when the test was executed in ISO 8601 format (2014-01-21T16:17:18). Timezone may not be specified. optional -->
         >

    <!-- Properties (e.g., environment settings) set during test
     execution. The properties element can appear 0 or once. -->
    <properties>
      <!-- property can appear multiple times. The name and value attributres are required. -->
      <property name="" value=""/>
    </properties>

    <!-- testcase can appear multiple times, see /testsuites/testsuite@tests -->
    <testcase name=""       <!-- Name of the test method, required. -->
          assertions="" <!-- number of assertions in the test case. optional -->
          classname=""  <!-- Full class name for the class the test method is in. required -->
          status=""
          time=""       <!-- Time taken (in seconds) to execute the test. optional -->
          >

      <!-- If the test was not executed or failed, you can specify one
           the skipped, error or failure elements. -->

      <!-- skipped can appear 0 or once. optional -->
      <skipped/>

      <!-- Indicates that the test errored. An errored test is one
           that had an unanticipated problem. For example an unchecked
           throwable or a problem with the implementation of the
           test. Contains as a text node relevant data for the error,
           for example a stack trace. optional -->
      <error message="" <!-- The error message. e.g., if a java exception is thrown, the return value of getMessage() -->
         type=""    <!-- The type of error that occured. e.g., if a java execption is thrown the full class name of the exception. -->
         ></error>

      <!-- Indicates that the test failed. A failure is a test which
       the code has explicitly failed by using the mechanisms for
       that purpose. For example via an assertEquals. Contains as
       a text node relevant data for the failure, e.g., a stack
       trace. optional -->
      <failure message="" <!-- The message specified in the assert. -->
           type=""    <!-- The type of the assert. -->
           ></failure>

      <!-- Data that was written to standard out while the test was executed. optional -->
      <system-out></system-out>

      <!-- Data that was written to standard error while the test was executed. optional -->
      <system-err></system-err>
    </testcase>

    <!-- Data that was written to standard out while the test suite was executed. optional -->
    <system-out></system-out>
    <!-- Data that was written to standard error while the test suite was executed. optional -->
    <system-err></system-err>
  </testsuite>
</testsuites>

8. Test Discovery

Ideally, my project should look like:

├── src
│   ├── main.sml
│   └── foo.sml
├── test
│   ├── xunit.sml
│   ├── runner.sml
│   ├── main.sml
│   └── foo-test.sml
└── README.md

That is to say, the tests reside in their own directory, separate from the source code being tested. We would like to have runner.sml iterate through the files and subdirectories, execute the tests and suites defined.

(The reason we have separate main.sml files is because of how idiosyncratic each SML compiler is, and we want to support Poly/ML and MLton.)

Writing code for test discovery may be a bit tricky. I think the fact of the matter is that we'll need to add each test suite to the build file, and automatically use the relative path as the test suite's name. This would involve using OS.FileSys and possibly OS.Path, and revising our suite method to take no arguments (since it would be inferred automatically).

We can make test discovery done "by hand" using whatever build process, and the functions suite and test. The test runner will then iterate through the test suites (which are loaded into all_suites), and execute them. Thus test discovery boils down to maintaining a list of files to compile in the Makefile (or equivalent) in the project/test/ subdirectory.

Last Updated 2023-09-25 Mon 08:59.