Skip to main content

Module Unit Testing Guide

This guide covers how to create unit tests for CLIO Runtime modules (ChiMods). The testing framework allows both the runtime and client to run in a single process, enabling integration testing without multi-process coordination.

Test Environment Setup

Environment Variables

Unit tests require specific environment variables for module discovery and configuration:

# Path to compiled Module libraries (build/bin directory)
export CLIO_REPO_PATH="/path/to/build/bin"

# Library path for dynamic loading
export LD_LIBRARY_PATH="/path/to/build/bin:$LD_LIBRARY_PATH"

# Optional: Specify a custom configuration file
export CLIO_SERVER_CONF="/path/to/clio_default.yaml"

Module Discovery: The runtime scans both CLIO_REPO_PATH and LD_LIBRARY_PATH for Module shared libraries (e.g., libclio_bdev_runtime.so). Point these at your build/bin directory.

Configuration Files

Tests use the same configuration format as production. The runtime looks for configuration in this order:

  1. CLIO_SERVER_CONF environment variable
  2. ~/.clio/clio.yaml

A minimal test configuration:

networking:
port: 9413

runtime:
num_threads: 4
queue_depth: 1024

compose:
- mod_name: clio_bdev
pool_name: "ram::chi_default_bdev"
pool_query: local
pool_id: "301.0"
bdev_type: ram
capacity: "512MB"

See the Configuration Reference for all parameters.

Test Framework

The project uses a custom lightweight test framework defined in context-runtime/test/simple_test.h. It provides macros similar to Catch2:

#include "simple_test.h"

namespace {
bool ready = true;
int value = 42;
} // namespace

TEST_CASE("Descriptive test name", "[tag1][tag2]") {
SECTION("subsection name") {
REQUIRE(ready);
REQUIRE_FALSE(!ready);
REQUIRE_NOTHROW(value + 1);
INFO("diagnostic message: " << value);
if (value < 0) {
FAIL("explicit failure message");
}
}
}

Test Runners

There are two ways to define main():

