godot-tdd-full
SKILL.md
Godot TDD 完整工作流
本 Skill 提供 Godot TDD(测试驱动开发)的完整工作流,整合接口设计、测试开发、代码实现和质量验证。
快速开始
// 开始新功能的 TDD 流程
{ "skill": "godot-tdd-full", "action": "start", "feature": "功能名称", "description": "功能描述" }
// 设计文件写入后,等待用户确认
{ "skill": "godot-tdd-full", "action": "approve_design" }
// 用户确认后,进入测试阶段
{ "skill": "godot-tdd-full", "action": "next_phase" }
// 运行测试验证
{ "skill": "godot-tdd-full", "action": "run_tests", "project": "D:/path/to/project" }
TDD 工作流(带用户确认)
┌─────────────────────────────────────────────────────────────────────┐
│ TDD 完整流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. start → 生成设计文档 │
│ ↓ │
│ 2. [等待用户确认] → 用户评审 design.md │
│ ↓ │
│ 3. approve_design → 用户批准设计 │
│ ↓ │
│ 4. next_phase → 进入测试阶段 │
│ ↓ │
│ 5. 编写测试 → 运行测试(预期失败 red) │
│ ↓ │
│ 6. next_phase → 进入实现阶段 │
│ ↓ │
│ 7. 最小实现 → 让测试通过(green) │
│ ↓ │
│ 8. next_phase → 进入验证阶段 │
│ ↓ │
│ 9. verify → lint/format 检查 │
│ ↓ │
│ 10. next_phase → 进入测试阶段 │
│ ↓ │
│ 11. run_tests → 运行测试验证(refactor) │
│ ↓ │
│ 循环直到功能完成 │
│ │
└─────────────────────────────────────────────────────────────────────┘
用户参与点
| 阶段 | 操作 | 说明 |
|---|---|---|
start |
LLM 执行 | 生成设计文档 |
approve_design |
用户执行 | 评审并批准设计 |
next_phase |
LLM 执行 | 进入下一阶段 |
run_tests |
LLM 执行 | 运行测试 |
关键规则
- 设计阶段必须等待用户确认 - 使用
approve_design显式确认 - 用户确认后才能写测试 - 确保设计被认可
- 实现必须严格遵循设计 - 如需修改接口,返回设计阶段
Phase 1: 接口设计 (Design)
Godot 类系统基础
在 Godot 中,脚本不是真正的类,而是附加到引擎内置类的资源:
# 脚本继承 Node,成为其扩展
class_name Player extends Node
# 也可以不写 extends,隐式继承 RefCounted
class_name MathUtils
关键点:
- 脚本通过
class_name注册到 ClassDB - 无
extends时隐式继承RefCounted - 场景是可复用、可实例化、可继承的节点组
状态查询方法(必备)
测试需要能够验证对象状态,必须提供查询方法:
class_name Player extends Node
var _health: int = 100
var _speed: float = 300.0
# 状态查询方法(供测试断言使用)
func get_health() -> int:
return _health
func is_alive() -> bool:
return _health > 0
动作方法签名
# 好的例子:有明确的类型注解和返回值
func move_to(target: Vector2) -> void:
...
func take_damage(amount: int) -> void:
...
func heal(amount: int) -> int: # 返回实际恢复的血量
...
信号定义(解耦)
class_name Player extends Node
# 状态变化信号
signal health_changed(new_health: int, old_health: int)
signal died()
# 使用示例
func take_damage(amount: int) -> void:
var old_health = _health
_health = max(0, _health - amount)
health_changed.emit(_health, old_health)
if _health == 0:
died.emit()
错误处理
class_name FileManager extends Node
# 使用 Error 枚举作为返回值
func load_file(path: String) -> Error:
var file = FileAccess.open(path, FileAccess.READ)
if not file:
return FileAccess.get_open_error()
return OK
接口契约测试(重要)
核心原则:测试覆盖 = 被测模块 + 它调用的所有依赖
问题案例
# DifficultySelect.gd (UI层) - 调用者
func _show_description():
# ❌ 调用了不存在的方法
var desc = DifficultyManager.get_difficulty_description_by_id(_difficulty)
# DifficultyManager.gd - 提供者
func get_difficulty_description(id: int) -> String: # ✅ 实际方法名
...
为什么测试没发现:
- 只测试了 DifficultyManager 内部逻辑
- 没有测试 DifficultySelect 的调用路径
测试覆盖规则
| 修改的文件 | 必须测试的内容 |
|---|---|
| Manager.gd | 内部逻辑 + 所有公开 API |
| UI.gd | UI 逻辑 + 它调用的 Manager 方法 |
| 新增交互 | 两个模块的集成测试 |
调用者测试示例
# test_difficulty_select.gd
extends GutTest
func test_difficulty_select_calls_manager():
var dm = DifficultyManager.new()
var ui = DifficultySelect.new()
ui.manager = dm
ui._on_selected(1)
# 如果方法名错误,这里会失败
assert_called(dm, "get_difficulty_description", [1])
设计检查清单
- 符合 Godot 类系统规则
- 单一职责
- 有测试友好的查询方法
- 所有方法有类型注解
设计文档模板
设计阶段需要输出以下文档到项目目录:
{project}/
├── specs/ # 设计文档目录
│ └── {feature}/ # 按功能划分
│ ├── design.md # 需求 + 接口设计(合并)
│ ├── test_cases.md # 测试用例清单
│ └── implementation_plan.md # 实现计划/伪代码
design.md 模板
# {功能名} 设计文档
## 1. 需求描述
### 1.1 功能概述
描述功能的核心目的和业务价值。
### 1.2 功能列表
| 编号 | 功能 | 优先级 | 说明 |
|-----|------|-------|------|
| F1 | | P0 | |
### 1.3 用户故事
**作为** [角色]
**我希望** [功能]
**以便** [价值]
### 1.4 输入输出
#### 输入
| 参数 | 类型 | 必填 | 说明 |
|-----|------|-----|------|
#### 输出
| 结果 | 类型 | 说明 |
|-----|------|------|
### 1.5 约束条件
- 性能要求
- 兼容性要求
- 其他约束
### 1.6 验收标准
- [ ] 验收标准1
- [ ] 验收标准2
---
## 2. 接口设计
### 2.1 类定义
#### {ClassName}
```gdscript
class_name {ClassName} extends {BaseClass}
# 导出配置
@export var {prop}: {Type} = {default}
# 私有变量
var _{prop}: {Type}
# 状态查询方法
func get_{property}() -> {Type}:
func is_{condition}() -> bool:
# 动作方法
func {method}({params}) -> {ReturnType}:
# 信号
signal {signal_name}({params})
2.2 接口清单
| 类 | 方法/属性 | 类型 | 说明 |
|---|
2.3 状态机设计
| 当前状态 | 操作 | 下一状态 |
3. 版本历史
| 版本 | 日期 | 变更说明 |
|---|---|---|
| v1.0 | 初始版本 |
#### test_cases.md 模板
```markdown
# {功能名} 测试用例
## 1. 测试类
`test_{feature}.gd`
## 2. 等价类划分
| 输入类型 | 有效类 | 无效类 |
|---------|-------|-------|
| {param} | 1-100 | <1, >100 |
## 3. 边界值测试
| 用例 | 输入 | 预期 |
|-----|------|------|
| test_{name} | {value} | {result} |
## 4. 状态转换测试
| 当前状态 | 操作 | 下一状态 |
## 5. 版本历史
| 版本 | 日期 | 变更说明 |
|-----|------|---------|
| v1.0 | | 初始版本 |
接口修改规则
核心原则:实现必须严格遵循设计定义的接口
允许修改接口的流程
发现需要修改接口
↓
┌─────────────────────────────────────────────┐
│ 不能直接修改代码!循环到设计阶段 │
│ ↓ │
│ 1. 在 design.md 中更新接口定义 │
│ 2. 更新 test_cases.md (如果需要) │
│ 3. 用户评审设计变更 │
│ 4. 设计确认后重新实现 │
└─────────────────────────────────────────────┘
禁止行为
- ❌ 直接在实现代码中修改接口签名
- ❌ 跳过设计阶段直接添加方法
- ❌ 忽略设计文档进行编码
Phase 2: 测试开发 (Test)
前置条件
安装 GUT 插件:
cd your-project/addons
git clone https://github.com/bitwes/Gut.git gut
测试脚本结构
extends GutTest
func before_all():
# 所有测试前执行一次
pass
func test_my_functionality():
# 测试代码
assert_eq(result, expected, "描述")
func before_each():
# 每个测试前执行
func after_each():
# 每个测试后执行
常用断言
assert_eq(actual, expected, message)
assert_ne(a, b, message)
assert_true(condition, message)
assert_false(condition, message)
assert_null(value, message)
assert_not_null(value, message)
assert_between(value, min_val, max_val, message)
测试命名约定
# 文件命名
test_player.gd
test_math_utils.gd
# 方法命名
func test_player_take_damage_reduces_health():
...
参数化测试
func get_damage_test_params():
return [
["damage_10", 10, 90],
["damage_50", 50, 50],
["damage_overkill", 1000, 0],
]
func test_params_damage(data = get_damage_test_params()):
var amount = data[1]
var expected = data[2]
var player = Player.new()
player.take_damage(amount)
assert_eq(player.get_health(), expected)
测试 Autoload 单例
GUT 支持加载 Autoload 单例,并提供专门的 API 进行 Mock/Stub。
访问单例
extends GutTest
# GUT 默认会加载项目的所有 autoload
func test_access_singleton():
# 直接访问单例(使用真实实例)
var score = GameManager.get_score()
assert_eq(score, 0)
Mock 单例(Double)
# 创建单例的完整 Double
func test_with_singleton_double():
var gm = double_singleton("GameManager")
# Stub 方法
stub(gm, "get_score").to_return(100)
# 测试代码
assert_eq(gm.get_score(), 100)
Partial Double 单例(推荐)
# 使用 Partial Double 保留原始实现
func test_with_partial_singleton_double():
var gm = partial_double_singleton("GameManager")
# 只 stub 需要的方法,其他方法保持原始行为
stub(gm, "reset_game").to_do_nothing()
# 保留原始方法行为
assert_eq(gm.get_level(), 1)
assert_true(gm.is_game_active())
完整示例
extends GutTest
# 测试 Player 与 GameManager 单例的交互
func test_player_death_updates_game_manager():
# 使用 partial double 保留大部分行为
var gm = partial_double_singleton("GameManager")
stub(gm, "on_player_died").to_do_nothing()
var player = Player.new()
player.take_damage(100)
# 验证调用了单例方法
assert_called(gm, "on_player_died")
单例测试最佳实践
| 场景 | 使用方法 |
|---|---|
| 只需读取单例状态 | 直接访问真实单例 |
| 需要控制单例返回值 | double_singleton() |
| 需要保留大部分行为 | partial_double_singleton()(推荐) |
| 验证方法调用 | assert_called() + spy |
测试检查清单
- 测试能运行
- 测试失败(预期行为,尚未实现)
- 命名符合约定:
test_<类名>_<方法名>_<场景>
Phase 3: 代码实现 (Implement)
GDScript 风格规则
类型注解
# 函数参数和返回值(强制)
func move_to(target: Vector2) -> void:
...
func calculate_damage(base: float, multiplier: float) -> float:
return base * multiplier
# 变量类型注解
var health: int = 100
var speed: float = 300.0
var position: Vector2 = Vector2.ZERO
属性可见性
# 私有属性(使用下划线前缀)
var _health: int = 100
var _velocity: Vector2 = Vector2.ZERO
# 只读属性(使用 getter)
var health: int:
get:
return _health
# 导出变量(编辑器配置)
@export var max_health: int = 100
@export var move_speed: float = 300.0
常量命名
const MAX_HEALTH := 100
const MOVE_SPEED := 300.0
const GRAVITY := 980.0
Godot 特性使用
Signal(观察者模式)
signal health_changed(new_health: int)
func take_damage(amount: int) -> void:
_health = max(0, _health - amount)
health_changed.emit(_health)
func _ready() -> void:
health_changed.connect(_on_health_changed)
@onready(节点引用)
extends Node2D
@onready var sprite: Sprite2D = $Sprite2D
@onready var animation: AnimationPlayer = $AnimationPlayer
@export(编辑器配置)
@export var max_health: int = 100
@export var move_speed: float = 300.0
# 资源类型
@export var bullet_scene: PackedScene
# 枚举类型
@export_enum("Fast", "Normal", "Slow") var speed_mode: String = "Normal"
常见反模式
| 反模式 | 错误示例 | 正确做法 |
|---|---|---|
| 直接暴露内部变量 | var health: int = 100 |
var _health; func get_health() |
| 缺少类型注解 | func move_to(target): |
func move_to(target: Vector2) |
| 不使用 @onready | get_node("Sprite2D") |
@onready var sprite = $Sprite2D |
| 测试逻辑污染 | if is_in_test_mode: |
测试逻辑完全分离 |
实现检查清单
- 所有函数有类型注解
- 变量有类型注解
- 私有属性使用下划线前缀
- 使用 @onready 而非 get_node()
- 信号正确解耦
- 避免循环依赖
Phase 4: 质量验证 (Verify)
可用工具
| 工具 | 用途 | 参数 |
|---|---|---|
gdlint |
Lint GDScript 文件 | project, file, all |
gdformat |
格式化/检查 GDScript | project, file, check |
godot_get_errors |
解析错误日志 | project, log_file |
godot_check_all |
运行所有检查 | project, file |
Lint 规则
| 规则 | 严重性 | 描述 |
|---|---|---|
unused-variable |
Error | 变量声明但未使用 |
shadowed-variable |
Error | 变量遮蔽成员变量 |
function-name |
Error | 函数名违反命名约定 |
trailing-whitespace |
Warning | 行尾有空白 |
missing-docstring |
Warning | 函数缺少文档 |
line-too-long |
Warning | 行超过 120 字符 |
使用示例
// 运行完整项目检查
{ "project": "D:/path/to/godot-project" }
// 检查单个文件
{ "project": "D:/path/to/godot-project", "file": "D:/path/to/script.gd" }
// 仅检查格式(不修改)
{ "project": "D:/path/to/godot-project", "file": "D:/path/to/script.gd", "check": true }
验证检查清单
- lint 通过
- format 通过
- 无错误
Phase 5: 运行测试 (Run Tests)
命令格式
godot --path <project> -s addons/gut/gut_cmdln.gd [options]
可用选项
| 选项 | 描述 |
|---|---|
-gdir=<path> |
测试目录(默认:res://test/) |
-gtest=<path> |
特定测试文件路径 |
-gselect=<pattern> |
按名称模式匹配测试文件 |
-gunit_test_name=<name> |
运行特定测试方法 |
-gexit |
测试完成后退出 |
-glog=<0-3> |
日志级别 |
示例
# 运行所有测试
godot --path . -s addons/gut/gut_cmdln.gd -gexit
# 运行特定测试文件
godot --path . -s addons/gut/gut_cmdln.gd -gtest=res://test/test_player.gd -gexit
# 按模式运行测试
godot --path . -s addons/gut/gut_cmdln.gd -gselect=player -gexit
测试结果解读
返回码:
0= 所有测试通过1= 部分测试失败
输出格式:
res://test/test_example.gd
* test_should_pass
* test_another_pass
[2/2 passed]
Totals
------
Scripts: 1
Tests: 2
Passing: 2
Failing: 0
运行测试检查清单
- 所有测试通过
- 覆盖率满足要求
完整示例
需求:Player 受伤系统
{
"skill": "godot-tdd-full",
"action": "start",
"feature": "Player受伤系统",
"description": "实现 Player 受伤后扣除血量并发出信号的功能"
}
Phase 1: 设计
# Player.gd (接口定义)
class_name Player extends Node
signal health_changed(new_health: int)
signal died()
func get_health() -> int:
...
func take_damage(amount: int) -> void:
...
Phase 2: 测试
# test_player.gd
extends GutTest
func test_player_take_damage_reduces_health():
var player = Player.new()
player.take_damage(10)
assert_eq(player.get_health(), 90)
func test_player_dies_when_health_reaches_zero():
var player = Player.new()
player.take_damage(100)
assert_false(player.is_alive())
Phase 3: 实现
# Player.gd (完整实现)
class_name Player extends Node
var _health: int = 100
signal health_changed(new_health: int)
signal died()
func get_health() -> int:
return _health
func is_alive() -> bool:
return _health > 0
func take_damage(amount: int) -> void:
_health = max(0, _health - amount)
health_changed.emit(_health)
if _health == 0:
died.emit()
Phase 4: 验证
{ "project": "D:/path/to/project", "file": "D:/path/to/Player.gd" }
Phase 5: 运行测试
godot --path . -s addons/gut/gut_cmdln.gd -gtest=res://test/test_player.gd -gexit
循环依赖处理
当脚本存在循环依赖时:
# 错误:循环依赖
func register_character(character: Character2D, height_x: float):
# 解决:使用基类参数
func register_character(character: Node, height_x: float):
最佳实践
- 测试隔离 - 每个测试独立,不依赖其他测试
- 描述性命名 -
test_player_jump_calculation而不是test1 - 单一断言 - 每个测试验证一个具体行为
- 清理资源 - 测试后调用
queue_free() - 浮点数比较 - 使用
is_equal_approx()避免精度问题 - 只测试公共接口 - 通过设计阶段定义的接口测试
故障排除
| 错误 | 解决方案 |
|---|---|
File not found: addons/gut/gut_cmdln.gd |
安装 GUT 插件 |
Nonexistent function 'is_above' |
修复脚本循环依赖 |
Cannot call non-static function on class |
检查 autoload 配置 |
GUT class_names not imported |
先运行 godot --import |
| 测试未找到 | 检查 -gdir 路径和 -gprefix 设置 |
overlaps_body 类型错误 |
PhysicsBody 用 overlaps_body,Area2D 用 overlaps_area |
注意事项
-
每个阶段必须通过检查点才能进入下一阶段
- Phase 1: 设计完成并经用户确认
- Phase 2: 测试能运行且失败(预期)
- Phase 3: 测试通过
- Phase 4: lint/format 检查通过
- Phase 5: 所有测试通过
-
重构必须确保测试通过
- 重构前确保所有测试通过
- 重构后运行测试验证
- 保持测试覆盖率
-
所有代码修改后必须运行完整验证
- 静态检查:gdlint + gdformat
- 运行时检查:GUT 测试(捕获动态错误)
- 两者缺一不可
-
测试命名遵循约定
- 文件命名:
test_<类名>.gd - 方法命名:
test_<类名>_<方法名>_<场景>
- 文件命名:
-
接口契约不可破坏
- 实现必须严格遵循设计定义的接口
- 如需修改接口,必须返回 Phase 1 更新设计文档
- 禁止直接在实现代码中修改接口签名
-
测试覆盖 = 被测模块 + 它调用的所有依赖
- 修改 Manager.gd → 测试 Manager 内部逻辑 + 所有公开 API
- 修改 UI.gd → 测试 UI 逻辑 + 它调用的 Manager 方法
- 新增交互 → 测试两个模块的集成
Weekly Installs
1
Repository
chen19007/my_skillsFirst Seen
6 days ago
Security Audits
Installed on
zencoder1
amp1
cline1
openclaw1
opencode1
cursor1