Testworks Usage

Testworks is a Dylan unit testing library.

See also: Testworks Reference

Quick Start

For the impatient (i.e., most of us), this section summarizes most of what you need to know to use Testworks.

Add use testworks; to both your test library and test module.

Tests contain arbitrary code and at least one assertion or expectation.

Terminology: An assertion failure terminates the test and skips any further assertions or expectations. (This is normally preferred since it prevents cascading failures.) An expectation failure does not terminate the test. Assertions are Testworks macros that begin with assert- and expectations begin with expect-. When this documentation refers to “assertions” it should be understood to include both kinds of macros.

Example test:

define test test-my-function ()
  let v = do-something();
  assert-equal("expected-value", my-function(v));
  assert-condition(<error>, my-function(v, key: 7), "regression test for bug 12345");
end test;

If there are no assertions in a test it is considered “NOT IMPLEMENTED”, which is displayed in the output (as a reminder to implement it) but is not considered a failure.

See also: Assertions for other assertion macros.

Benchmarks do not require any assertions and are automatically given the “benchmark” tag:

define benchmark fn1-benchmark ()
  fn1()
end;

See also, benchmark-repeat.

If you have a large or complex test library, “suites” may be used to organize tests into groups (for example one suite per module) and may be nested arbitrarily. This does impose a small maintenance burden of adding each test to a suite, and is not required. See Suites for details.

define suite my-library-suite ()
  suite module1-suite;
  suite module2-suite;
  test some-other-test;
end;

Note

Suites must be defined textually after the other suites and tests they contain.

To run your tests of course you need an executable and there are two ways to accomplish this:

  1. Have your library call run-test-application and compile it as an executable. With no arguments run-test-application runs all tests and benchmarks, as filtered by the Testworks command-line options.

    If you prefer to manually organize your tests with suites, pass your top-level suite to run-test-application and that determines the initial set of tests that are filtered by the command line.

    Note

    If you forget to add a test to any suite, the test will not be run.

  2. Compile your test library as a shared library and run it with the testworks-run application. For example, for the foo-test library:

    _build/bin/testworks-run --load libfoo-test.so
    

In both cases run-test-application parses the command line so the options are the same. Use --help to see all options.

Defining Tests

Assertions

Assertions come in two forms: assert-* macros and expect-* macros. When an assert-* macro fails, it causes the test to fail and the remainder of the test to be skipped. This kind of assertion is generally preferred because it prevents “cascading failures”. In contrast, when an expect-* macro fails, it causes a test failure but the test continues running, so there may be multiple assertion failures recorded for the test.

Note

You may also find check-* macros in existing test suites. These are a deprecated form of assertion, replaced by expect-*.

The benefit of the expect-* macros is that when you’re initially debugging the test it may require fewer iterations if you can see multiple failures in one pass.

An assertion accepts an expression to evaluate and report back on, saying if the expression passed, failed, or crashed (i.e., signaled an error). As an example, in

assert-true(foo > bar)
expect(foo > bar)

the expression foo > bar is compared to #f, and the result is recorded by the test harness.

See the Testworks Reference for detailed documentation on the available assertion macros.

Each assertion macro accepts an optional description (a format string and optional format arguments), after the required arguments, which is displayed if the assertion fails. If the description isn’t provided, Testworks makes one from the expressions passed to the assertion macro. For example, assert-true(2 > 3) produces this failure message:

FAILED: (2 > 3)
  expression "(2 > 3)" evaluates to #f

In general, Testworks should be pretty good at reporting the actual values that caused the failure so it shouldn’t be necessary to include them in the description all the time. Usually if your test iterates over various inputs it’s a good idea to provide a description so the failing input can be easily identified.

If you do provide a description it may either be a single value to display, as with format-to-string("%s", v), or a format string and corresponding format arguments. These are all valid:

assert-equal(want, got);       // auto-generated description
assert-equal(want, got, foo);  // foo used as description
assert-equal(want, got, "does %= = %=?", a, b);  // formatted description

Tests

Tests contain assertions and arbitrary code needed to support those assertions. Each test may be part of a suite. Use the test-definer macro to define a test:

