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:
CLIO_SERVER_CONFenvironment variable~/.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
-
Initialize once: Use a static
g_initializedflag in a fixture to avoid redundantCLIO_INITcalls. The init function has an internal static guard — calling it twice returnstrueimmediately, but the fixture pattern keeps it explicit. -
Use
task.Wait(): Always callWait()on async futures before accessing results. There is no need for manual polling loops. -
Check
return_code_: AfterWait(), checktask->return_code_ == 0(ortask->GetReturnCode() == 0) to verify success. -
Sleep after init: Add
std::this_thread::sleep_for(500ms)afterCLIO_INITto let worker threads start before submitting tasks. -
Finalize cleanly: Set
SimpleTest::g_test_finalize = clio::run::CLIO_RUNTIME_FINALIZEso the runtime shuts down before static destructors run, and endmain()withSIMPLE_TEST_PROCESS_EXIT(result). -
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_* -
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