Testing Patterns and Best Practices¶
This guide covers patterns, strategies, and tips for writing reliable E2E tests with godot-e2e.
Fixture Strategies¶
godot-e2e supports three test isolation strategies, each with different tradeoffs between speed and isolation.
Strategy 1: Scene Reload (default, recommended)¶
One Godot process per test module. The scene is reloaded before each test.
@pytest.fixture(scope="module")
def _game_process():
with GodotE2E.launch(PROJECT_PATH) as game:
game.wait_for_node("/root/Main", timeout=10.0)
yield game
@pytest.fixture(scope="function")
def game(_game_process):
_game_process.reload_scene()
_game_process.wait_for_node("/root/Main", timeout=5.0)
yield _game_process
When to use: Most tests. Scene reload resets the scene tree, node properties, and script variables. This is fast (no process startup overhead) and provides good isolation.
Limitations: Global state that lives outside the scene (singletons, autoloads, static variables) persists between tests. If your game uses global state, use change_scene back to a known scene or use game_fresh.
Strategy 2: Fresh Process (maximum isolation)¶
A new Godot process for every test function.
@pytest.fixture(scope="function")
def game_fresh():
with GodotE2E.launch(PROJECT_PATH) as game:
game.wait_for_node("/root/Main", timeout=10.0)
yield game
When to use: Tests that modify global state (autoload properties, singletons), tests that need a completely clean slate, or tests that verify crash recovery.
Limitations: Slow. Each test pays the cost of launching Godot and establishing a connection (~2-5 seconds). Use sparingly.
Strategy 3: Shared Session (fastest)¶
One Godot process for the entire test session.
@pytest.fixture(scope="session")
def game_session():
with GodotE2E.launch(PROJECT_PATH) as game:
game.wait_for_node("/root/Main", timeout=10.0)
yield game
When to use: Read-only tests that do not mutate game state, or when you need the absolute fastest execution and are willing to manage test ordering manually.
Limitations: No automatic reset between tests. Tests must clean up after themselves or be carefully ordered. A crash in one test terminates the session.
Locator-First Authoring¶
Recommendation: prefer Locator over absolute scene paths. Locators are lazy references that re-resolve on every action, so they survive scene reloads and minor refactors that would break a hand-written /root/Menu/VBox/ClickButton.
Common queries¶
game.locator(name="StartButton") # by node name
game.locator(text="Save") # by Control.text
game.locator(group="enemies") # by group
game.locator(type="BaseButton") # all buttons (incl. CheckBox, etc.)
game.locator(script="res://player.gd") # by attached script
game.locator(name="*Boss*") # glob (when value contains * or ?)
Sugar for the two most common cases:
Composition¶
Multiple kwargs are AND-composed; filter() adds more predicates. Use whichever reads better.
# Equivalent
game.locator(type="Button", text="Save")
game.locator(type="Button").filter(text="Save")
Chaining for sub-tree scope¶
The parent must resolve to exactly one node — otherwise MultipleMatchesError. Use .first() / .nth(i) / .filter(...) to disambiguate.
Multi-match safety¶
A query that matches more than one node raises MultipleMatchesError by default. This is deliberate: silently picking the first match is the most common cause of post-refactor flakiness.
game.locator(type="Button").click() # raises if > 1 match
# All explicit:
game.locator(type="Button").first().click()
game.locator(type="Button").nth(2).click()
for btn in game.locator(type="Button").all():
btn.click()
Auto-wait on click¶
Locator.click() polls actionability before clicking (Control targets only):
is_visible_in_tree()mouse_filter != MOUSE_FILTER_IGNORE- the node's global rect intersects the viewport
So this works without explicit wait_* boilerplate even when the button is being animated in:
Pass force=True when you want to skip the check (e.g. the target is intentionally off-screen).
After scene reload¶
button = game.get_by_button("Click Me")
button.click()
game.reload_scene()
button.click() # still works — query re-runs against fresh tree
When to fall back to the path-based API¶
Locator is the recommended default, but the existing path-based API (game.click_node(path), game.get_property(path, prop), ...) is unchanged and still appropriate when:
- You already know the exact path and want zero overhead.
- You need an unsupported query (e.g. by viewport position).
- You are migrating an existing test gradually.
Both styles can be mixed within the same test.
Physics-Based Testing¶
Use wait_physics_frames for movement tests¶
Godot processes movement and physics in _physics_process. If your game moves a character in _physics_process, you must wait for physics frames to see the result:
def test_player_moves_right(game):
initial_x = game.get_property("/root/Main/Player", "position:x")
game.input_action("ui_right", True)
game.wait_physics_frames(10) # Let physics run
game.input_action("ui_right", False)
new_x = game.get_property("/root/Main/Player", "position:x")
assert new_x > initial_x
Why not wait_process_frames for movement?¶
wait_process_frames waits for _process frames, which are visual/render frames. Physics runs on a separate tick rate (default 60 Hz). If your movement code is in _physics_process (as is standard for CharacterBody2D), waiting for process frames may not advance physics.
Always use wait_physics_frames when testing:
- Character movement
- Collision detection
- RigidBody behavior
- Any logic in _physics_process
Use wait_process_frames for:
- Animation progress
- UI transitions
- Anything in _process
UI Testing¶
Clicking a node directly¶
Use click_node to click at a Control or Node2D's screen position without calculating coordinates manually:
def test_button_click(game):
game.click_node("/root/Main/UI/StartButton")
game.wait_for_node("/root/GameLevel", timeout=5.0)
The server computes the click position:
- Control nodes: center of get_global_rect().
- Node2D nodes: viewport-transformed global position.
Clicking at screen coordinates¶
For precise positioning or non-node targets:
game.click(400, 300) # Click at center of 800x600 window
game.input_mouse_button(400, 300, 1, True) # Mouse down
game.input_mouse_button(400, 300, 1, False) # Mouse up
State Verification¶
Auto-retrying assertions with expect()¶
The cleanest way to assert on game state is expect(locator).to_*. Each matcher polls until the condition holds or the timeout elapses, then raises ExpectationFailedError -- which pytest renders as a regular assertion failure (ExpectationFailedError subclasses AssertionError).
from godot_e2e import expect
def test_score_reaches_target(game):
game.click_at(start_button_pos)
expect(game.locator(name="ScoreLabel")).to_have_text("Score: 10")
expect(game.locator(name="HUD")).to_have_property("level", 2)
Why this beats assert game.get_property(...) == expected:
- A bare
assertreads the property once. Anything mid-frame (animations, scene transitions, signal handlers running on the next idle) gives a flaky test. expect(...)polls every 50 ms (configurable) up to a 5 s default timeout, so it rides out timing variance without needing explicitwait_physics_framescalls.- Failure messages include the last observed value and a depth-4 scene-tree dump.
For predicates that don't fit the standard matchers, use to_satisfy with a description:
expect(game.locator(group="enemies")).to_satisfy(
lambda loc: loc.count() == 0,
description="all enemies cleared",
)
Server-side polling for equality only: wait_for_property¶
If you want a single equality check polled on the Godot side (no per-poll TCP round-trip), wait_for_property is still available:
Use expect() when you need anything beyond equality (visibility, count, custom predicates) or when you want pytest to render the failure as an assertion. Use wait_for_property for the hottest equality polls in CPU-sensitive suites.
Reading properties after actions¶
Always wait for the appropriate number of frames after an input before reading state:
def test_press_increments_counter(game):
game.press_action("ui_accept")
# press_action already waits 2 physics frames internally (press + release)
counter = game.get_property("/root/Main", "counter")
assert counter == 1
Group-Based Node Lookup¶
Prefer groups over hardcoded paths¶
Node paths like "/root/Main/Enemies/Enemy1" are fragile -- they break if you reorganize the scene tree. Instead, add nodes to groups and look them up dynamically:
# Fragile: breaks if Enemy1 is moved
game.get_property("/root/Main/Enemies/Enemy1", "health")
# Robust: finds the node wherever it lives
enemies = game.find_by_group("enemies")
for enemy_path in enemies:
health = game.get_property(enemy_path, "health")
assert health > 0
Combining groups with patterns¶
Use query_nodes to filter by both group and name pattern:
# All boss enemies (in "enemies" group, name starts with "Boss")
bosses = game.query_nodes(pattern="Boss*", group="enemies")
Scene Transition Testing¶
Verify the new scene loads¶
Always call wait_for_node after change_scene to ensure the new scene is ready before reading its state:
def test_level_transition(game):
game.change_scene("res://levels/level2.tscn")
game.wait_for_node("/root/Level2", timeout=5.0)
level_name = game.get_property("/root/Level2", "level_name")
assert level_name == "Level 2"
change_scene is deferred -- it blocks until the new scene's root is available, but you should still use wait_for_node if you need to access child nodes that may take additional frames to initialize.
Verifying the current scene¶
Reload for state reset¶
def test_reload_resets_state(game):
game.call("/root/Main", "add_to_counter", [10])
assert game.get_property("/root/Main", "counter") == 10
game.reload_scene()
game.wait_for_node("/root/Main", timeout=5.0)
counter = game.get_property("/root/Main", "counter")
assert counter == 0
Screenshot on Failure¶
How it works¶
The built-in pytest plugin stashes test result reports on each test item. When a test using the game or game_fresh fixture fails, the fixture's teardown phase checks for the failure and captures a screenshot.
Screenshots are saved to:
The path is printed to stdout:
Manual screenshots¶
You can capture screenshots at any point in a test:
def test_visual_state(game):
game.press_action("ui_accept")
path = game.screenshot("/tmp/after_accept.png")
assert os.path.isfile(path)
If no path is provided, screenshots are saved to Godot's user://e2e_screenshots/ directory with a timestamp filename.
CI artifact collection¶
In CI, upload the test_output/ directory as a build artifact:
- name: Upload failure screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-failure-screenshots
path: test_output/
Seeing engine errors¶
Game-side push_error, push_warning, and script runtime errors are normally invisible to the test process — the assertion fails, but you can't tell whether the game silently logged the cause. godot-e2e captures these and surfaces them on test failure under a captured godot logs section, alongside captured stdout / captured stderr.
Default behaviour¶
When using the standard game / game_fresh fixtures, no test code is required:
def test_button_triggers_save(game):
game.locator(text="Save").click()
# The game's _on_save_pressed() does push_error("DB write failed") on
# error. If the test fails for any reason, that line shows up in the
# pytest failure report — no extra assertion needed.
assert game.get_property("/root/Menu/StatusLabel", "text") == "Saved"
If _on_save_pressed calls push_error("DB write failed") and the assertion fires, pytest's failure block ends with:
------------------------------ captured godot logs ------------------------------
[ERROR] DB write failed (res://scripts/menu.gd:42)
Asserting on logs explicitly¶
Sometimes the absence of an error is the test:
def test_quiet_startup(game):
# Drive the game through its startup flow.
game.locator(text="Start").click()
game.wait_for_node("/root/Game", timeout=2.0)
errors = [e for e in game.collected_logs if e.level == "error"]
assert errors == [], f"unexpected engine errors: {errors!r}"
Or that a specific warning path was exercised:
def test_invalid_input_logs_warning(game):
game.call("/root/Form", "submit", [{"email": "not-an-email"}])
msgs = [e.message for e in game.collected_logs if e.level == "warning"]
assert any("invalid email" in m.lower() for m in msgs)
Including print() output¶
print() is excluded by default (it's noisy). Raise verbosity for tests that need it:
def test_dialogue_progression(game):
game.set_log_verbosity("info")
game.locator(text="Talk").click()
# The dialogue system print()s each line as it advances.
lines = [e.message for e in game.collected_logs if e.level == "info"]
assert any("Hello, traveler." in line for line in lines)
Inspecting logs on a raised exception¶
Every godot-e2e exception carries a logs attribute containing the entries that arrived with the failing command's response — useful when the test wraps the call in pytest.raises:
def test_invalid_property_emits_diagnostic(game):
with pytest.raises(NodeNotFoundError) as exc_info:
game.get_property("/root/Nope", "x")
assert any("not found" in e.message.lower() for e in exc_info.value.logs)
Flaky Test Mitigation¶
Use wait_for_property instead of wait_frames for state changes¶
Frame-based waits are inherently timing-dependent. A test that works locally may fail in CI where frame rates differ:
# Flaky: depends on frame timing
game.press_action("ui_accept")
game.wait_physics_frames(5)
assert game.get_property("/root/Main", "animation_done") is True
Instead, wait for the actual state:
# Stable: waits until condition is met
game.press_action("ui_accept")
game.wait_for_property("/root/Main", "animation_done", True, timeout=5.0)
Use directional assertions over exact values¶
Physics simulation can produce slightly different values depending on frame rate and timing:
# Fragile: exact position depends on frame timing
assert game.get_property("/root/Main/Player", "position:x") == 450.0
# Robust: verify direction of movement
initial_x = game.get_property("/root/Main/Player", "position:x")
game.input_action("ui_right", True)
game.wait_physics_frames(10)
game.input_action("ui_right", False)
new_x = game.get_property("/root/Main/Player", "position:x")
assert new_x > initial_x # Direction, not exact value
Generous timeouts¶
Use comfortable timeouts, especially for CI:
game.wait_for_node("/root/Main", timeout=10.0) # 10s for initial load
game.wait_for_property("/root/Main", "ready", True, timeout=5.0)
Batch Operations for Performance¶
When you need to read multiple properties, use batch to make a single network round-trip:
# Slow: 3 round-trips
x = game.get_property("/root/Main/Player", "position:x")
y = game.get_property("/root/Main/Player", "position:y")
health = game.get_property("/root/Main/Player", "health")
# Fast: 1 round-trip
results = game.batch([
("get_property", {"path": "/root/Main/Player", "property": "position:x"}),
("get_property", {"path": "/root/Main/Player", "property": "position:y"}),
("get_property", {"path": "/root/Main/Player", "property": "health"}),
])
x, y, health = results
Batch only supports instant commands. Deferred commands (input, waits) return an error if included in a batch.
Debugging Tips¶
Enable server-side logging¶
The --e2e-log flag makes the Godot server print every request and response to stdout:
[godot-e2e] server listening on port 6008
[godot-e2e] client connected
[godot-e2e] << hello (id=1)
[godot-e2e] >> {"id":1,"ok":true,"godot_version":"4.4.0","server_version":"1.0.0"}
[godot-e2e] << get_property (id=2)
[godot-e2e] >> {"id":2,"result":{"_t":"v2","x":400.0,"y":300.0}}
To enable logging when using the launcher, pass the flag as an extra argument. Note that --e2e-log must be passed after the -- separator (the launcher handles --e2e, --e2e-port, and --e2e-token automatically, but --e2e-log must be added explicitly):
Inspect the scene tree¶
Use get_tree to dump the current scene tree when a test fails or behaves unexpectedly:
The TimeoutError exception from wait_for_node automatically includes a scene tree dump in its scene_tree attribute:
try:
game.wait_for_node("/root/Main/MissingNode", timeout=2.0)
except TimeoutError as e:
print("Scene tree at timeout:")
print(json.dumps(e.scene_tree, indent=2))
Connect to a running game¶
For interactive debugging, start Godot manually with --e2e and connect from Python:
from godot_e2e import GodotE2E
game = GodotE2E.connect(port=6008)
print(game.get_tree("/root", depth=2))
game.close()
Verify crash recovery¶
Use the game_fresh fixture for tests that intentionally crash or kill the Godot process:
def test_crash_recovery(game_fresh):
assert game_fresh.node_exists("/root/Main") is True
# Kill the process
game_fresh._launcher.process.kill()
game_fresh._launcher.process.wait()
# Next command raises ConnectionLostError
with pytest.raises(ConnectionLostError):
game_fresh.get_property("/root/Main", "counter")