define test NAME (#key EXPECTED-FAILURE?, TAGS)
  BODY
end;

For example:

define test my-test ()
  assert-equal(2, 3);
  assert-equal(#f, #f);
  assert-true(identity(#t), "Check identity function");
end;

The result looks like this:

$ _build/bin/my-test-suite
Running suite my-test-suite:
Running test my-test:
  2 = 3: [2 and 3 are not =.]
   FAILED in 0.000257s and 17KiB

my-test failed
  #f = #f passed
  2 = 3 failed [2 and 3 are not =.]
Ran 2 checks: FAILED (1 failed)
Ran 1 test: FAILED (1 failed)
FAILED in 0.000257 seconds

Note that the third assertion was not executed since the second one failed and terminated my-test.

Tests may be tagged with arbitrary strings, providing a way to select or filter out tests to run:

define test my-test-2 (tags: #["huge"])
  ...huge test that takes a long time...
end test;

define test my-test-3 (tags: #["huge", "verbose"])
  ...test with lots of output...
end test;

Tags can then be passed on the Testworks command-line. For example, this skips both of the above tests:

$ _build/bin/my-test-suite-app --tag=-huge --tag=-verbose

Negative tags take precedence, so --tag=huge --tag=-verbose runs my-test-2 and skips my-test-3.

If the test is expected to fail, or fails under some conditions, Testworks can be made aware of this:

define test failing-test
    (expected-to-fail-reason: "bug 1234")
  assert-true(#f);
end test;

define test fails-on-windows
    (expected-to-fail?: method () $os-name = #"win32" end,
     expected-to-fail-reason: "blah is not implemented for WIN32 platform")
  if ($os-name = #"win32")
    assert-false(#t);
  else
    assert-true(#t);
  end if;
end test;

A test that is expected to fail and then fails is considered to be a passing test. If the test succeeds unexpectedly, it is considered a failing test. When marking a test as expected to fail, expected-to-fail-reason: is required and expected-to-fail?: is optional, and normally unnecessary. An example of a good reason is a bug URL or other bug reference.

Note

When providing a value for expected-to-fail?: always provide a method of no arguments. For example, instead of expected-to-fail?: $os-name == #"win32" use expected-to-fail?: method () $os-name == #"win32" end. The former is equivalent to expected-to-fail?: #f on non-Windows platforms and results in an UNEXPECTED SUCCESS result. This is because the (required) reason string is used as shorthand to indicate that failure is expected even when expected-to-fail?: is #f.

Test setup and teardown is accomplished with normal Dylan code using block () ... cleanup ... end;

define test foo ()
  block ()
    do-setup-stuff();
    assert-equal(...);
    assert-equal(...);
  cleanup
    do-teardown-stuff()
  end
end;

Benchmarks

Benchmarks are like tests except for:

  • They do not require any assertions. (They pass unless they signal an error.)

  • They are automatically assigned the “benchmark” tag.

The benchmark-definer macro is like test-definer:

define benchmark my-benchmark ()
  ...body...
end;

Benchmarks may be added to suites:

define suite my-benchmarks-suite ()
  benchmark my-benchmark;
end;

Benchmarks and tests may be combined in the same suite, but this is discouraged. It is preferable to have separate libraries for the two since benchmarks often take longer to run and may not necessarily need to be run for every commit.

See also, benchmark-repeat.

Suites

Suites are an optional feature that may be used to organize your tests into a hierarchy. Suites contain tests, benchmarks, and other suites. A suite is defined with the suite-definer macro. The format is:

define suite NAME (#key setup-function, cleanup-function)
    test TEST-NAME;
    benchmark BENCHMARK-NAME;
    suite SUITE-NAME;
end;

For example:

define suite first-suite ()
  test my-test;
  test example-test;
  test my-test-2;
end;

define suite second-suite ()
  suite first-suite;
  test my-test;
end;

Suites can specify setup and cleanup functions via the keyword arguments setup-function and cleanup-function. These can be used for things like establishing database connections, initializing sockets and so on.

A simple example of doing this can be seen in the http-server test suite:

define suite http-test-suite (setup-function: start-sockets)
  suite http-server-test-suite;
  suite http-client-test-suite;
end;

Interface Specification Suites

The interface-specification-suite-definer macro creates a normal test suite, much like define suite does, but based on an interface specification. For example,

define interface-specification-suite time-specification-suite ()
  sealed instantiable class <time> (<object>);
  constant $utc :: <zone>;
  variable *zone* :: <zone>;
  sealed generic function in-zone (<time>, <zone>) => (<time>);
  function now (#"key", #"zone") => (<time>);
  ...
end;

The specification usually has one clause, or “spec”, for each name exported from your public interface module. Each spec creates a test named test-{name}-specification to verify that the implementation matches the spec for {name}. For example, by checking that the names are bound, that their bindings have the correct types, that functions accept the right number and types of arguments, etc.

Specification suites are otherwise just normal suites. They may include other arbitrary tests and child suites if desired:

define interface-specification-suite time-suite ()
  ...
  test test-time-still-moving-forward;
  suite time-travel-test-suite;
end;

This also means that if your interface is large you may use multiple interface-specification-suite-definer forms and then group them together.

See interface-specification-suite-definer for more details on the various kinds of specs.

Organizing Tests for One Library

If you don’t use suites, the only organization you need is to name your tests and benchmarks uniquely, and you can safely skip the rest of this section. If you do use suites, read on….

Tests are used to combine related assertions into a unit, and suites further organize related tests and benchmarks. Suites may also contain other suites.

It is common for the test suite for library xxx to export a single test suite named xxx-test-suite, which is further subdivided into sub-suites, tests, and benchmarks as appropriate for that library. Some suites may be exported so that they can be included as a component suite in combined test suites that cover multiple related libraries. (The alternative to this approach is running each library’s tests as a separate executable.)

Note

It is an error for a test to be included in a suite multiple times, even transitively. Doing so would result in a misleading pass/fail ratio, and it is more likely to be a mistake than to be intentional.

The overall structure of a test library that is intended to be included in a combined test library may look something like this:

// --- library.dylan ---

define library xxx-tests
  use common-dylan;
  use testworks;
  use xxx;                 // the library you are testing
  export xxx-tests;        // so other test libs can include it
end;

define module xxx-tests
  use common-dylan;
  use testworks;
  use xxx;                 // the module you are testing
  export xxx-test-suite;   // so other suites can include it
end;

// --- main.dylan ---

define test my-awesome-test ()
  assert-true(...);
  assert-equal(...);
  ...
end;

define benchmark my-awesome-benchmark ()
  awesomely-slow-function();
end;

define suite xxx-test-suite ()
  test my-awesome-test;
  benchmark my-awesome-benchmark;
  suite my-awesome-other-suite;
  ...
end;

Running Tests As A Stand-alone Application

If you don’t need to export any suites so they can be included in a higher-level combined test suite library (i.e., if you’re happy running your test suite library as an executable) then you can simply call run-test-application to parse the standard testworks command-line options and run the specified tests:

run-test-application();

and you can skip the rest of this section.

If you need to export a suite for use by another library, then you must also define a separate executable library, traditionally named “xxx-test-suite-app”, which calls run-test-application().

Here’s an example of such an application library:

1. The file library.dylan which must use at least the library that exports the test suite, and testworks:

Module:    dylan-user
Synopsis:  An application library for xxx-test-suite

define library xxx-test-suite-app
  use xxx-test-suite;
  use testworks;
end;

define module xxx-test-suite-app
  use xxx-test-suite;
  use testworks;
end;
  1. The file xxx-test-suite-app.dylan which simply contains a call to the method run-test-application:

Module: xxx-test-suite-app

run-test-application();
  1. The file xxx-test-suite-app.lid which specifies the names of the source files:

Library: xxx-test-suite-app
Target-type: executable
Files: library.dylan
       xxx-test-suite-app.dylan

Once a library has been defined in this fashion it can be compiled into an executable with dylan-compiler -build xxx-test-suite-app.lid and run with xxx-test-suite-app --help.

Reports

The --report and --report-file options can be used to write a full report of test run results so that those results can be compared with subsequent test runs, for example to find regressions. These are the available report types:

failures (the default)

Prints out only the list of failures and a summary, in readable text format.

full

Like failures but prints results whether passing or failing.

json

Outputs JSON objects that match the suite/test/assertion tree structure, with full detail.

summary

Prints out only a summary of how many assertions, tests and suites were executed, passed, failed or crashed.

surefire

Outputs XML in Surefire format. This elides information about specific assertions. This format is supported by various tools such as Jenkins.

xml

Outputs XML that directly matches the suite/test/assertion tree structure, with full detail.

Comparing Test Results

* To be filled in *

Quick version:

  • (master branch)$ my-test-suite –report json –report-file out1.json

  • (your branch)$ my-test-suite –report json –report-file out2.json

  • $ testworks-report out1.json out2.json