Skip to main content
Version: 1.21.4

游戏测试(Game Tests)

游戏测试(Game Tests)是一种在游戏内运行单元测试的方式。该系统设计为可扩展且支持并行运行,能够高效地执行大量不同的测试。测试对象之间的交互和行为只是这个框架众多应用场景中的一部分。

创建一个游戏测试(Game Test)​

一个标准的游戏测试通常遵循以下三个基本步骤:

  1. 加载一个结构(structure)或模板(template),用于承载需要进行交互或行为测试的场景。
  2. 用一个方法(method)来实现对该场景进行操作的逻辑。
  3. 执行该方法的逻辑。如果达到了预期的成功状态,则测试通过。否则,测试失败,结果会被存储在场景旁边的一个讲台(lectern)中。

因此,要创建一个游戏测试,必须有一个包含场景初始状态的模板,以及一个实现具体测试逻辑的方法。

测试方法(The Test Method)​

一个游戏测试方法是一个 Consumer<GameTestHelper> 类型的引用,意味着它接收一个 GameTestHelper 参数且不返回任何内容。要让游戏测试方法被识别,必须加上 @GameTest 注解(annotation):

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

@GameTest 注解还包含了一些成员,用于配置该游戏测试的运行方式。

// 在某个类中
@GameTest(
setupTicks = 20L, // 测试在执行前会花费 20 个 tick 进行设置
required = false // 失败时只记录日志,不影响该批次的执行
)
public static void exampleConfiguredTest(GameTestHelper helper) {
// 执行测试相关操作
}

相对定位(Relative Positioning)​

所有 GameTestHelper 的方法都会将结构模板场景中的相对坐标,转换为基于结构方块(structure block)当前位置的绝对坐标。为了便于相对坐标与绝对坐标之间的转换,可以分别使用 GameTestHelper#absolutePos 和 GameTestHelper#relativePos。

结构模板的相对位置可以通过在游戏中使用 test 指令 加载结构、将玩家移动到期望的位置,然后运行 /test pos 指令来获得。该指令会获取距离玩家 200 格范围内最近结构的相对坐标,并以可复制的文本组件形式输出到聊天栏,方便作为最终的本地变量使用。

tip

通过 /test pos 生成的本地变量可以通过在命令末尾添加引用名称来指定变量名:

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

成功完成(Successful Completion)​

一个游戏测试方法的唯一职责是:在达到有效的完成状态时标记测试为成功。如果在超时时间内(由 GameTest#timeoutTicks 定义)未达到成功状态,则测试会自动判定为失败。 GameTestHelper 中包含许多可用于定义测试成功状态的抽象方法;但其中有四个方法尤其重要,需要重点了解。

方法描述
#succeed将测试标记为成功。
#succeedIf立即测试所提供的 Runnable,如果未抛出 GameTestAssertException,则测试成功。如果在当前 tick 未成功,则测试标记为失败。
#succeedWhen每 tick 都会测试所提供的 Runnable,直到超时为止;如果某个 tick 的检查未抛出 GameTestAssertException,则测试成功。
#succeedOnTickWhen在指定的 tick 运行所提供的 Runnable,如果未抛出 GameTestAssertException,则测试成功。如果在其他任意 tick 成功,则测试被标记为失败。
caution

游戏测试会在每一个 tick 被执行,直到测试被标记为成功。因此,那些安排在特定 tick 上成功的方法,必须确保在之前的任何 tick 都会失败。

调度操作(Scheduling Actions)​

并不是所有操作都会在测试开始时立即执行。你可以将操作调度到特定时间点或间隔来执行:

方法描述
#runAtTickTime在指定的 tick 执行该操作。
#runAfterDelay在当前 tick 之后延迟 x 个 tick 执行该操作。
#onEachTick在每一个 tick 执行该操作。

断言(Assertions)​

在游戏测试的任何时刻,都可以进行断言以检查某个条件是否为真。GameTestHelper 提供了多种断言方法;本质上,当状态不符合预期时,这些方法会抛出 GameTestAssertException。

动态生成测试方法(Generated Test Methods)​

如果需要动态生成游戏测试方法,可以创建一个测试方法生成器(test method generator)。这些方法不接收任何参数,并返回一个 TestFunction 的集合。要让测试方法生成器被识别,必须使用 @GameTestGenerator 注解:

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

TestFunction​

TestFunction 是一个包含了 @GameTest 注解信息以及实际运行测试方法的封装对象。

tip

任何使用 @GameTest 注解的方法,都会通过 GameTestRegistry#turnMethodIntoTestFunction 被转换为 TestFunction。你可以参考该方法,手动创建不依赖注解的 TestFunction。

批量执行(Batching)​

游戏测试可以按批次(batch)执行,而不是按照注册顺序执行。只需为测试指定相同的 GameTest#batch 字符串,即可将其归入同一批次。 单独使用批处理(batching)本身并没有什么实际作用。然而,批处理可以用于在当前测试运行的关卡(level)上执行初始化(setup)和清理(teardown)操作。要实现这一点,可以通过为某个方法添加 @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) {
// 执行测试相关操作
}
}

