WCT Testing with C++
(top) (pkg)

Table of Contents

Wire-Cell testing allows writing tests in C++ tests at different levels of granularity. C++ test source files match the usual WCT testing pattern:

<pkg>/<prefix>[<sep>]<name>.cxx

The <prefix> may match any of the groups named in the framework document. The developer is free to write tests of these types following the guidelines give by the WCT testing.

In addition, C++ tests may be written in the form of doctest unit test framework. These tests are meant to test the smallest code “units”. All <pkg>/test/doctest*.cxx source files will be compiled to a single, per-package build/<pkg>/wcdoctest-<pkg> executable. Tests implemented with doctest should be very fast running and should make copious use of doctest CPP macros and run atomically (no dependencies on other tests).

If you have not yet done so, read the testing framework document for an overview and the writing tests document for general introduction to writing tests. The remaining sections describe how to write WCT tests in C++.

1. Write a doctest test

Edit a file named to match the <pkg>/test/doctest*.cxx pattern:

emacs util/test/doctest-my-first-test.cxx

Include the single doctest.h header, and any others your test code may require and provide at least one TEST_CASE("...").

#include "WireCellUtil/doctest.h"
#include "WireCellUtil/Logging.h"
TEST_CASE("my first test") {
    bool ok=true;
    CHECK(1 + 1 == 2);          // does not halt testing
    REQUIRE(ok == true);        // halts testing
    SUBCASE("a test using above as existing context") {
        ok = false;
        CHECK(!ok);
    }
    SUBCASE("another test using copy of the context") {
        // subcase above does not change our 'ok' variable
        CHECK(ok);
    }
}

TEST_CASE("my second test") {
    spdlog::debug("this test is very trivial");
    CHECK(true);
}

Compile and run just one test:

waf --target=wcdoctest-test
./build/test/wcdoctest-test --test-case='my first test'
./build/test/wcdoctest-test   # runs all test cases

The doctest runner has many options

./build/util/wcdoctest-util --help

2. Logging with doctest

Developers are encouraged not to use std::cout or std::cerr in doctest tests. Instead, as shown in the above example, we should use logging at debug level (or trace).

#include "WireCellUtil/Logging.h"
TEST_CASE("...") {
    spdlog::debug("some message");
    // ...
}

By default, these messages will not be seen. But they can be turned on:

$ SPDLOG_LEVEL=DEBUG ./build/test/wcdoctest-test

3. Atomic C++ tests

An “atomic” C++ test source file matches:

<pkg>/test/test*.cxx
<pkg>/test/atomic*.cxx

Each atomic source file must provide a main() function and results in a similarly named executable found at:

build/<pkg>/test*
build/<pkg>/atomic*

Some reasons to write atomic tests (compared to doctest tests) include:

  • The developer wishes the test to accept optional command line arguments to perform variant tests.
  • The test is long-running (more than about 1 second) and so benefits from task-level parallelism provided by waf.

4. A simple atomic C++ test

A trivial atomic test is shown:

int main(int argc, char* argv[])
{
    bool ok = true;
    if (!ok) {
        return 1;
    }
    return 0;
}

Compile and run with:

$ waf --tests --target=atomic-simple
$ ./build/test/atomic-simple

5. Logging with atomic tests

Like in section 2, developers should use debug level logging instead of std::cout or std::cerr. For this to work, the code requires some boilerplate:

#include "spdlog/spdlog.h"
#include "spdlog/cfg/env.h"

int main(int argc, char* argv[])
{
    // required for SPDLOG_LEVEL env var
    spdlog::cfg::load_env_levels(); 

    spdlog::debug("all messages should be at debug or trace");
    spdlog::info("avoid use of info() despite this example");

    // Now some "tests"
    bool ok = true;
    if (!ok) return 1;

    return 0;
}
$ ./build/test/atomic-simple-logging
[2023-04-25 11:56:26.348] [info] avoid use of info() despite this example

$ SPDLOG_LEVEL=debug ./build/test/atomic-simple-logging
[2023-04-25 11:59:47.884] [debug] all messages should be at debug or trace
[2023-04-25 11:59:47.884] [info] avoid use of info() despite this example

6. Mixing atomic and doctest

It is possible make an atomic test use doctest. It will still be processed as an atomic test by WCT build system but it will gain the facilities of doctest. Along with logging, it requires a bit more boilerplate:

#define DOCTEST_CONFIG_IMPLEMENT
#include "WireCellUtil/doctest.h"
#include "spdlog/spdlog.h"
#include "spdlog/cfg/env.h"
int main(int argc, char** argv) {
    spdlog::cfg::load_env_levels();
    doctest::Context context;
    context.applyCommandLine(argc, argv);
    int rc = context.run();
    if (rc) { return rc; }

    bool ok = true;             // start my tests
    if (!ok) return 1;
    return 0;
}
$ waf --tests --target=atomic-doctest
$ ./build/test/atomic-doctest
$ ./build/test/atomic-doctest --help

