VoltMod
C++23 framework for CS2 server plugins
Loading...
Searching...
No Matches
Testing

Unit tests use doctest and stay SDK-free: no HL2SDK, no live Runtime, entity or database connection. Test logic that takes plain values and returns plain values - parsers, the target-selector grammar, angle math, decaying scores, throttles, detector heuristics. Keep that logic in free functions over structs and the rest follows.

Running

uv run poe test # build, then ctest
uv run poe test -R SteamId # only matching case names
ctest --preset windows-msvc-release --output-on-failure
ctest --preset windows-msvc-release -N # list without running

Each test case is its own CTest entry, so a CI report names the failing case. The presets set noTestsAction: error, so a run that discovers nothing fails the job instead of passing.

Run the binary under build/<preset>/ directly for doctest's own filters:

voltmod-tests --list-test-cases
voltmod-tests --test-case="SteamId::*"
voltmod-tests --source-file="*Targeting*"
voltmod-tests --success # print passing asserts too

conan create excludes tests/, so a package build compiles no tests. CI builds the source checkout separately:

- run: voltmod build -p linux-steamrt-release --no-lockfile
- run: voltmod test -p linux-steamrt-release

--no-lockfile resolves without conan.lock, which CI needs because it builds against SDK packages it just created from the HEAD recipes.

Writing a case

Put cases in tests/<Module>/*.cpp. voltmod_add_tests() supplies main and picks up new files.

#include <doctest/doctest.h>
TEST_CASE("ParseDuration: suffixes")
{
CHECK_EQ(ParseDuration("30"), 30);
CHECK_EQ(ParseDuration("5m"), 300);
CHECK_EQ(ParseDuration("perm"), 0); // permanent
CHECK_EQ(ParseDuration("nope"), -1); // unparseable
}
int ParseDuration(std::string_view text)
Definition Strings.cpp:35

CHECK* records a failure and continues; REQUIRE* stops the case, so use it before dereferencing or indexing a value under test:

auto snap = Detectors::AimSnap::FindSettledSnap(window, cfg);
REQUIRE(snap.has_value()); // stop here rather than crash on snap->Ago below
CHECK_EQ(snap->Ago, 1);

Compare directly. CHECK_EQ and friends print both operands; wrapping the comparison in a predicate loses that:

ParseDurationTests.cpp(9): ERROR: CHECK_EQ( ParseDuration("5m"), 300 ) is NOT correct!
values: CHECK_EQ( 5, 300 )

doctest::Approx's tolerance is relative: |a - b| < epsilon * (scale + max(|a|, |b|)), so Approx(180.0f).epsilon(0.01) accepts a 1.81 gap. Where the test means an absolute tolerance (degrees, score units), write a local Near(a, b, eps) helper; the angle and decaying-score suites do. For throws use CHECK_THROWS_AS, CHECK_THROWS_WITH or CHECK_NOTHROW.

Each SUBCASE re-runs the enclosing body from the top, so setup is written once and every branch gets a fresh copy with no fixture class:

TEST_CASE("FilterPlayers: team selectors")
{
auto players = Players(); // rebuilt for every SUBCASE below
SUBCASE("@ct matches both CTs")
{
auto r = FilterPlayers(players, ParseTargetToken("@ct"), {.AllowMultiple = true}, Caller);
CHECK_EQ(Size(r), std::size_t{2});
}
SUBCASE("immunity narrows instead of failing")
{
players[1].Targetable = false;
auto r = FilterPlayers(players, ParseTargetToken("@ct"), {.AllowMultiple = true}, Caller);
CHECK_EQ(FrontSlot(r), 2);
}
}
TargetQuery ParseTargetToken(std::string_view token)
Definition Targeting.cpp:11
std::expected< std::vector< int >, TargetFailure > FilterPlayers(std::span< const PlayerView > players, const TargetQuery &query, const TargetRules &rules, int callerSlot, const std::function< std::size_t(std::size_t)> &randomIndex)

TEST_CASE_TEMPLATE instantiates the body once per type, reporting each as its own case:

TEST_CASE_TEMPLATE("Trim accepts any string-like input", T, const char*, std::string)
{
CHECK_EQ(Strings::Trim(T{" hi "}), std::string("hi"));
}

Case names cannot contain <tt>[</tt>, <tt>]</tt> or <tt>;</tt>

Discovery runs the freshly built binary with --list-test-cases and parses the output as a CMake list, where [...] groups and ; separates. An unmatched bracket folds every following case into one entry and fails configure with the unrelated-looking add_test called with incorrect number of arguments; a semicolon silently splits one case into two bogus entries. voltmod_add_tests() scans the sources and fails configure naming the offending file instead.

Spell interval bounds out - wraps to -180 exclusive through 180 inclusive, not wraps into (-180, 180]. Parentheses, commas, colons, <, > and :: are fine.

Adding tests to a plugin

voltmod_add_tests(myplugin-tests
SOURCES
src/Detectors/AimSnapCore.cpp
DEFINITIONS
MYPLUGIN_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/data"
)

voltmod_add_tests(<name> [DATABASE] [SOURCES ...] [DEFINITIONS ...]) comes from cmake/VoltModTests.cmake, a build module of the Conan package. It globs tests/**/*.cpp (excluding tests/Api/), supplies doctest's main, links doctest::doctest and VoltMod::Portable (the framework code that builds without the game SDK), adds the plugin's src/ and tests/ to the include path, and registers the cases with CTest. It is a no-op when BUILD_TESTING is off.

Argument Means
SOURCES the plugin's SDK-free translation units to compile beside the test cases
DATABASE also link VoltMod::Database, so a test can open a SQLite database and run the plugin's migrations
DEFINITIONS compile definitions for the test target

Test binaries never link the plugin module or VoltMod::Sdk, so nothing drags in the game SDK. The Conan side is one line in conanfile.py:

def build_requirements(self):
self.test_requires("doctest/2.5.2")

Api surface checks

Each Api.hpp aggregate must compile as the only VoltMod include in a translation unit, and RootApiSurfaceTest.cpp checks that the main umbrella pulls in neither the JSON layer nor the menu-building surface. These are compile-only, and they need the full HL2SDK build, so they live in tests/Api/ and compile into voltmod-api-surface-check - an object library in the root CMakeLists.txt linked against VoltMod::Sdk and VoltMod::Database.

Module layering and source conventions

voltmod lint # the framework's module layering, or a consumer's plugins/

In the framework checkout lint checks the module dependencies and the framework's source conventions. In a consumer repo it checks the source conventions under plugins/. -C <dir> runs it on another repo.