Cripts unit tests

test_cripts runs the cripts objects against the real plugin API and real header heaps, without starting a server. Nothing is stubbed: every TS API call a test makes goes to the same implementation traffic_server uses.

Layout

The two targets play the parts of traffic_server and a cript:

ProcessLoads with dlopen()Which links
In productiontraffic_servera compiled criptlibcripts
In the teststest_criptstest_cripts_cases.solibcripts

Inside the test_cripts process, in order:

test_cripts                 executable: main.cc, plus the core and tsapi_objects (the real TS API), symbols exported
  main()
    1. set up the core: pfreelist off, Diags, http_init(), api_init()
    2. dlopen("test_cripts_cases.so") ------------------+
    3. Catch::Session().run() runs the cases            |
                    ^                                   v
                    |               test_cripts_cases.so    module: the test_*.cc files
                    |                 - links libcripts; its static initializers run now, after step 1
                    +---------------- - each TEST_CASE registers with libCatch2 as the module loads
                                      - its TS API calls, and libcripts', bind to symbols in test_cripts

Both targets link the same shared libCatch2, so the cases the module registers in step 2 are the ones step 3 runs.

Why the cases are a dlopen()ed module

libcripts' static initializers read state that the core sets up before it loads any plugin:

  • The global proxy object looks up the overridable configurations with TSHttpTxnConfigFind(). That reads a table the core builds in its own static initializers. A shared library‘s initializers run before the executable’s, so linking libcripts into the test executable would run the lookup against an empty table and TSFatal before main().
  • The cripts::Method constants copy the API's well-known method strings, which api_init() sets. Loaded any earlier, every constant would be empty.

A real cript is dlopen()ed after the core is up, and the tests do the same.

Why the module takes only the core's headers

The module must not link the core libraries. If it did, it would get private copies of the core's globals, for example the header token tables that http_init() sets up in the executable. It would then run against copies that were never initialized.

Why libCatch2 must be shared

The cases register in the module, and main() runs them. That only works with one Catch2 registry for both, so libCatch2 has to be a shared library. lib/CMakeLists.txt builds it that way, and src/cripts/CMakeLists.txt stops the configure if that ever changes, because a static Catch2 would silently run no tests.

Setup in main()

main() sets up the core, as traffic_server does before it loads plugins, and only then loads the cases:

  • There are no event threads, so cmd_disable_pfreelist keeps the allocators off the thread local free lists.
  • The main thread is registered as an EThread, so this_thread() gives the cripts Context and continuation allocations a thread, as on a net thread.
  • Diags writes to stderr, so TSWarning(), TSError() and TSFatal() from the code under test print their messages instead of crashing.
  • http_init() sets up the well-known header strings and the parser tables.
  • api_init() sets the API's well-known strings, such as TS_HTTP_METHOD_GET.

The test transaction (CriptsTestTxn.h)

The cripts header and URL objects reach the request through calls such as TSHttpTxnClientReqGet(txnp, ...). These only check that txnp is a live HttpSM and return one of its t_state.hdr_info headers. cripts_test::Txn provides exactly that, without running the state machine:

  • an HttpSM marked alive, with the HTTP configuration attached;
  • a client request parsed by the real parser, and optionally a server response (set_server_response());
  • a cripts::Context for that transaction.

A test case declares context the same way a cript has it, so the cripts macros work unchanged:

TEST_CASE("Header: add a field", "[cripts][Headers]")
{
  cripts_test::Txn txn{"GET /a HTTP/1.1\r\nHost: example.com\r\n\r\n"};
  auto             context = txn.context();

  borrow req = cripts::Client::Request::Get();

  req["X-New"] = "value";
  CHECK(txn.client_request_field("X-New") == "value");
}

When checking writes, check what the core holds rather than what cripts reads back:

  • client_request_field(), client_request_field_count() and client_url() read the core's HTTPHdr and URL directly, so a change that only lives in a cripts cache still fails the test.
  • sm() gives access to the rest of the transaction state.
  • cripts_test::str() turns a cripts component into a std::string. Catch2 needs this to print values, because the components only convert through a non-const GetSV().

To test what happens across hooks, call context->reset(). It is what the Epilogue does on entering each hook. Code that depends on the current hook needs context->state.hook set first. For example, response status writes go to the header only in the response hooks.

Adding tests

  • Put new cases in a test_<Area>.cc file. Add the file to the test_cripts_cases sources in src/cripts/CMakeLists.txt.
  • Each Catch2 case is registered as its own ctest (add_catch2_test) and runs in its own process, so a case that aborts does not take the others with it. Cases must not depend on each other.
  • New cases only change the module. The executable is relinked when the module changes (LINK_DEPENDS), which is what makes ctest discover them.

Run the tests with:

ctest --test-dir <build dir> -R test_cripts

To run a single case:

<build dir>/src/cripts/test_cripts "Header: add a field"

Limits

There is no event loop, no network connection and no session: ssnp is null, and there is no server VC. Hooks never fire, so the Epilogue's hook dispatch is not exercised. Behaviour that needs any of these belongs in an AuTest under tests/gold_tests/cripts/ instead.