游戏测试

游戏测试

游戏测试(Game Test)是一种运行游戏内单元测试的方式。该系统设计为可扩展、并行运行,以高效执行大量不同测试。测试对象交互和行为只是这一框架的部分用途。

GameTest 流程:结构模板提供初始场景,带 GameTest 注解的方法接收 GameTestHelper 执行逻辑;测试需要注册,再通过 /test 或 runGameTestServer 运行;succeedIf 立即检查,succeedWhen 每 tick 重试直到成功或超时;图示 tick 为解释条件检查的虚构示意,非运行结果。

原创说明图,依据 Forge 1.21.x 官方文档 绘制。

创建游戏测试

标准游戏测试包含三个基本步骤:

  1. 加载一个结构或模板,其中包含用于测试交互或行为的场景。
  2. 一个方法提供对场景执行的逻辑。
  3. 执行该方法的逻辑。如果达到成功状态,测试成功;否则测试失败,结果保存在场景旁的讲台中。

因此,创建游戏测试需要包含场景初始状态的现有模板,以及提供执行逻辑的方法。

测试方法

游戏测试方法是 Consumer<GameTestHelper> 引用,即接收 GameTestHelper,不返回值。要被识别,方法必须带有 @GameTest 注解:

public class ExampleGameTests {
  @GameTest
  public static void exampleTest(GameTestHelper helper) {
    // 执行操作
  }
}

@GameTest 注解还包含配置测试运行方式的成员:

@GameTest(
  setupTicks = 20L, // 测试花 20 tick 准备执行
  required = false // 失败会被记录,但不影响批次执行
)
public static void exampleConfiguredTest(GameTestHelper helper) {
  // 执行操作
}
相对位置

所有 GameTestHelper 方法都使用结构方块的当前位置,将结构模板场景中的相对坐标转换为绝对坐标。可分别通过 GameTestHelper#absolutePos 和 #relativePos 方便地转换。

在游戏中可通过测试命令加载结构,将玩家放在目标位置,再运行 /test pos,取得玩家相对于 200 格内最近结构的坐标。命令会在聊天中导出可复制文本组件,可用作 final 局部变量。

提示:在 /test pos 命令末尾添加名称,可指定生成局部变量的引用名:

/test pos <var>  # 导出 'final BlockPos <var> = new BlockPos(...);'
成功完成

游戏测试方法负责在有效完成时将测试标记为成功。如果在 GameTest#timeoutTicks 定义的超时之前未达到成功状态,测试自动失败。

GameTestHelper 有许多定义成功状态的抽象方法,以下四个尤其重要:

方法 说明
#succeed 将测试标记为成功。
#succeedIf 立即测试提供的 Runnable,不抛出 GameTestAssertException 即成功;如果当前 tick 未成功,则标记为失败。
#succeedWhen 每 tick 测试提供的 Runnable 直到超时;某个 tick 检查未抛出 GameTestAssertException 即成功。
#succeedOnTickWhen 在指定 tick 测试提供的 Runnable,未抛出 GameTestAssertException 即成功;如果在其他 tick 成功,则标记为失败。

重要:游戏测试每 tick 执行,直到标记为成功。因此,安排在指定 tick 成功的方法必须确保此前每个 tick 都失败。

安排操作

并非所有操作都在测试开始时发生,可安排在特定时间或间隔执行:

方法 说明
#runAtTickTime 在指定 tick 执行。
#runAfterDelay 在当前 tick 之后 x 个 tick 执行。
#onEachTick 每 tick 执行。
断言

游戏测试中的任何时候都可断言某条件是否为真。GameTestHelper 有许多断言方法,其本质是在未达到相应状态时抛出 GameTestAssertException。

生成的测试方法

如果需要动态生成测试方法,可创建测试方法生成器。这类方法不接收参数,返回 TestFunction 集合;必须带有 @GameTestGenerator 注解才会被识别:

public class ExampleGameTests {
  @GameTestGenerator
  public static Collection<TestFunction> exampleTests() {
    // 返回 TestFunction 集合
  }
}
TestFunction

TestFunction 包装 @GameTest 注解中的信息和运行测试的方法。

提示:带 @GameTest 的方法都会通过 GameTestRegistry#turnMethodIntoTestFunction 转换为 TestFunction。不用注解创建时可参考该方法。

批处理

游戏测试可以按批次执行,而非按注册顺序执行。提供相同的 GameTest#batch 字符串即可将测试加入同一批次。

批处理本身没有额外作用,但可以对测试运行的当前维度执行准备和清理。为方法添加 @BeforeBatch 注解进行准备,或添加 @AfterBatch 注解进行清理。其 #batch 值必须与游戏测试提供的字符串一致。

批处理方法是 Consumer<ServerLevel> 引用,即接收 ServerLevel,不返回值:

