A C++ testing framework for Godot GDExtensions. You write googletest-style suites in plain C++. gdextest compiles them into a test-only shared object, loads it in a real (headless) Godot process through a generated fixture project, and reports pass/fail through the process exit code. No separate runner binary, no scripting layer, no changes to your release build.
Website: deadshrimpstudio.com/projects/gdextest
- Tests live in the engine. Integration tests reach singletons,
ClassDB, and the live scene tree directly. Pure-logic tests need no engine at all. - Multi-frame tests. C++20 coroutines suspend across engine frames:
co_await ctx.await_frames(2). Timeouts bound every wait, so a stalled test fails instead of hanging CI. - Signal monitoring. Watch any object's signals, count emissions, and inspect the arguments of every emission.
- Leak checks. Track Godot objects and
RefCountedreferences. The framework fails the test when they survive teardown. - Lifecycle hooks.
configure_hostcallbacks plus any number ofExtensionAdapters cover extension-specific startup and shutdown without touching the runner. - One command.
gdextest testbuilds, generates the fixture, launches Godot, and quits with0or1. It works on a fresh clone without touching yourSConstruct. - CI-ready output. JSON and JUnit XML, plus a clean GoogleTest-style console report with colored pass/fail lines — rendered by the CLI after the engine exits, so Godot's own output never mangles it. Filtering, stable-hash sharding, seeded shuffling, and shard merging are built in.
- Two host modes. Editor mode hooks a scan-safe
EditorPlugin; runtime mode uses a plain autoload for runtime-only extensions. - Zero release footprint. Everything compiles only when
GDEXTEST_ENABLEDis defined. Release builds never see it.
git submodule add <this-repo-url> extern/gdextest
git submodule update --init// tests/smoke.cpp
#include "gdextest/assert.h"
#include "gdextest/registry.h"
GDEX_TEST(smoke, framework_is_wired) {
GDEX_EXPECT(true);
}./extern/gdextest/gdextest testOne command does the whole flow. It writes a starter .gdextest.toml when none exists (it never overwrites one), runs the doctor preflight (config, Godot, SCons, godot-cpp version, test sources), builds the test library, generates and warms the fixture, launches Godot headless, prints the summary, and removes the disposable fixture afterwards. Exit codes: 0 all passed, 1 any failure or crash, 2 usage or environment error.
No SConstruct wiring required. When your build file does not call the framework SConscript, the build runs through a temporary injected copy that is deleted afterwards. gdextest scaffold --apply makes the wiring permanent (a .bak backup is written first) and generates testsupport/entry.cpp from your config values.
Prerequisites: SCons, a C++20 toolchain, Python 3, and a Godot 4.5 binary. Godot is auto-discovered (project tree, ancestor directories, $HOME, PATH).
A test body receives a TestContext& named ctx. Assertion macros find it by name:
GDEX_TEST(counter, bump_increments) {
Counter c;
c.bump();
GDEX_EXPECT_EQ(c.value(), 1);
}A failed expectation records a failure and continues, so one test reports every failing check. The macro set:
| Family | Macros |
|---|---|
| Boolean and comparison | GDEX_EXPECT, GDEX_EXPECT_TRUE/FALSE, GDEX_EXPECT_EQ/NE/LT/LE/GT/GE, GDEX_EXPECT_NEAR |
| Pointers | GDEX_EXPECT_NULL, GDEX_EXPECT_NOT_NULL |
Strings (std::string, const char*, godot::String mix freely) |
GDEX_EXPECT_STR_EQ/NE/CONTAINS/STARTS_WITH/ENDS_WITH/EMPTY/NOT_EMPTY |
| Exceptions | GDEX_EXPECT_THROW, GDEX_EXPECT_NO_THROW, GDEX_EXPECT_ANY_THROW |
| Fatal (stop the test) | GDEX_ASSERT_TRUE/FALSE/EQ/NE/NULL/NOT_NULL, GDEX_ABORT_TEST |
| Flow control | GDEX_FAIL, GDEX_FAIL_IF, GDEX_FAIL_UNLESS, GDEX_SKIP |
Tags (TAG_UNIT, TAG_INTEGRATION, TAG_ASYNC, TAG_SLOW, TAG_FLAKY) mark test categories. TAG_FLAKY tests get up to flaky_retries extra attempts (default 3). Cleanup callbacks (ctx.add_teardown), skip handling, and leak tracking are covered in Writing tests.
GDEX_TEST_ASYNC(async, engine_frames_advance) {
godot::Engine *engine = godot::Engine::get_singleton();
const int64_t before = static_cast<int64_t>(engine->get_process_frames());
co_await ctx.await_frames(2); // suspend across 2 process frames
GDEX_EXPECT_GE(static_cast<int64_t>(engine->get_process_frames()) - before, 2);
}The body is a C++20 coroutine and must end with co_return;. The runner pumps SceneTree.process_frame and resumes the body when the wait resolves. Every wait carries a timeout (default 30 s per wait, 60 s per test), so a test that never resolves is red, never a hang. See Async tests.
Tag a test TAG_INTEGRATION and include gdextest/engine.h to reach the live engine:
GDEX_TEST_T(engine, can_build_scene_graph, TAG_INTEGRATION) {
godot::SceneTree *tree = gdextest::engine_tree(ctx); // live engine tree
GDEX_EXPECT_NOT_NULL(static_cast<void *>(tree));
godot::Node *child = memnew(godot::Node);
ctx.track_object(child);
tree->get_root()->add_child(child);
tree->get_root()->remove_child(child);
memdelete(child);
}Both accessors return null outside engine-triggered runs, so guard before use. For signal behavior, ctx.signals() returns a per-test SignalMonitor that counts emissions and records their arguments. See Engine integration.
Two complementary ways:
-
Compile it into the test build. Add your implementation files to
[gdextest.tests] sources. Fast and hermetic. The CLI runs anldd -rpreflight (Linux) after the build, so code that is missing from the test build fails fast with the symbol list instead of a Godot load-time crash. -
Load the real extension in the fixture. Point at your built library with
[gdextest.consumer_extension]when a suite needs registered classes, editor plugins, or behavior only the real.soexhibits:[gdextest.consumer_extension] library = "addons/my_extension/bin/libmy_extension.linux.editor.x86_64.so"
Only one of
library/manifestneeds configuring; the other is derived automatically. The fixture stages the extension underaddons/consumer/and rewrites the manifest's library paths, so a real addon manifest works as-is.
./extern/gdextest/gdextest test --json=results.json --junit=results.xmlThe exit code gates the pipeline. JUnit XML renders as inline annotations on GitHub Actions and other CI surfaces. gdextest init --ci writes the workflow for you. For parallel runs:
./extern/gdextest/gdextest test --shard=0/4 --json=shard0.json
./extern/gdextest/gdextest report 'shard*.json' --json=merged.json --junit=merged.xmlShard assignment is a stable hash, so the same test always lands in the same shard and shards stay disjoint. report exits 1 when any merged test failed, so the merge step gates too.
| Command | Effect |
|---|---|
gdextest test |
The one-command flow: config init + doctor + build + fixture + headless run |
gdextest init [--ci] [--force] |
Create the starter .gdextest.toml (and CI workflow) |
gdextest doctor |
Environment checks without building |
gdextest scaffold [--apply] |
Wire SConstruct permanently + generate entry/smoke suite |
gdextest list |
Build and list the selected tests (needs a run trigger) |
gdextest report <paths…> |
Merge shard result JSON into one JSON/JUnit report |
gdextest clean |
Remove generated test output |
Runner flags (--gdextest-run, --gdextest-filter=, --gdextest-shard=k/n, --gdextest-shuffle[=seed], --gdextest-json=, and the budget flags) are forwarded by test. See the CLI reference for every command, flag, and exit code.
The full documentation lives in docs/:
| Page | Contents |
|---|---|
| Getting started | Install, first suite, first run, CI |
| Writing tests | Assertions, tags, teardowns, skipping, resource tracking |
| Async tests | Multi-frame tests, waits, timeouts, budgets |
| Engine integration | Live-engine tests and SignalMonitor |
| Architecture | Design, layering rules, adapters, run lifecycle |
| CLI reference | Commands, flags, exit codes, output formats |
| Configuration | The full .gdextest.toml reference |
| Consumer guide | End-to-end integration, adapters, custom fixtures |
| API reference | The complete public API, header by header |
| Troubleshooting | Known failure modes and their fixes |
- Suites register in a pure C++ registry at static-initialization time. The core never touches Godot types there.
- Only a small engine boundary touches godot-cpp: the runner,
engine.h,strings.h,signals.h, and the entry/adapter sources. - The fixture loads the test library in editor mode (
--headless --editor). The plugin waits for the editor's first filesystem scan, then the adapter checks the trigger (--gdextest-runorGDX_RUN_TESTS) and starts the run. - The runner writes the JSON results document before anything else, then exits through
SceneTree::quit(code)—0pass,1failure,2usage error. Its own console lines are marker-prefixed (GDX_TEST_OUTPUT:) and the CLI renders the human report from the JSON after the engine exits, so Godot's banner and load chatter never interleave with test results. user://stays hermetic: the CLI wipesbuild/gdextest/user-databefore every run and pointsXDG_DATA_HOMEat it. The fixture project is disposable and removed after every run.
| Path | Role |
|---|---|
include/gdextest/ |
Public headers: registry, assertions, TestContext, async, signals, runner |
src/framework/ |
Framework implementation (registry.cpp, runner.cpp, host.cpp, signals.cpp) |
src/gdextest_entry.cpp |
GDExtension entry + EditorPlugin shell (test build only) |
src/support/adapter.cpp |
Reference adapter: trigger detection, adapter dispatch, run start |
tests/ |
Framework self-tests + reference suites |
tools/ |
CLI (gdextest.py), config loading, fixture generator |
run_tests.sh |
Build + run this repository's own suite, one command |
docs/ |
The documentation wiki — start at docs/index.md |
- Godot 4.5 (the framework uses 4.5-only GDExtension APIs).
- SCons and a C++20 toolchain (coroutines are required for async tests).
- Python 3 for the CLI.
See LICENSE.