mcpField
T
test-helpers

test-helpers

github.com/donchitos测试与质量28.5k442 次安装1 周前更新
代码仓库

关于 test-helpers

test-helpers 是面向游戏项目的测试辅助代码生成技能,适合需要减少测试样板代码的开发者。它依据项目引擎、语言、测试框架和现有测试模式生成通用及系统专用辅助文件,并要求确认后才写入。

主要功能

  1. 01按引擎生成测试辅助文件
  2. 02适配已配置的断言 API
  3. 03从现有测试提取代码模式
  4. 04生成系统专用工厂辅助代码
  5. 05避免覆盖已有文件

适用场景

  1. 01首次搭建测试框架后生成辅助库
  2. 02为重复的测试初始化抽取共用代码
  3. 03为新游戏系统编写测试辅助代码
  4. 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):

引擎辅助工具根目录
Godottests/helpers/
UnityAssets/Tests/EditMode/Helpers/ — 位于 /test-setup 创建的 EditModeTests 程序集内。如果 PlayMode 测试也需要相同的辅助工具,请先询问,再创建 Assets/Tests/Helpers/TestHelpers.asmdef 并从两个测试程序集引用它。
UnrealSource/<Module>/Private/Tests/Helpers/

下文中的 tests/helpers/ 指项目引擎对应的辅助工具根目录。

运行时机:

  • /test-setup 搭建框架后(首次运行)
  • 多个测试文件重复相同的设置样板代码时
  • 开始为新系统编写测试时

断言:使用项目的测试框架,绝不使用裸 assert()

生成的辅助工具必须通过已配置测试框架的断言 API 进行断言,根据 testing.framework 确认——不能使用 GDScript 内置的 assert()。

在辅助工具中使用裸 assert() 会导致两个问题,而且都不会明确报错:

  1. 它会中止运行,而不是让测试失败。 框架断言会记录失败并继续运行,因此一次预期不符只会导致一个测试标红。裸 assert() 会停止执行,因此首次失败会掩盖此后的所有结果。
  2. 它会在发布版本中被剥离。 基于 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,验证可能需要辅助文件支持的其他技能。

评论

支持 Markdown