Skip to content

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.

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:

game.get_by_text("UI Testing Demo")
game.get_by_button("Start")

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

panel = game.locator(name="OptionsPanel")
panel.locator(type="CheckBox").first().click()

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:

game.get_by_button("Start").click()        # waits up to 5s for actionability

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 assert reads 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 explicit wait_physics_frames calls.
  • 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:

game.wait_for_property("/root/Main", "score", 10, timeout=5.0)

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

scene = game.get_scene()
assert "level2.tscn" in 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:

test_output/<test_name>_failure.png

The path is printed to stdout:

[godot-e2e] Failure screenshot saved: test_output/test_player_moves_right_failure.png

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):

with GodotE2E.launch(project_path, extra_args=["--e2e-log"]) as game:
    ...

Inspect the scene tree

Use get_tree to dump the current scene tree when a test fails or behaves unexpectedly:

tree = game.get_tree("/root", depth=3)
import json
print(json.dumps(tree, indent=2))

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:

godot --path ./my_project -- --e2e --e2e-port=6008 --e2e-log
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")