public class ExampleGameTests {
  @BeforeBatch(batch = "firstBatch")
  public static void beforeTest(ServerLevel level) {
    // 执行准备
  }
  @GameTest(batch = "firstBatch")
  public static void exampleTest2(GameTestHelper helper) {
    // 执行操作
  }
}

注册游戏测试

游戏测试必须注册才能在游戏中运行。可通过 @GameTestHolder 注解或 RegisterGameTestsEvent 注册;两种方式都仍要求测试方法带 @GameTest、@GameTestGenerator、@BeforeBatch 或 @AfterBatch 注解。

GameTestHolder

@GameTestHolder 注册其类型(类、接口、枚举或 record)中的测试方法。该注解的单个方法有多种用途;在此,提供的 #value 必须是模组 ID,否则默认配置下测试不会运行。

@GameTestHolder(MODID)
public class ExampleGameTests {
  // ...
}

RegisterGameTestsEvent

RegisterGameTestsEvent 也可通过 #register 注册类或方法。事件监听器必须添加到模组事件总线。以此方式注册的测试,必须在每个 @GameTest 方法的 templateNamespace 中提供模组 ID。

public void registerTests(RegisterGameTestsEvent event) {
  event.register(ExampleGameTests.class);
}
// 在 ExampleGameTests 中
@GameTest(templateNamespace = MODID)
public static void exampleTest3(GameTestHelper helper) {
  // 执行准备
}

注意:GameTestHolder#value 和 GameTest#templateNamespace 可以与当前模组 ID 不同,但需要更改构建脚本中的配置。

结构模板

游戏测试在由结构或模板加载的场景中执行。所有模板都定义场景尺寸及待加载的初始数据(方块和实体)。模板必须作为 .nbt 文件保存在 data/<namespace>/structures 中。

提示:可使用结构方块创建并保存结构模板。

模板位置取决于是否指定命名空间、是否在名称前加类名,以及是否指定模板名。命名空间先由 GameTest#templateNamespace 决定;未指定时使用 GameTestHolder#value;两者均未指定时使用 minecraft。

如果对类或带测试注解的方法应用 @PrefixGameTestTemplate 并设为 false,模板名不会加简单类名前缀;否则将小写简单类名加点号放在模板名前。模板名称由 GameTest#template 决定,未指定时使用小写方法名。

// 所有结构的模组 ID 为 MODID
@GameTestHolder(MODID)
public class ExampleGameTests {
  // 加类名前缀,未指定模板名:modid:examplegametests.exampletest
  @GameTest public static void exampleTest(GameTestHelper helper) { /*...*/ }
  // 无类名前缀,未指定模板名:modid:exampletest2
  @PrefixGameTestTemplate(false)
  @GameTest public static void exampleTest2(GameTestHelper helper) { /*...*/ }
  // 加类名前缀,指定模板名:modid:examplegametests.test_template
  @GameTest(template = "test_template")
  public static void exampleTest3(GameTestHelper helper) { /*...*/ }
  // 无类名前缀,指定模板名:modid:test_template2
  @PrefixGameTestTemplate(false)
  @GameTest(template = "test_template2")
  public static void exampleTest4(GameTestHelper helper) { /*...*/ }
}

运行游戏测试

使用 /test 命令运行。该命令有很多配置,以下子命令对运行测试尤其重要:

子命令 说明
run <test_name> 运行指定测试。
runall 运行所有可用测试。
runthis 运行玩家 15 格内最近的测试。
runthese 运行玩家 200 格内的测试。
runfailed 运行上次运行中失败的所有测试。

注意:子命令跟在测试命令后:/test <subcommand>。

构建脚本配置

游戏测试在构建脚本(build.gradle)中提供额外配置,以适应不同运行和集成环境。

启用其他命名空间

如果构建脚本按推荐方式配置,只会启用当前模组 ID 下的测试。要从其他命名空间加载测试,运行配置必须将 forge.enabledGameTestNamespaces 设为逗号分隔各命名空间的字符串。属性为空或未设置时,会加载所有命名空间。

// 在运行配置中
property 'forge.enabledGameTestNamespaces', 'modid1,modid2,modid3'

警告:命名空间之间不能有空格,否则无法正确加载。

游戏测试服务器运行配置

游戏测试服务器是运行构建服务器的特殊配置。构建服务器返回的退出码为失败的必需游戏测试数量;无论必需还是可选,所有失败测试都会记录。可使用 gradlew runGameTestServer 运行。

在其他运行配置中启用游戏测试

默认只有 client、server 和 gameTestServer 运行配置启用游戏测试。若其他运行配置也要运行测试,必须将 forge.enableGameTest 设为 true。

// 在运行配置中
property 'forge.enableGameTest', 'true'

原文:Game Tests。