注册游戏测试(Registering a Game Test)​

要在游戏内运行,游戏测试(Game Test)必须先进行注册。有两种注册方式:通过 @GameTestHolder 注解,或者通过 RegisterGameTestsEvent。无论采用哪种注册方式,测试方法都必须使用 @GameTest、@GameTestGenerator、@BeforeBatch 或 @AfterBatch 其中之一进行注解。

GameTestHolder​

@GameTestHolder 注解会注册该类型(类、接口、枚举或 record)中的所有测试方法。@GameTestHolder 只包含一个方法,但用途多样。在这里,提供的 #value 必须是该模组(mod)的 mod id,否则测试在默认配置下不会运行。

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

RegisterGameTestsEvent​

RegisterGameTestsEvent 也可以通过 #register 方法注册类或方法。事件监听器必须被添加到 mod 事件总线(event bus)。以这种方式注册的测试方法,必须为每个带有 @GameTest 注解的方法,通过 GameTest#templateNamespace 指定其 mod id。

// 在某个类中
public void registerTests(RegisterGameTestsEvent event) {
event.register(ExampleGameTests.class);
}

// 在 ExampleGameTests 中
@GameTest(templateNamespace = MODID)
public static void exampleTest3(GameTestHelper helper) {
// 执行初始化操作
}
note

GameTestHolder#value 和 GameTest#templateNamespace 所提供的值可以与当前的 mod id 不同。此时需要在 构建脚本 中进行相应配置。

结构模板(Structure Templates)​

游戏测试(Game Test)是在由结构(structure)或模板(template)加载的场景中执行的。所有模板都定义了场景的尺寸和初始数据(包括方块和实体)。模板必须以 .nbt 文件的形式存储在 data/<namespace>/structure 目录下。

tip

可以使用结构方块(structure block)创建并保存结构模板。

模板的位置由以下几个因素决定:

  • 是否指定了模板的命名空间(namespace)。
  • 是否需要将类名添加在模板名称前。
  • 是否指定了模板的名称。 模板的命名空间(namespace)由 GameTest#templateNamespace 决定;如果未指定,则使用 GameTestHolder#value;如果两者都未指定,则默认为 minecraft。

如果在带有测试注解的类或方法上应用了 @PrefixGameTestTemplate 并将其设置为 false,则模板名称前不会加上简单类名(simple class name)。否则,简单类名会被转换为小写,并在模板名称前加上一个点进行拼接。

模板的名称由 GameTest#template 决定。如果未指定,则使用方法名的小写形式作为模板名。

// 所有结构的 modid 都将为 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) { /*...*/ }
}

运行 Game Test(运行游戏测试)​

可以使用 /test 命令来运行 Game Test(游戏测试)。test 命令具有高度可配置性;不过,只有少数子命令与实际运行测试密切相关:

子命令(Subcommand)说明(Description)
run运行指定的测试:run <test_name>。
runall运行所有可用的测试。
runclosest运行距离玩家 15 格以内最近的测试。
runthese运行距离玩家 200 格以内的所有测试。
runfailed运行上一次测试运行中失败的所有测试。
note

子命令需跟在 test 命令之后使用:/test <subcommand>。

Buildscript 配置(Buildscript Configurations)​

Game Test(游戏测试)在构建脚本(buildscript,即 build.gradle 文件)中提供了额外的配置选项,用于在不同环境下运行和集成测试。

启用其他命名空间(Enabling Other Namespaces)​

如果你的构建脚本已按推荐方式设置,那么只有当前 mod id 下的 Game Test(游戏测试)会被启用。若要让其他命名空间(namespace)下的 Game Test 也被加载,需要在运行配置中设置属性 neoforge.enabledGameTestNamespaces,并将每个命名空间用逗号分隔填写为字符串。如果该属性为空或未设置,则会加载所有命名空间。

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

命名空间之间不能有空格,否则命名空间将无法被正确加载。

Game Test 服务器运行配置(Game Test Server Run Configuration)​

Game Test 服务器是一种特殊配置,用于运行构建服务器。该构建服务器会返回一个退出码,表示必需的、失败的 Game Test 数量。所有失败的测试(无论是否必需)都会被记录到日志中。可以通过 gradlew runGameTestServer 启动此服务器。

关于 NeoGradle 的重要信息
caution

由于 Gradle 的工作机制存在一些特殊情况,默认情况下,如果某个任务强制系统退出,Gradle 守护进程会被终止,导致 Gradle 运行器报告构建失败。NeoGradle 默认会对运行任务设置强制退出,以保证不会顺序执行任何子项目。但因此,Game Test 服务器总是会失败。

你可以通过在运行配置中使用 #setForceExit 方法禁用强制退出来解决这个问题:

// Game Test 服务器运行配置
gameTestServer {
// ...
setForceExit false
}

在其他运行配置中启用 Game Test(Enabling Game Tests in Other Run Configurations)​

默认情况下,只有 client、server 和 gameTestServer 运行配置启用了 Game Test。如果你希望其他运行配置也能运行 Game Test,需要将 neoforge.enableGameTest 属性设置为 true。

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