测试模式与最佳实践¶
本指南涵盖了使用 godot-e2e 编写可靠端到端测试的模式、策略和技巧。
夹具策略¶
godot-e2e 支持三种测试隔离策略,在速度和隔离性之间各有不同的权衡。
策略 1:场景重新加载(默认,推荐)¶
每个测试模块共用一个 Godot 进程。每个测试前重新加载场景。
@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
适用场景:大多数测试。场景重新加载会重置场景树、节点属性和脚本变量。速度快(无进程启动开销)且提供良好的隔离性。
局限性:存在于场景之外的全局状态(单例、自动加载节点、静态变量)会在测试之间保留。如果你的游戏使用了全局状态,请使用 change_scene 回到已知场景,或使用 game_fresh。
策略 2:全新进程(最大隔离)¶
每个测试函数启动一个新的 Godot 进程。
@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
适用场景:修改全局状态的测试(自动加载节点属性、单例)、需要完全干净环境的测试,或验证崩溃恢复的测试。
局限性:速度慢。每个测试都要承担启动 Godot 和建立连接的开销(约 2-5 秒)。谨慎使用。
策略 3:共享会话(最快)¶
整个测试会话共用一个 Godot 进程。
@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
适用场景:不修改游戏状态的只读测试,或者当你需要最快的执行速度并愿意手动管理测试顺序时。
局限性:测试之间无自动重置。测试必须自行清理,或精心安排执行顺序。一个测试中的崩溃会终止整个会话。
Locator 优先的写法¶
推荐做法:优先使用 Locator,而不是绝对场景路径。Locator 是懒解析的引用,每次 action 都会重新查询,因此能在场景 reload 和小幅重构后继续有效——而手写的 /root/Menu/VBox/ClickButton 一改就坏。
常见查询¶
game.locator(name="StartButton") # 按节点名
game.locator(text="Save") # 按 Control.text
game.locator(group="enemies") # 按 group
game.locator(type="BaseButton") # 所有按钮(含 CheckBox 等)
game.locator(script="res://player.gd") # 按附加脚本
game.locator(name="*Boss*") # glob(值含 * 或 ?)
最常见两种的语法糖:
组合¶
多关键字 AND 组合;filter() 追加更多谓词。挑读起来顺的写法。
链式限定子树¶
父 Locator 必须解析到正好 1 个节点,否则抛 MultipleMatchesError。用 .first() / .nth(i) / .filter(...) 消歧。
多匹配保护¶
匹配到 1 个以上节点的查询默认抛 MultipleMatchesError。这是有意的:静默取第一个匹配是重构后失败最常见的原因。
game.locator(type="Button").click() # >1 匹配会报错
# 显式选一个:
game.locator(type="Button").first().click()
game.locator(type="Button").nth(2).click()
for btn in game.locator(type="Button").all():
btn.click()
点击的自动等待¶
Locator.click() 在点击前会轮询 actionability(仅 Control 节点):
is_visible_in_tree()mouse_filter != MOUSE_FILTER_IGNORE- 节点的 global rect 与 viewport 相交
所以即使按钮正在淡入动画,也不需要显式 wait_* 样板:
需要跳过检查(比如目标本来就在屏幕外)传 force=True。
场景 reload 后¶
button = game.get_by_button("Click Me")
button.click()
game.reload_scene()
button.click() # 仍然有效——会针对新树重跑查询
何时退回到 path-based API¶
Locator 是推荐默认,但现有的 path-based API(game.click_node(path)、game.get_property(path, prop) 等)保持不变,下列场景仍合适:
- 已经知道精确路径,不想多一次查询。
- 需要 Locator 不支持的查询(比如按视口位置)。
- 渐进式迁移已有测试。
两种风格可以在同一个测试里混用。
基于物理的测试¶
对移动测试使用 wait_physics_frames¶
Godot 在 _physics_process 中处理移动和物理。如果你的游戏在 _physics_process 中移动角色,你必须等待物理帧才能看到结果:
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
为什么不用 wait_process_frames 来测试移动?¶
wait_process_frames 等待的是 _process 帧,即视觉/渲染帧。物理运行在独立的频率上(默认 60 Hz)。如果你的移动代码在 _physics_process 中(这是 CharacterBody2D 的标准做法),等待处理帧可能不会推进物理模拟。
以下场景始终使用 wait_physics_frames:
- 角色移动
- 碰撞检测
- RigidBody 行为
- 所有在 _physics_process 中的逻辑
以下场景使用 wait_process_frames:
- 动画进度
- UI 过渡效果
- 所有在 _process 中的逻辑
UI 测试¶
直接点击节点¶
使用 click_node 点击 Control 或 Node2D 的屏幕位置,无需手动计算坐标:
def test_button_click(game):
game.click_node("/root/Main/UI/StartButton")
game.wait_for_node("/root/GameLevel", timeout=5.0)
服务器会计算点击位置:
- Control 节点:get_global_rect() 的中心点。
- Node2D 节点:经视口变换的全局位置。
点击屏幕坐标¶
用于精确定位或非节点目标:
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
状态验证¶
用 expect() 写自动重试断言¶
断言游戏状态最干净的写法是 expect(locator).to_*。每个 matcher 都会轮询直到条件满足或超时,超时则抛 ExpectationFailedError——pytest 会按普通断言失败渲染(ExpectationFailedError 同时继承 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)
为什么比 assert game.get_property(...) == expected 好:
- 裸
assert只读一次。任何处于中间帧(动画、场景切换、下一个 idle 帧才执行的信号处理)的状态都会让测试 flaky。 expect(...)默认每 50 ms 轮询一次(可配),上限 5 s,可以平滑跨越时序波动,无需显式wait_physics_frames。- 失败信息包含最后观测值和深度 4 的场景树 dump。
不在标准 matcher 范围内的判断用 to_satisfy 加一个 description:
expect(game.locator(group="enemies")).to_satisfy(
lambda loc: loc.count() == 0,
description="all enemies cleared",
)
仅做等值检查时可用 wait_for_property(服务端轮询)¶
如果只需要一项等值检查并希望省下每轮 TCP 往返,wait_for_property 仍可用:
需要等值以外(可见性、计数、自定义 predicate)或希望 pytest 把失败渲染成断言时用 expect();CPU 敏感的测试套件里热路径上的等值轮询用 wait_for_property。
操作后读取属性¶
在输入之后,务必等待适当数量的帧再读取状态:
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
基于分组的节点查找¶
优先使用分组而非硬编码路径¶
像 "/root/Main/Enemies/Enemy1" 这样的节点路径很脆弱 -- 一旦重新组织场景树就会失效。相反,将节点添加到分组中并动态查找:
# 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
组合使用分组和模式¶
使用 query_nodes 同时按分组和名称模式筛选:
# All boss enemies (in "enemies" group, name starts with "Boss")
bosses = game.query_nodes(pattern="Boss*", group="enemies")
场景切换测试¶
验证新场景已加载¶
在 change_scene 之后务必调用 wait_for_node,确保新场景就绪后再读取其状态:
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 是延迟操作 -- 它会阻塞直到新场景的根节点可用,但如果你需要访问可能需要额外帧来初始化的子节点,仍然应该使用 wait_for_node。
验证当前场景¶
通过重新加载重置状态¶
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
失败时截图¶
工作原理¶
内置的 pytest 插件在每个测试项上暂存测试结果报告。当使用 game 或 game_fresh 夹具的测试失败时,夹具的清理阶段会检查失败情况并截取屏幕截图。
截图保存到:
路径会打印到标准输出:
手动截图¶
你可以在测试的任意时刻截取屏幕截图:
def test_visual_state(game):
game.press_action("ui_accept")
path = game.screenshot("/tmp/after_accept.png")
assert os.path.isfile(path)
如果未提供路径,截图会保存到 Godot 的 user://e2e_screenshots/ 目录,文件名带有时间戳。
CI 产物收集¶
在 CI 中,将 test_output/ 目录上传为构建产物:
- name: Upload failure screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-failure-screenshots
path: test_output/
看到引擎错误¶
游戏侧的 push_error、push_warning、脚本运行时错误对测试进程默认是不可见的——断言失败但你不知道游戏其实静默记录了原因。godot-e2e 会捕获这些错误,并在测试失败时把它们以 captured godot logs section 的形式连同 captured stdout / captured stderr 一起放进 pytest 报告。
默认行为¶
使用标准 game / game_fresh fixture 时,无需额外测试代码:
def test_button_triggers_save(game):
game.locator(text="Save").click()
# 游戏的 _on_save_pressed() 在错误时会 push_error("DB write failed")。
# 不论测试因为什么原因失败,那一行都会出现在 pytest 失败报告里。
assert game.get_property("/root/Menu/StatusLabel", "text") == "Saved"
如果 _on_save_pressed 调了 push_error("DB write failed") 且断言失败,pytest 失败块末尾会出现:
------------------------------ captured godot logs ------------------------------
[ERROR] DB write failed (res://scripts/menu.gd:42)
显式断言日志¶
有时"没有错误"才是测试要检查的事:
def test_quiet_startup(game):
# 把游戏走完启动流程
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}"
或者断言某条特定的警告路径被走过了:
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)
包含 print() 输出¶
print() 默认不捕获(太吵)。需要时用 set_log_verbosity 临时提高:
def test_dialogue_progression(game):
game.set_log_verbosity("info")
game.locator(text="Talk").click()
# 对话系统会在每行台词推进时 print() 一行
lines = [e.message for e in game.collected_logs if e.level == "info"]
assert any("Hello, traveler." in line for line in lines)
抛异常时检查日志¶
每个 godot-e2e 异常都带 logs 属性,承载失败命令响应里附带的日志条目——测试用 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)
不稳定测试的应对策略¶
用 wait_for_property 代替 wait_frames 来等待状态变化¶
基于帧数的等待本质上依赖于时序。一个在本地通过的测试可能在帧率不同的 CI 环境中失败:
# 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
改为等待实际的状态:
# Stable: waits until condition is met
game.press_action("ui_accept")
game.wait_for_property("/root/Main", "animation_done", True, timeout=5.0)
使用方向性断言而非精确值¶
物理模拟可能因帧率和时序差异产生略微不同的值:
# 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
充裕的超时设置¶
使用宽松的超时时间,尤其是在 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 在单次网络往返中完成:
# 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
批处理仅支持即时命令。如果包含延迟命令(输入、等待类),会返回错误。
调试技巧¶
启用服务器端日志¶
--e2e-log 标志使 Godot 服务器将每个请求和响应打印到标准输出:
[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}}
使用启动器时启用日志,需要将该标志作为额外参数传递。注意 --e2e-log 必须在 -- 分隔符之后传递(启动器自动处理 --e2e、--e2e-port 和 --e2e-token,但 --e2e-log 需要显式添加):
检查场景树¶
当测试失败或行为异常时,使用 get_tree 导出当前场景树:
wait_for_node 抛出的 TimeoutError 异常会在其 scene_tree 属性中自动包含场景树快照:
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))
连接到正在运行的游戏¶
进行交互式调试时,手动以 --e2e 启动 Godot 并从 Python 连接:
from godot_e2e import GodotE2E
game = GodotE2E.connect(port=6008)
print(game.get_tree("/root", depth=2))
game.close()
验证崩溃恢复¶
使用 game_fresh 夹具来测试故意导致崩溃或终止 Godot 进程的场景:
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")