1. Install the required NuGet packages
External Godot tests require all three packages: Gua.Core, Gua.Testing, and Gua.Testing.Godot. Run these commands in the test project directory to resolve the latest compatible stable packages, then commit the versions written to the project.
dotnet add package Gua.Core
dotnet add package Gua.Testing
dotnet add package Gua.Testing.Godot
# When using NUnit
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package NUnit
dotnet add package NUnit3TestAdapterAlign the game integration and related Gua package versions. See version management for recording and pinning the result.
What each command adds
| Code | Meaning |
|---|---|
dotnet add package Gua.Core | Adds shared Gua types and the native runtime boundary. |
dotnet add package Gua.Testing | Adds semantic locators, actions, waits, assertions, and diagnostics. |
dotnet add package Gua.Testing.Godot | Adds the process host that starts Godot and connects to its bridge. |
dotnet add package Microsoft.NET.Test.Sdk | Lets dotnet test discover and run a test framework. |
dotnet add package NUnit | Adds NUnit tests, fixtures, and assertions. |
dotnet add package NUnit3TestAdapter | Connects NUnit discovery to Microsoft.NET.Test.Sdk. |
If Godot is not on PATH, set its executable explicitly.
$env:GODOT_EXECUTABLE = "C:\path\to\Godot_v4.7-stable_mono_win64_console.exe"
dotnet test YourGame.Tests.csprojWhat the run commands mean
| Code | Meaning |
|---|---|
$env:GODOT_EXECUTABLE = "...console.exe" | Selects the Godot executable inherited by the test process and its child. |
dotnet test YourGame.Tests.csproj | Builds the named test project and runs its discovered NUnit tests. |
2. Launch and operate a scene semantically
using Gua.Testing;
using Gua.Testing.Godot;
using var host = GodotSceneTestHost.Load(
"res://Main.tscn",
new GodotSceneTestHostOptions {
ProjectPath = projectPath,
UseAvailableBridgePort = true,
StartupResetPolicy = GuaResetPolicy.Strict,
TeardownResetPolicy = GuaResetPolicy.Strict,
});
GuaAssertions.GetByRole(
host.Context, "button", "Start Game"
).ToBeVisible();
await GuaAssertions.GetById(
host.Context, "start"
).ClickAsync();
await GuaAssertions.WaitForTextAsync(
host.Context, "loading", "Loading..."
);Read the test in execution order
| Code | Meaning |
|---|---|
using Gua.Testing; | Imports locators, actions, waits, and assertions. |
using Gua.Testing.Godot; | Imports the Godot process host and its options. |
using var host = GodotSceneTestHost.Load(...) | Starts Main.tscn in a child Godot process and waits for the bridge; using disposes both. |
"res://Main.tscn" | Selects the scene resource inside the Godot project. |
ProjectPath = projectPath | Points the host at the directory containing project.godot. |
UseAvailableBridgePort = true | Selects a loopback port instead of sharing fixed port 8765. |
StartupResetPolicy = GuaResetPolicy.Strict | Fails rather than silently deleting dirty state at startup. |
TeardownResetPolicy = GuaResetPolicy.Strict | Reports leaked requests or events at the end of the test. |
GetByRole(..., "button", "Start Game") | Finds a button by semantic role and accessible name, not coordinates. |
.ToBeVisible() | Asserts that the matched node is visible in the current snapshot. |
GetById(..., "start").ClickAsync() | Sends a click and waits for the request-correlated host completion. |
WaitForTextAsync(..., "loading", "Loading...") | Refreshes snapshots until node loading exposes the expected text. |
3. Operate forms
var name = GuaAssertions.Query(host.Context)
.ByRole("textbox").Within("LoginForm").Get();
await name.SetValueAsync("alice");
await name.FocusAsync();
await GuaAssertions.PressKeyAsync(host.Context, "Enter");
await GuaAssertions.GetById(host.Context, "RememberMe")
.SetCheckedAsync(true);
await name.WaitForValueAsync("alice");What each form operation does
| Code | Meaning |
|---|---|
GuaAssertions.Query(host.Context) | Starts a semantic query against the connected UI tree. |
.ByRole("textbox") | Limits candidates to textbox nodes. |
.Within("LoginForm") | Searches only descendants of node LoginForm. |
.Get() | Requires exactly one match and reports zero or ambiguous results. |
name.SetValueAsync("alice") | Requests a value change and waits for host completion. |
name.FocusAsync() | Focuses the textbox so a global key action has a target. |
PressKeyAsync(host.Context, "Enter") | Sends an Enter key-down/key-up gesture to the focused Control. |
GetById(..., "RememberMe").SetCheckedAsync(true) | Finds the checkbox by stable ID and checks it. |
name.WaitForValueAsync("alice") | Observes the published value instead of treating action completion as state completion. |
Four rules for reliable tests
Set UseAvailableBridgePort = true to avoid collisions in parallel runs.
Detect leaked requests and events at startup and teardown.
Wait for nodes, values, or stable snapshots instead of sleeping for a fixed time.
Capture the UI tree, logs, stdout/stderr, and optional PNG evidence.
1. Reserve a port per test
If every test uses 8765, only one Godot process can bind it during a parallel run. Let the host reserve a loopback port and pass it to the child as GUA_BRIDGE_PORT.
using var host = GodotSceneTestHost.Load(
"res://Main.tscn",
new GodotSceneTestHostOptions
{
ProjectPath = projectPath,
UseAvailableBridgePort = true,
});
// The reserved URL is assigned to the child and RemoteContext.What the port options do
| Code | Meaning |
|---|---|
GodotSceneTestHost.Load("res://Main.tscn", ...) | Starts the scene and returns a host connected to its Gua bridge. |
ProjectPath = projectPath | Selects the Godot project used to resolve the scene. |
UseAvailableBridgePort = true | Asks the OS for an available port and passes it as GUA_BRIDGE_PORT. |
using var host | Closes the connection and child process when the scope ends. |
2. Reset strictly before and after the test
Do not carry nodes, requests, or events from one test into the next. A strict teardown reports unconsumed work instead of silently deleting it.
using var host = GodotSceneTestHost.Load(
"res://Main.tscn",
new GodotSceneTestHostOptions
{
ProjectPath = projectPath,
UseAvailableBridgePort = true,
StartupResetPolicy = GuaResetPolicy.Strict,
TeardownResetPolicy = GuaResetPolicy.Strict,
CaptureDiagnosticsBeforeTeardown = true,
CleanupAfterLeakReport = true,
});What strict isolation changes
| Code | Meaning |
|---|---|
StartupResetPolicy = GuaResetPolicy.Strict | Rejects dirty selected queues before the test starts. |
TeardownResetPolicy = GuaResetPolicy.Strict | Reports unconsumed work as a test leak at teardown. |
CaptureDiagnosticsBeforeTeardown = true | Captures evidence before the process is destroyed when teardown fails. |
CleanupAfterLeakReport = true | Performs cleanup only after preserving the leak report. |
3. Wait for observed state, not elapsed time
Task.Delay(1000) wastes time on a fast machine and may still be too short on a slow one. Wait directly for the state that should follow the action.
await GuaAssertions.GetById(host.Context, "start")
.ClickAsync();
// Re-fetch snapshots until the node is visible.
await GuaAssertions.WaitForVisibleAsync(
host.Context,
"loading",
timeout: TimeSpan.FromSeconds(3),
pollInterval: TimeSpan.FromMilliseconds(20));
// Optionally require three unchanged rendered frames.
await GuaAssertions.WaitForStableSnapshotAsync(
host.Context,
stableFrames: 3);What the waits guarantee
| Code | Meaning |
|---|---|
ClickAsync() | Waits for the correlated click result, not the full screen transition. |
WaitForVisibleAsync(..., "loading", ...) | Polls fresh snapshots until loading is visible. |
timeout: TimeSpan.FromSeconds(3) | Fails if the condition is still false after three seconds. |
pollInterval: TimeSpan.FromMilliseconds(20) | Controls how often the state is checked. |
WaitForStableSnapshotAsync(..., stableFrames: 3) | Waits for three consecutive unchanged semantic frames. |
4. Capture diagnostics when an assertion fails
Write the final UI tree, operation history, logs, and Godot stdout/stderr to artifacts/gua. In CI, upload that directory as a test artifact.
using var diagnostics = host.CreateDiagnosticsSession(
TestContext.CurrentContext.Test.FullName,
outputDirectory: Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"artifacts", "gua"));
using var assertionScope = GuaAssertionScope.Use(
new GuaAssertionOptions
{
DiagnosticsSession = diagnostics,
});
// A failure preserves the primary exception and writes diagnostics.
GuaAssertions.GetByRole(
host.Context, "button", "Start Game"
).ToBeVisible();What the diagnostics setup does
| Code | Meaning |
|---|---|
host.CreateDiagnosticsSession(...) | Creates an evidence collector tied to this Godot host. |
TestContext.CurrentContext.Test.FullName | Uses the full NUnit test name to identify the artifact set. |
Path.Combine(..., "artifacts", "gua") | Builds a portable output path. |
GuaAssertionScope.Use(...) | Associates assertion failures in this scope with the diagnostics session. |
DiagnosticsSession = diagnostics | Selects the UI tree, history, logs, and process evidence collector. |
.ToBeVisible() | Keeps the assertion as the primary failure even if evidence capture also fails. |
With GitHub Actions, pass artifacts/gua/** to actions/upload-artifact after the test step so evidence remains available from a failed run.
Run tests with GitHub Actions
The example below installs the gua-v1.1.1 add-on. Use matching 1.1.1 NuGet packages in the test project. See CI setup and version selection.
The public gua-project/gua-tester/godot@v3.2 action installs the official Godot build matching its runner, links the selected released Gua add-on, sets GODOT_EXECUTABLE, and runs dotnet test.
name: Godot Gua UI tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Run Gua UI tests
uses: gua-project/gua-tester/godot@v3.2
with:
project-path: game
test-project: tests/GuaTester.Tests.csproj
godot-version: "4.7"
gua-plugin-tag: gua-v1.1.1
godot-status: stableWhat each workflow section controls
| Code | Meaning |
|---|---|
on: pull_request / push | Runs the workflow for pull requests and pushes to main. |
runs-on: windows-latest | Windows is shown here; ubuntu-22.04 and macOS runners select their official Godot archive automatically. |
actions/checkout@v4 | Checks out the game and test sources. |
uses: gua-project/gua-tester/godot@v3.2 | Installs Godot, links the released add-on, and runs dotnet test. |
project-path: game | Points to the directory containing project.godot. |
test-project: tests/GuaTester.Tests.csproj | Selects the .NET test project to run. |
godot-version / godot-status | Pins the runner to Godot 4.7 stable. |
Wait for actionable UI
Gua.Testing locators resolve a current strict single match when used. Actions wait for visible/enabled state and the advertised action, then correlated host completion. Multiple matches fail immediately. Resolution, enqueue, and completion share one timeout budget; rejected enqueue and host failure are not automatically retried.
using Gua.Testing;
var play = GuaAssertions.Query(host.Context).ByRole("button", "Play");
await play.ClickAsync(timeout: TimeSpan.FromSeconds(5));
await GuaAssertions.Query(host.Context).ById("game-screen").ResolveAsync();ResolveAsync waits for the next node to exist; add the state assertion your test requires. Locator API and timeout rules.
Compare semantic state without pixels
Add Gua.Testing.Snapshots to compare UI IDs, hierarchy, text, values, and state against reviewed JSON baselines. It works with host.Context, including a headless host. Add Gua.Testing.Visual separately when rendered pixels matter. Installation, masks, and baseline update example.
dotnet add package Gua.Testing.SnapshotsSave test evidence with Gua.Testing Trace
Trace belongs to Gua.Testing; no extra Trace package is needed. Enable it for a test scope, complete it with the test's actual outcome, and create an offline HTML report at a new output path.
using Gua.Testing;
await using var trace = new GuaTraceSession(new() {
OutputDirectory = "artifacts/traces",
SavePolicy = GuaTraceSavePolicy.Always,
});
using var scope = GuaAssertionScope.Use(new() { Trace = trace });
try {
await GuaAssertions.Query(host.Context).ById("play").ClickAsync();
await trace.CompleteAsync(GuaTraceOutcome.Passed);
} catch {
await trace.CompleteAsync(GuaTraceOutcome.Failed);
throw;
}
var report = GuaTraceReport.WriteHtml(trace.ArtifactPath, "artifacts/report.html");
// Check report.Succeeded and trace.Status separately from the test result.Trace records evidence; it does not replay the flow or change its result. Capture UI/World or Observe values explicitly when required, and handle sensitive data before recording. Trace capture APIs · Open a saved Trace in Inspector.