Option 1 — SIMPLE_TEST_MAIN() macro (preferred for tests that don't need the runtime):

#include "simple_test.h"

namespace {
bool g_ok = true;
} // namespace

TEST_CASE("example", "[demo]") {
REQUIRE(g_ok);
}

// SIMPLE_TEST_MAIN() expands to a full main(): it runs every registered
// TEST_CASE, applying an optional [tag] / name filter taken from argv[1],
// then exits via SIMPLE_TEST_PROCESS_EXIT so cleanup runs correctly on
// every platform.
SIMPLE_TEST_MAIN()

Option 2 — Custom main() with runtime initialization (required for tests that submit tasks):

#include "simple_test.h"
#include <clio_runtime/clio_runtime.h>
#include <string>

int main(int argc, char **argv) {
// Initialize CLIO Runtime + client in one process.
if (!clio::run::CLIO_INIT(clio::run::RuntimeMode::kClient, true)) {
return 1;
}
// Ensure the runtime is finalized cleanly after all tests run.
SimpleTest::g_test_finalize = clio::run::CLIO_RUNTIME_FINALIZE;

// Run tests with optional filter from command line.
std::string filter = (argc > 1) ? argv[1] : "";
int result = SimpleTest::run_all_tests(filter);
// Terminates immediately on Windows (dodges a libzmq static-destructor
// abort); a no-op on POSIX, where normal teardown then runs.
SIMPLE_TEST_PROCESS_EXIT(result);
return result;
}

clio::run::CLIO_INIT(clio::run::RuntimeMode::kClient, true) starts both the runtime (worker threads, task queues) and the client library in a single process. The true flag (default_with_runtime) means "also start the embedded runtime."

Test Fixture Pattern

Most tests use a fixture class that initializes the runtime once across all test cases:

#include "simple_test.h"
#include <clio_runtime/clio_runtime.h>
#include <chrono>
#include <thread>

using namespace std::chrono_literals;

namespace {
bool g_initialized = false;
} // namespace

class MyModuleFixture {
public:
MyModuleFixture() {
if (!g_initialized) {
bool success =
clio::run::CLIO_INIT(clio::run::RuntimeMode::kClient, true);
if (success) {
g_initialized = true;
SimpleTest::g_test_finalize = clio::run::CLIO_RUNTIME_FINALIZE;
std::this_thread::sleep_for(500ms); // Allow workers to start
}
}
}
};

Instantiate the fixture at the top of each TEST_CASE:

#include "simple_test.h"
#include <clio_runtime/clio_runtime.h>

namespace {
bool g_initialized = false;

class MyModuleFixture {
public:
MyModuleFixture() {
if (!g_initialized) {
g_initialized =
clio::run::CLIO_INIT(clio::run::RuntimeMode::kClient, true);
}
}
};
} // namespace

TEST_CASE("My test", "[mymod]") {
MyModuleFixture fixture;
REQUIRE(g_initialized);

// ... test body ...
}

Complete Test Example

This example demonstrates parsing a compose config, creating a pool via the admin client, then exercising it through a module (bdev) client — following the patterns used in the actual codebase (e.g., test_compose.cc, test_streaming.cc, test_bdev_chimod.cc).

#include "simple_test.h"
#include <clio_runtime/clio_runtime.h>
#include <clio_runtime/config_manager.h>
#include <clio_runtime/admin/admin_client.h>
#include <clio_runtime/bdev/bdev_client.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#include <vector>

using namespace std::chrono_literals;

namespace {
bool g_initialized = false;

// Initializes the CLIO Runtime + client once for the whole test binary.
class ComposeFixture {
public:
ComposeFixture() {
if (!g_initialized) {
bool success =
clio::run::CLIO_INIT(clio::run::RuntimeMode::kClient, true);
if (success) {
g_initialized = true;
SimpleTest::g_test_finalize = clio::run::CLIO_RUNTIME_FINALIZE;
std::this_thread::sleep_for(500ms);
}
}
}
};

// Helper: write a compose config to a temp file and return its path.
std::string CreateComposeConfig() {
std::string bdev_path =
(std::filesystem::temp_directory_path() / "test_bdev.dat").string();
std::string config_path =
(std::filesystem::temp_directory_path() / "test_compose_config.yaml")
.string();
std::ofstream f(config_path);
f << "runtime:\n"
<< " num_threads: 4\n"
<< "networking:\n"
<< " port: 9413\n"
<< "compose:\n"
<< "- mod_name: clio_bdev\n"
// Single-quote so Windows backslashes / drive-colons are taken literally.
<< " pool_name: '" << bdev_path << "'\n"
<< " pool_query: dynamic\n"
<< " pool_id: 200.0\n"
<< " capacity: 10MB\n"
<< " bdev_type: file\n";
f.close();
return config_path;
}
} // namespace

TEST_CASE("Parse compose configuration", "[compose]") {
ComposeFixture fixture;
REQUIRE(g_initialized);

std::string config_path = CreateComposeConfig();

// Parse the compose file into a local ConfigManager, exactly as the runtime
// parses compose files.
clio::run::ConfigManager file_config;
REQUIRE(file_config.LoadYaml(config_path));

const auto& compose_config = file_config.GetComposeConfig();
REQUIRE(compose_config.pools_.size() >= 1);

// Find our test pool.
bool found = false;
for (const auto& pool : compose_config.pools_) {
if (pool.mod_name_ == "clio_bdev") {
REQUIRE(pool.pool_id_.major_ == 200);
REQUIRE(pool.pool_id_.minor_ == 0);
REQUIRE(pool.pool_query_.IsDynamicMode());
found = true;
break;
}
}
REQUIRE(found);
}

TEST_CASE("Admin client Compose", "[compose]") {
ComposeFixture fixture;
REQUIRE(g_initialized);

std::string config_path = CreateComposeConfig();
clio::run::ConfigManager file_config;
REQUIRE(file_config.LoadYaml(config_path));

auto* admin_client = CLIO_ADMIN;
REQUIRE(admin_client != nullptr);

const auto& compose_config = file_config.GetComposeConfig();

// Submit a Compose task per pool and wait for each to finish.
for (const auto& pool_config : compose_config.pools_) {
auto task = admin_client->AsyncCompose(pool_config);
task.Wait();
REQUIRE(task->GetReturnCode() == 0);
}

// Verify the pool exists by allocating a block through the bdev client.
clio::run::PoolId bdev_pool_id(200, 0);
clio::run::bdev::Client bdev_client(bdev_pool_id);
auto alloc_task =
bdev_client.AsyncAllocateBlocks(clio::run::PoolQuery::Local(), 1024);
alloc_task.Wait();
REQUIRE(alloc_task->GetReturnCode() == 0);
}

int main(int argc, char **argv) {
if (!clio::run::CLIO_INIT(clio::run::RuntimeMode::kClient, true)) {
return 1;
}
std::string filter = (argc > 1) ? argv[1] : "";
int result = SimpleTest::run_all_tests(filter);
SIMPLE_TEST_PROCESS_EXIT(result);
return result;
}

Async Task Patterns

Basic async submit and wait

#include "simple_test.h"
#include <clio_runtime/clio_runtime.h>
#include <clio_runtime/bdev/bdev_client.h>

void example() {
clio::run::PoolId pool_id(200, 0);
clio::run::bdev::Client client(pool_id);

// Submit asynchronously, then block until the result is ready.
auto task =
client.AsyncAllocateBlocks(clio::run::PoolQuery::Local(), 4096);
task.Wait();
REQUIRE(task->return_code_ == 0);
}

task is a clio::run::Future<T>. After Wait(), access result fields via task->field_name_.

Creating a module pool then using it

#include "simple_test.h"
#include <clio_runtime/clio_runtime.h>
#include <clio_runtime/bdev/bdev_client.h>

void example() {
clio::run::PoolId pool_id(200, 0);
clio::run::bdev::Client client(pool_id);

// Create the container (routes to the admin pool under the hood).
auto create_task = client.AsyncCreate(
clio::run::PoolQuery::Dynamic(), "my_pool", pool_id,
clio::run::bdev::BdevType::kRam, /*total_size=*/1024 * 1024);
create_task.Wait();
client.pool_id_ = create_task->new_pool_id_;
REQUIRE(create_task->return_code_ == 0);

// Use the module: allocate a block from the new pool.
auto task =
client.AsyncAllocateBlocks(clio::run::PoolQuery::Local(), 4096);
task.Wait();
REQUIRE(task->return_code_ == 0);
}

Multiple parallel tasks

#include "simple_test.h"
#include <clio_runtime/clio_runtime.h>
#include <clio_runtime/bdev/bdev_client.h>
#include <vector>

void example() {
clio::run::PoolId pool_id(200, 0);
clio::run::bdev::Client client(pool_id);

const int num_tasks = 8;
std::vector<clio::run::Future<clio::run::bdev::AllocateBlocksTask>> tasks;
for (int i = 0; i < num_tasks; ++i) {
tasks.push_back(
client.AsyncAllocateBlocks(clio::run::PoolQuery::DirectHash(i), 4096));
}
for (auto& task : tasks) {
task.Wait();
REQUIRE(task->return_code_ == 0);
}
}

CMake Integration

Add unit tests to your module's CMakeLists.txt. Follow the pattern used in context-runtime/modules/bdev/test/CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)

# Test executable
set(TEST_TARGET my_module_tests)
add_executable(${TEST_TARGET} test_my_module.cc)

target_include_directories(${TEST_TARGET} PRIVATE
${CLIO_RUN_ROOT}/include
${CLIO_RUN_ROOT}/test # For simple_test.h
${CLIO_RUN_ROOT}/modules/admin/include
${CLIO_RUN_ROOT}/modules/bdev/include
)

target_link_libraries(${TEST_TARGET}
clio_admin_client # Admin module client
clio_bdev_client # Bdev module client
ctp::cxx # ClioCtp library
${CMAKE_THREAD_LIBS_INIT} # Threading support
)

set_target_properties(${TEST_TARGET} PROPERTIES
CXX_STANDARD 20
CXX_STANDARD_REQUIRED ON
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)

# Install test executable
install(TARGETS ${TEST_TARGET} RUNTIME DESTINATION bin)

# CTest registration
if(CLIO_CORE_ENABLE_TESTS)
add_test(
NAME my_module_all_tests
COMMAND ${TEST_TARGET}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)
set_tests_properties(my_module_all_tests PROPERTIES
ENVIRONMENT "CLIO_REPO_PATH=${CMAKE_BINARY_DIR}/bin;LD_LIBRARY_PATH=${CMAKE_BINARY_DIR}/bin:$ENV{LD_LIBRARY_PATH}"
TIMEOUT 180
)
endif()

If your module has its own runtime and client libraries, link those as well:

target_link_libraries(${TEST_TARGET}
my_module_runtime
my_module_client
clio_admin_client
ctp::cxx
${CMAKE_THREAD_LIBS_INIT}
)

Building and Running Tests

# Build
cd /workspace/build
cmake ..
make -j$(nproc)
sudo make install # Required — tests use rpath-based linking

# Run all tests in a binary
./bin/my_module_tests

# Run tests matching a tag filter
./bin/my_module_tests "[compose]"

# Run tests matching a name substring
./bin/my_module_tests "Parse compose"

# Run via CTest
ctest -R my_module

Best Practices

  1. Initialize once: Use a static g_initialized flag in a fixture to avoid redundant CLIO_INIT calls. The init function has an internal static guard — calling it twice returns true immediately, but the fixture pattern keeps it explicit.

  2. Use task.Wait(): Always call Wait() on async futures before accessing results. There is no need for manual polling loops.

  3. Check return_code_: After Wait(), check task->return_code_ == 0 (or task->GetReturnCode() == 0) to verify success.

  4. Sleep after init: Add std::this_thread::sleep_for(500ms) after CLIO_INIT to let worker threads start before submitting tasks.

  5. Finalize cleanly: Set SimpleTest::g_test_finalize = clio::run::CLIO_RUNTIME_FINALIZE so the runtime shuts down before static destructors run, and end main() with SIMPLE_TEST_PROCESS_EXIT(result).

  6. Clean up shared memory between runs: If a previous test crashed, stale shared memory segments can block the next run:

    rm -f /dev/shm/clio_*
  7. Kill stale processes on port conflicts: Tests bind to port 9413. If a previous run left a zombie:

    sudo kill -9 $(sudo lsof -t -i :9413) 2>/dev/null