Tutorial 99: Unit Testing CNA Game Logic
What you’ll learn
- Separating pure game logic from rendering so it can be tested at all.
- Wiring GoogleTest into your project alongside CNA's own suite.
- Initialising CNA headlessly for tests, and mocking disposable resources.
- Running the tests in CI.
- How CNA's own suite is organised: the aggregate
CnaTests, the focused per-module targets, and where GPU tests open their windows.
Before you start — Tutorial 02: Setting Up Your Dev Environment (you already ran ctest against CNA's suite there) and Tutorial 71: Memory Management in C++ (the IDisposable pattern being mocked). The HEADLESS renderer exists for exactly this case.
CNA's own test suite
At CNA snapshot c1c316b9 (a development snapshot after alpha.1; alpha.1 counted 568 files and 8,263 definitions with the same commands), CNA ships 813 C++ test source files, 781 of which contain 11,380 statically discoverable GoogleTest-family definitions — covering math types
(Vector2, Vector3, Matrix, Quaternion), geometry (BoundingBox, BoundingFrustum, Plane, Ray), curves
(Bezier, Hermite), game loop semantics (fixed versus variable timestep), PackedVector precision and
colour conversions, the build-time content pipeline, platforms, audio and much else. A further 781 standalone *_test.cpp pixel programs under examples/ have their own main() and are not in those figures.
The exact executables and CTest registrations are configuration-scoped: renderer, platform, audio
implementation, host and feature options determine what is compiled. A multi-renderer build can include
several renderer test sets, so do not treat a source-tree count as the expected output of one ctest -N.
Test sources live beside the code they test, in modules/<name>/tests/ and modules/renderers/<family>/tests/, and CMake collects them by module. Besides the aggregate CnaTests, each module builds its own focused executable — CnaMathTests, CnaCoreTests, CnaAudioTests, CnaContentTests, CnaGraphicsTests, CnaStorageTests and so on, 22 in all. They are iteration targets (excluded from all, not extra CTest entries) so you can rebuild one module without linking everything; Tutorial 160 lists them and shows how to run them.
cmake --build build --target CnaTests # everything, one binary
ctest --test-dir build --output-on-failure
cmake --build build --target CnaMathTests # one module (run from the repository root)
./build/CnaMathTests --gtest_filter='Vector2Test.*'
These are counts of what exists, not a claim that they all pass. Nobody — this
page included — can tell you the pass rate for your configuration without running it. CNA's
general-tests-ci.yml runs the full default CTest registration on OPENGLES3 under Xvfb and then
distinguishes named known failures from new regressions; its allowlist has one entry at this snapshot
(EasyGL_GraphicsDevice_ReferenceStencil). The alpha.1 defect where its configure command
named a renderer CMake no longer accepts is fixed, and input-ci.yml now runs four valid rows (OPENGLES3,
ASan+UBSan on OPENGLES3, SDL_RENDERER and VULKAN). Whether those jobs are green was not verified, and
the GPU pixel/oracle matrix is not continuously gated: no workflow runs the XNA oracle corpus. Run your configuration and read its output.
Two CTest details worth knowing: labels follow the current renderer names, so ctest -L DIRECTX9
works and ctest -L D3D9 matches nothing; and cmake --build ... --target CNA no longer
works at all, because CNA is an INTERFACE library with no sources. CNA's own tests preset also notes that running the
CnaTests binary directly (with SDL_AUDIODRIVER=dummy) is more reliable than ctest, which starts one short-lived process per test case.
Testing pure logic separately from rendering
Keep game logic in classes that do not depend on GraphicsDevice. Test those classes
with GoogleTest independently. Only wire them to Game in the final game executable.
// GameLogic.hpp -- no CNA graphics dependency
class ScoreSystem {
public:
void AddScore(int points, int multiplier = 1) {
score_ += points * multiplier;
}
int Score() const { return score_; }
void Reset() { score_ = 0; }
private:
int score_ = 0;
};
GoogleTest integration
# CMakeLists.txt for tests
cmake_minimum_required(VERSION 3.20)
project(MyGameTests CXX)
set(CMAKE_CXX_STANDARD 23)
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
)
FetchContent_MakeAvailable(googletest)
# Game logic sources (no rendering)
add_library(GameLogic STATIC
src/ScoreSystem.cpp
src/PhysicsLogic.cpp
src/AIController.cpp
)
# Test executable
add_executable(MyGameTests
tests/ScoreSystemTest.cpp
tests/PhysicsLogicTest.cpp
tests/AIControllerTest.cpp
)
target_link_libraries(MyGameTests PRIVATE
GameLogic
GTest::gtest_main
)
include(GoogleTest)
gtest_discover_tests(MyGameTests)
Sample game logic test:
// tests/ScoreSystemTest.cpp
#include <gtest/gtest.h>
#include "ScoreSystem.hpp"
TEST(ScoreSystem, StartsAtZero) {
ScoreSystem s;
EXPECT_EQ(s.Score(), 0);
}
TEST(ScoreSystem, AddScore) {
ScoreSystem s;
s.AddScore(100);
EXPECT_EQ(s.Score(), 100);
}
TEST(ScoreSystem, Multiplier) {
ScoreSystem s;
s.AddScore(50, 3);
EXPECT_EQ(s.Score(), 150);
}
TEST(ScoreSystem, Reset) {
ScoreSystem s;
s.AddScore(999);
s.Reset();
EXPECT_EQ(s.Score(), 0);
}
// Test CNA math types directly (no GPU needed)
#include "Microsoft/Xna/Framework/Vector2.hpp"
using namespace Microsoft::Xna::Framework;
TEST(Vector2, Length) {
Vector2 v(3.0f, 4.0f);
EXPECT_NEAR(v.Length(), 5.0f, 1e-5f);
}
TEST(Vector2, Normalize) {
Vector2 v(3.0f, 4.0f);
v.Normalize();
EXPECT_NEAR(v.Length(), 1.0f, 1e-5f);
}
TEST(Vector2, Lerp) {
Vector2 a(0.0f, 0.0f), b(10.0f, 20.0f);
Vector2 r = Vector2::Lerp(a, b, 0.5f);
EXPECT_NEAR(r.X, 5.0f, 1e-5f);
EXPECT_NEAR(r.Y, 10.0f, 1e-5f);
}
Headless CNA init for testing
Some tests need CNA initialized but no visible window. The best answer is a build that never creates one
(the HEADLESS renderer, below). If you do test against a GPU renderer, give the tests a display that is not your
desktop. For your own test binaries, SDL3's offscreen video driver is one option, provided your
renderer can create its context on it:
# Your own tests, headless (no display required); verify that your renderer accepts this driver
SDL_VIDEODRIVER=offscreen ctest --test-dir build --output-on-failure
# CNA's own window-creating renderer tests pin SDL_VIDEODRIVER=x11 themselves, so give them a virtual display
xvfb-run -a ctest --test-dir build --output-on-failure
That second form works because CNA no longer forces a display onto its tests: CNA_TEST_DISPLAY is empty by
default, so window-creating tests inherit the caller's DISPLAY. Naming your live desktop (:0) is
opt-in and needs -DCNA_TEST_ALLOW_LIVE_DISPLAY=ON; any other value, such as an Xvfb on :99, is
honoured; and no test falls back to your Wayland compositor. Tutorial 160
covers the private-compositor runner and the display policy in full.
// In a test fixture that needs GraphicsDevice:
class GraphicsTest : public ::testing::Test {
protected:
void SetUp() override {
// Skip GPU tests on CI boxes that provide no display at all.
if (!getenv("DISPLAY") && !getenv("WAYLAND_DISPLAY")) {
GTEST_SKIP() << "No display available";
}
}
};
Rather than skipping GPU tests, consider configuring a second build with
-DCNA_GRAPHICS_RENDERER=HEADLESS. That renderer implements the whole
IGraphicsRenderer contract without a GPU or a window, and still validates arguments and
tracks resource lifetimes — so it catches misuse instead of silently swallowing it. It produces no
pixels; if you need real pixels without a GPU, SOFTWARE is a genuine CPU rasteriser you read
back with GetBackBufferData() (which needs the HiDef graphics profile; see
Tutorial 125). See Tutorial 72.
Mocking IDisposable resources
// Mock texture that tracks whether Dispose was called
class MockTexture2D {
public:
bool disposed = false;
int width = 64;
int height = 64;
void Dispose() { disposed = true; }
};
TEST(ContentCache, DisposesOnEvict) {
ContentCache<MockTexture2D> cache(/*maxSize=*/2);
auto* t1 = cache.Load("a");
auto* t2 = cache.Load("b");
auto* t3 = cache.Load("c"); // should evict t1
EXPECT_TRUE(t1->disposed);
EXPECT_FALSE(t2->disposed);
EXPECT_FALSE(t3->disposed);
}
CI integration
A sketch for your game repository; adapt the paths. It assumes your top-level CMakeLists.txt pulls CNA in with
add_subdirectory and that CNA, sharp-runtime, easy-gl and meta-gl are checked out as siblings.
At this snapshot both CNA and sharp-runtime must be on their apple/m4-stabilization branch; the default branches hold alpha.1-era code.
# .github/workflows/tests.yml
name: CNA Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with: { submodules: true } # non-recursive is correct, and much faster
- name: Install dependencies
run: |
sudo apt-get install -y cmake ninja-build g++-14 xvfb libgl1-mesa-dev
# Video (FFmpeg) is OPTIONAL: install libavcodec-dev libavformat-dev libavutil-dev
# libswresample-dev only if your game plays video (CNA_ENABLE_VIDEO=AUTO picks it up).
# The default Linux renderer is OPENGLES3, which needs the easy-gl and meta-gl siblings;
# sharp-runtime is always required; sharp-runtime and meta-gl must be on their
# apple/m4-stabilization branch (easy-gl on develop).
- name: Configure
run: cmake -S . -B build -G Ninja -DCNA_GRAPHICS_RENDERER=OPENGLES3 -DCMAKE_BUILD_TYPE=Debug
- name: Build
run: cmake --build build --target CnaTests MyGameTests
- name: Run CNA tests
# Window-creating tests inherit DISPLAY (CNA_TEST_DISPLAY is empty by default)
run: xvfb-run -a ctest --test-dir build --output-on-failure
env: { SDL_AUDIODRIVER: dummy }
- name: Run game tests
run: ./build/MyGameTests
For CI that must not depend on a display at all, configure a second build with -DCNA_GRAPHICS_RENDERER=HEADLESS
and run only your logic tests there. To see what a large CNA job runs and how CNA's own workflows are split, see
Verification & Known Issues.