7. Using atomic as a variant test

An atomic test must run with no command line arguments. However, we may allow optional arguments. One example:

aux/test/test_idft.cxx

This tests various aspects of the IDFT interface implementations. It can be run as an atomic test with the default IDFT implementation:

$ ./build/aux/test_idft

It can also be run in a variant form by giving optional command line argumetns:

$ ./build/aux/test_idft FftwDFT WireCellAux
$ ./build/aux/test_idft TorchDFT WireCellPytorch
$ ./build/aux/test_idft cuFftDFT WireCellCuda

The first variant is actually identical to the atomic call. The latter two require that WCT is build with support for PyTorch and CUDA, respectively. An atomic test for each of the latter two variants can be found in their respective packages.

C++ tests require particular attention to dependencies. The test_idft is a little special in that it only has build-time dependency on the iface sub package yet it is placed in the aux package and can have run-time dependency on other higher-level packages via WCT’s plugin and component factory mechanisms. In its default calling, it relies on the FftwDFT component being available. This component is provided the WireCellAux plugin library (from the aux sub package) and so this minimal run-time dependency is satisfied by placing the test in the aux sub package. Depending on the variant form, it must be run in a context with either the WireCellPytorch or WireCellCuda plugins available. We will show how to register these variants so they are run when these optional sub packages are built.

8. Growing a test

Tests tend to grow. Developers are strongly urged to grow tests in a way that defines separate test cases separately. When a developer writes a doctest test this is easily done by add more TEST_CASE() and/or SUBCASE() instances to the source file. When writing an atomic test, the developer must invent their own “mini unit test framework”. One common pattern is “bag of test_* functions. Functions are distinquished by name and/or templates:

static
void test_2d_threads(IDFT::pointer dft, int nthreads, int nloops, int size = 1024)
{
    // ...
}
template<typename ValueType>
void test_2d_transpose(IDFT::pointer dft, int nrows, int ncols)
{
   // ...
}

int main(int argc, char* argv[])
{
    // ...
    test_2d_transpose<IDFT::scalar_t>(idft, 2, 8);
    // ...
    return 0;
}

9. Failing tests

A test is successful if it completes with a return status code of zero. A failed test can be indicated in a number of ways:

  • return non-zero status code from main().
  • throw an exception.
  • call assert() or abort().
  • call WCT’s Assert() or AssertMsg().
  • apply doctest assertion macros.

The test developer is free to use any or a mix of these methods and is strongly urged to use them pervasively throughout the test code.

Do not write tests that lack any forms of actual error exit. Otherwise they are not actually testing anything!

10. WCT C++ testing support

As introduced above, WCT provides some support for testing. The first are simple wrappers around assert() and one that will print a message if the assertion fails:

#include "WireCellUtil/Testing.h"

int main()
{
    int x = 42;
    Assert(x == 42);
    AssertMsg(x == 0, "Not the right answer");
    return 0;
}

In addition, WCT provides facilities for reporting simple performance statistics, specifically CPU time and memory usage.

#include "WireCellUtil/TimeKeeper.h"
#include "WireCellUtil/MemUsage.h"
#include "WireCellUtil/ExecMon.h"
TimeKeeper
a “stopwatch” to record time along with a message for various steps in a test
MemUsage
similar but to record memory usage
ExecMon
combine the two.

See test_timekeeper.cxx, test_memusage.cxx and test_execmon.cxx, respectively, in util/test/.

11. Output diagnostic files

Tests may produce files, even atomic tests that may have no files governing waf task dependencies. These files can be useful to persist beyond the test job. The ideal location for these files is the build/ directory and as sibling to the C++ test executable. C++ has a simple pattern to achieve this:

int main(int argc, char* argv[])
{
    std::string name = argv[0];
    std::string outname = name + ".ext";
    std::string outname2 = name + "_other.ext";
    // open and write to outname and outname 2....
    return 0;
}

As the C++ test executable is found build/<pkg>/<prefix><sep><name>, these output files will be found there as siblings.

See also the use of the data repository in data repository document for special files to per persisted beyond the local build/ area.

12. Found input files

Likewise, an atomic test must not expect any input files specified by the caller. However, it may load files that can be found from the environment. A common example is to find a WCT “wires” file or others provided by wire-cell-data. Here is a C++ pattern do that in a way that naturally allows an atomic test to also be called in a variant manner.

int main(int argc, char* argv[])
{
    const char* filename = "microboone-celltree-wires-v2.1.json.bz2";
    if (argc > 1) {
        filename = argv[1];
    }
    // use filename...
    return 0;
}

See util/test/test_wireschema.cxx for an example.

For this kind of file to be found the user must define WIRECELL_PATH to include a directory holding the contents of wire-cell-data.

In principle the path in argv[0] may also be used to locate the top of the wire-cell-toolkit source in order to locate files provided by the source and use them as input.

The data repository also provides a set of known files for input.

Author: Brett Viren

Created: 2023-05-03 Wed 11:39

Validate