关于 test-helpers
test-helpers 是面向游戏项目的测试辅助代码生成技能,适合需要减少测试样板代码的开发者。它依据项目引擎、语言、测试框架和现有测试模式生成通用及系统专用辅助文件,并要求确认后才写入。
主要功能
- 01按引擎生成测试辅助文件
- 02适配已配置的断言 API
- 03从现有测试提取代码模式
- 04生成系统专用工厂辅助代码
- 05避免覆盖已有文件
适用场景
- 01首次搭建测试框架后生成辅助库
- 02为重复的测试初始化抽取共用代码
- 03为新游戏系统编写测试辅助代码
- 04按项目测试框架生成断言辅助代码
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ donchitos/ claude-code-game-studios --skill test-helpers安装后在 Claude Code 中输入/test-helpers确认已加载。
详细介绍
!bash "${CLAUDE_SKILL_DIR}/../../hooks/yaml-helper.sh" resolve_config --keys automation
自动化模式:解析 modes.automation(project.local.yaml →
project.yaml → 默认值 collaborative)。每次调用 AskUserQuestion 和
每次文件写入都遵循 .claude/docs/automation-modes.md
(协作模式始终询问 · 引导模式仅询问重大事项 · 自主模式记录并继续;
automation_always_ask 类别始终提示)。
测试辅助工具
将常见的设置、清理和断言模式抽象为辅助工具,可以更快、更一致地编写测试用例。此技能会根据项目实际使用的引擎、语言和系统生成 tests/helpers/ 库,让每位开发者少写样板代码,多写断言。
输出: 引擎专用辅助文件,位于引擎的辅助工具根目录——这是其测试运行器编译这些文件的唯一位置(.claude/docs/directory-structure.md):
| 引擎 | 辅助工具根目录 |
|---|---|
| Godot | tests/helpers/ |
| Unity | Assets/Tests/EditMode/Helpers/ — 位于 /test-setup 创建的 EditModeTests 程序集内。如果 PlayMode 测试也需要相同的辅助工具,请先询问,再创建 Assets/Tests/Helpers/TestHelpers.asmdef 并从两个测试程序集引用它。 |
| Unreal | Source/<Module>/Private/Tests/Helpers/ |
下文中的 tests/helpers/ 指项目引擎对应的辅助工具根目录。
运行时机:
/test-setup搭建框架后(首次运行)- 多个测试文件重复相同的设置样板代码时
- 开始为新系统编写测试时
断言:使用项目的测试框架,绝不使用裸 assert()
生成的辅助工具必须通过已配置测试框架的断言 API 进行断言,根据 testing.framework 确认——不能使用 GDScript 内置的 assert()。
在辅助工具中使用裸 assert() 会导致两个问题,而且都不会明确报错:
- 它会中止运行,而不是让测试失败。 框架断言会记录失败并继续运行,因此一次预期不符只会导致一个测试标红。裸
assert()会停止执行,因此首次失败会掩盖此后的所有结果。 - 它会在发布版本中被剥离。 基于
assert()构建的辅助工具库会在最需要检查的构建中完全停止断言,而且不会报错——测试仍会“通过”。
生成代码前,先查明已配置框架对应的断言形式。 不要将本文档或其他技能示例中的形式视为权威——这些仅供说明,且已有出入(仓库中出现过 assert_eq、assert_true 和 assert_that)。如果无法确认项目框架的正确 API,请明确说明并且不要生成任何辅助工具,不要猜测。
辅助工具不得继承框架的测试套件基类。 tests/helpers/ 中继承套件类型的文件会被运行器识别为包含零个测试的测试套件。辅助工具应为普通类;只有真正的测试文件才继承套件。
1. 解析参数
模式:
/test-helpers [system-name]— 为指定系统生成辅助工具 (例如/test-helpers combat)/test-helpers all— 为所有包含测试文件的系统生成辅助工具/test-helpers scaffold— 仅生成基础辅助工具库(不生成系统专用辅助工具);首次运行时使用- 不带参数 — 如果不存在辅助工具,则运行
scaffold;否则运行all
2. 检测引擎和语言
首先从 project.yaml 读取;对于缺失或为空的键,则回退到 .claude/docs/technical-preferences.md:
engine.name(否则使用Engine:的值)engine.language(否则使用Language:的值)testing.framework(否则使用 Testing 部分中的Framework:)
如果这两个来源都未配置引擎:"Engine not configured. Run /setup-engine first."
3. 加载现有测试模式
扫描引擎的测试根目录,查找已在使用的模式(Godot tests/、Unity
Assets/Tests/、Unreal Source/<Module>/Private/Tests/):
Glob pattern="tests/**/*_test.*" (Godot)
Glob pattern="Assets/Tests/**/*Tests.cs" (Unity)
Glob pattern="Source/*/Private/Tests/**/*.cpp" (Unreal)
从有代表性的样本中(最多 5 个文件)读取测试文件并提取:
- 设置模式(如何编写
before_each/setUp/ fixtures) - 常见断言模式(最常断言什么)
- 对象创建模式(如何在测试中实例化游戏对象或场景)
- Mock/stub 模式(如何替换依赖项)
这样可以确保生成的辅助工具符合项目现有风格,而非通用模板。
另请阅读:
design/gdd/systems-index.md——了解现有哪些系统- 范围内的 GDD ——了解需要测试哪些数据类型和值。按章节阅读:使用 Grep 工具(模式
^#,内容模式)查找所需标题,再用 offset 和 limit 阅读对应范围。 docs/architecture/tr-registry.yaml——将需求映射到受测试的系统
4. 生成特定于引擎的辅助工具
Godot 4(GDUnit4 / GDScript)
以下示例中的
FAIL_IF(...)是占位符,不是 API。 根据 §2 中的规则,在输出任何内容之前,必须根据项目实际使用的测试框架确定这一行应使用什么。替换为该框架真正的失败调用——即向运行器登记失败,同时让测试套件继续运行的调用形式——并删除标记注释。它故意没有写成
assert_that、assert_eq或assert_true。 这三个写法都在此仓库的某处出现过,彼此并不一致,而且**docs/engine-reference/未涵盖 GdUnit4 的 API**——因此在这里写下其中任何一个,都是在断言此仓库无法证实的 API,而这个文件的全部职责正是阻止这种做法。如果无法确认所配置框架的正确调用方式,§2 已经说明了该怎么做:不要生成辅助工具。 未解决的FAIL_IF写入磁盘属于 bug;缺少辅助工具则是任务要求。已完成的示例也必须遵守 §2——在这里直接使用
assert(),正是 §2 所禁止的形式,而它就在 §2 禁止该形式的六十行之后。除非有人将规则也应用到示例,否则以文字说明的规则不会自动涵盖示例。
基础辅助工具(tests/helpers/game_assertions.gd):
## Game-specific assertion utilities for [Project Name] tests.
## Domain-specific helpers built on the project's test framework.
##
## Usage (static — no instance needed):
## GameAssertions.assert_in_range(entity.health, 0, entity.max_health, "health")
class_name GameAssertions
extends RefCounted
## Assert a value is within the inclusive range [min_val, max_val].
## Use for any formula output that has defined bounds in a GDD.
static func assert_in_range(
value: float,
min_val: float,
max_val: float,
label: String = "value"
) -> void:
# FRAMEWORK ASSERT — resolve per the rule above; do not emit `assert()`.
FAIL_IF(
not (value >= min_val and value <= max_val),
"%s %.2f is outside expected range [%.2f, %.2f]" % [label, value, min_val, max_val]
)
## Assert a signal was emitted during a callable block.
## Usage: assert_signal_emitted(entity, "health_changed", func(): entity.take_damage(10))
static func assert_signal_emitted(
obj: Object,
signal_name: String,
action: Callable
) -> void:
# A lambda captures a local by value: `emitted = true` inside it would set
# the lambda's own copy. Mutate a Dictionary it shares instead. `...args`
# (4.5+) accepts a signal with any number of arguments.
var state := {"emitted": false}
obj.connect(signal_name, func(...args): state.emitted = true)
action.call()
# FRAMEWORK ASSERT — resolve per the rule above; do not emit `assert()`.
FAIL_IF(not state.emitted, "Expected signal '%s' to be emitted, but it was not." % signal_name)
## Assert that a callable does NOT emit a signal.
static func assert_signal_not_emitted(
obj: Object,
signal_name: String,
action: Callable
) -> void:
var state := {"emitted": false} # a Dictionary, as above
obj.connect(signal_name, func(...args): state.emitted = true)
action.call()
# FRAMEWORK ASSERT — resolve per the rule above; do not emit `assert()`.
FAIL_IF(state.emitted, "Expected signal '%s' NOT to be emitted, but it was." % signal_name)
## Assert a node exists at path within a parent.
static func assert_node_exists(parent: Node, path: NodePath) -> void:
# FRAMEWORK ASSERT — resolve per the rule above; do not emit `assert()`.
FAIL_IF(
not parent.has_node(path),
"Expected node at path '%s' to exist." % str(path)
)
工厂辅助工具(tests/helpers/game_factory.gd):
## Factory functions for creating test game objects.
## Returns minimal objects configured for unit testing (no scene tree required).
##
## Usage: var player = GameFactory.make_player(health: 100)
class_name GameFactory
extends RefCounted
## Create a minimal player-like object for testing.
## Override fields as needed.
static func make_player(health: int = 100) -> Node:
var player = Node.new()
player.set_meta("health", health)
player.set_meta("max_health", health)
return player
场景辅助工具(tests/helpers/scene_runner_helper.gd):
## Utilities for scene-based integration tests. For input simulation and frame
## stepping, use gdUnit4's own `scene_runner()` from inside the suite.
class_name SceneRunnerHelper
extends RefCounted
## Load a scene under `host` — the calling test suite, which passes `self` —
## and wait one frame for _ready() to complete. A plain class, so the runner
## never discovers this file as a suite with zero tests.
static func load_scene_and_wait(host: Node, scene_path: String) -> Node:
var scene: Node = load(scene_path).instantiate()
host.add_child(scene)
await host.get_tree().process_frame
return scene
Unity(NUnit / C#)
基础辅助工具(Assets/Tests/EditMode/Helpers/GameAssertions.cs):
using NUnit.Framework;
using UnityEngine;
/// <summary>
/// Game-specific assertion utilities for [Project Name] tests.
/// Extends NUnit's Assert with domain-specific helpers.
/// </summary>
public static class GameAssertions
{
/// <summary>
/// Assert a value is within an inclusive range [min, max].
/// Use for any formula output defined in GDD Formulas sections.
/// </summary>
public static void AssertInRange(float value, float min, float max, string label = "value")
{
Assert.That(value, Is.InRange(min, max),
$"{label} ({value:F2}) is outside expected range [{min:F2}, {max:F2}]");
}
/// <summary>Assert a UnityEvent or C# event was raised during an action.</summary>
public static void AssertEventRaised(ref bool wasCalled, System.Action action, string eventName)
{
wasCalled = false;
action();
Assert.IsTrue(wasCalled, $"Expected event '{eventName}' to be raised, but it was not.");
}
/// <summary>Assert a component exists on a GameObject.</summary>
public static void AssertHasComponent<T>(GameObject obj) where T : Component
{
var component = obj.GetComponent<T>();
Assert.IsNotNull(component,
$"Expected GameObject '{obj.name}' to have component {typeof(T).Name}.");
}
}
工厂辅助工具(Assets/Tests/EditMode/Helpers/GameFactory.cs):
using UnityEngine;
/// <summary>
/// Factory methods for creating minimal test objects without loading scenes.
/// </summary>
public static class GameFactory
{
/// <summary>Create a minimal GameObject with a named component for testing.</summary>
public static GameObject MakeGameObject(string name = "TestObject")
{
var go = new GameObject(name);
return go;
}
/// <summary>
/// Create a ScriptableObject of type T for data-driven tests.
/// Dispose with Object.DestroyImmediate after test.
/// </summary>
public static T MakeScriptableObject<T>() where T : ScriptableObject
{
return ScriptableObject.CreateInstance<T>();
}
}
Unreal Engine(C++)
基础辅助工具(Source/<Module>/Private/Tests/Helpers/GameTestHelpers.h):
#pragma once
#include "CoreMinimal.h"
#include "Misc/AutomationTest.h"
/**
* Game-specific assertion macros and helpers for [Project Name] automation tests.
* Include in any test file that needs domain-specific assertions.
*
* Usage:
* GAME_TEST_ASSERT_IN_RANGE(TestName, DamageValue, 10.0f, 50.0f, TEXT("Damage"));
*/
// Assert a float value is within inclusive range [Min, Max]
#define GAME_TEST_ASSERT_IN_RANGE(TestName, Value, Min, Max, Label) \
TestTrue( \
FString::Printf(TEXT("%s (%.2f) in range [%.2f, %.2f]"), Label, Value, Min, Max), \
(Value) >= (Min) && (Value) <= (Max) \
)
// Assert a UObject pointer is valid (not null, not garbage collected)
#define GAME_TEST_ASSERT_VALID(TestName, Ptr, Label) \
TestTrue( \
FString::Printf(TEXT("%s is valid"), Label), \
IsValid(Ptr) \
)
// Assert an Actor is in the world (spawned successfully)
#define GAME_TEST_ASSERT_SPAWNED(TestName, ActorPtr, ClassName) \
TestNotNull( \
FString::Printf(TEXT("Spawned actor of class %s"), TEXT(#ClassName)), \
ActorPtr \
)
/**
* Helper to create a minimal test world.
* Remember to call World->DestroyWorld(false) in teardown.
*/
namespace GameTestHelpers
{
inline UWorld* CreateTestWorld(const FString& WorldName = TEXT("TestWorld"))
{
UWorld* World = UWorld::CreateWorld(EWorldType::Game, false);
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
WorldContext.SetCurrentWorld(World);
return World;
}
}
5. 生成特定于系统的辅助工具
对于 [system-name] 或 all 模式,为每个系统生成一个辅助工具:
只读取本次所需的 GDD 章节——使用 Grep pattern="^## ([0-9]+\. )?(Formulas|Edge Cases|Detailed Rules|Detailed Design)" path="design/gdd/[system].md" output_mode="content" -A 30(可选数字用于匹配反向记录的 GDD 中的 ## 4. Formulas)——而不要完整读取全文(每项同类技能都会按标题对 GDD 进行 Grep;在 all 模式下,完整读取会让每个 400 多行的 GDD 都重复累加)。提取:
- 数据类型(实体类型、组件名称)
- 公式变量及其范围
- Edge Cases 中提及的常见测试场景
生成 tests/helpers/[system]_factory.[ext],其中包含针对该系统对象的工厂函数。
以 combat 系统为例(Godot/GDScript):
## Factory and assertion helpers for Combat system tests.
## Generated by /test-helpers combat on [date].
## Based on: design/gdd/combat.md
class_name CombatTestFactory
extends RefCounted
const DAMAGE_MIN := 0
const DAMAGE_MAX := 999 # From GDD: damage formula upper bound
## Create a minimal attacker object for damage formula tests.
static func make_attacker(attack: float = 10.0, crit_chance: float = 0.0) -> Node:
var attacker = Node.new()
attacker.set_meta("attack", attack)
attacker.set_meta("crit_chance", crit_chance)
return attacker
## Create a minimal target object for damage receive tests.
static func make_target(defense: float = 0.0, health: float = 100.0) -> Node:
var target = Node.new()
target.set_meta("defense", defense)
target.set_meta("health", health)
target.set_meta("max_health", health)
return target
## Assert damage output is within GDD-specified bounds.
static func assert_damage_in_bounds(damage: float) -> void:
GameAssertions.assert_in_range(damage, DAMAGE_MIN, DAMAGE_MAX, "damage")
6. 写入输出
展示将要创建的内容摘要:
## Test Helpers to Create
Base helpers (engine: [engine], helper root: [helper root]):
- [helper root]/game_assertions.[ext]
- [helper root]/game_factory.[ext]
[engine-specific extras]
System helpers ([mode]):
- [helper root]/[system]_factory.[ext] ← from [system] GDD
询问:“可以将这些辅助文件写入 [helper root] 吗?”
绝不覆盖现有文件。 如果文件已存在,请报告:
“跳过 [path] — 文件已存在。如果要重新生成,请手动删除该文件。”
写入后:判定:完成 — 已创建辅助文件。
“已创建辅助文件。在测试中使用方法:
- Godot:
class_name会自动导入 — 无需显式导入 - Unity:添加
using指令或引用测试程序集 - Unreal:
#include \"Tests/Helpers/GameTestHelpers.h\"(在模块内使用)”
协作协议
- 绝不覆盖现有辅助文件 — 其中可能包含手动编写的自定义内容。只生成尚不存在的新文件
- 生成的代码只是起点 — 为简化起见,生成的工厂函数使用元数据模式;代码存在后,应根据实际类结构进行调整
- 辅助文件应体现 GDD — 辅助文件中的边界和常量应对应到 GDD 的公式章节,而不是凭空设定
- 写入前先询问 — 在
tests/中创建文件前,始终先确认
后续步骤
- 如果尚未搭建测试框架,请运行
/test-setup。 - 使用
/dev-story实现故事 — 辅助文件可减少新测试文件中的样板代码。 - 运行
/skill-test,验证可能需要辅助文件支持的其他技能。