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.
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 alpha.1, CNA ships 568 C++ test source files containing 8,263 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, among much else.
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.
cmake --build build --target CnaTests
ctest --test-dir build --output-on-failure
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. Alpha.1's
general-tests-ci.yml is intended to run the full default CTest registration and then distinguish
four named known failures from new regressions. At this tag, however, its configure command still selects the
removed EASYGL identity, so it fails before that suite runs. Two EasyGL rows in input-ci.yml
have the same defect; the valid SDL_RENDERER, Vulkan and bgfx rows are separate evidence. The GPU pixel/oracle
matrix is not continuously gated either. 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.
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 window. Set the environment variable
SDL_VIDEODRIVER=offscreen before running tests to prevent SDL3 from opening a
real window.
# Run tests headless (no display required)
SDL_VIDEODRIVER=offscreen ctest --test-dir build --output-on-failure
// In a test fixture that needs GraphicsDevice:
class GraphicsTest : public ::testing::Test {
protected:
void SetUp() override {
// CNA headless: SDL_VIDEODRIVER=offscreen must be set
// or skip GPU tests on CI
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(). 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
# .github/workflows/tests.yml
name: CNA Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
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 g++-14 libgl1-mesa-dev \
libavcodec-dev libavformat-dev libavutil-dev libswresample-dev
# FFmpeg dev packages are REQUIRED on Linux — configure fails without them.
# A default Linux build also needs the sharp-runtime, easy-gl and meta-gl
# sibling checkouts beside cna/, because the Linux default renderer is OPENGLES3.
- name: Configure
run: cmake -S . -B build -DCNA_GRAPHICS_RENDERER=OPENGLES3 -DCMAKE_BUILD_TYPE=Debug
- name: Build
run: cmake --build build --target CnaTests MyGameTests -j4
- name: Run CNA tests
run: ctest --test-dir build --output-on-failure
env: { SDL_VIDEODRIVER: offscreen }
- name: Run game tests
run: ./build/MyGameTests