游戏测试

游戏测试

游戏测试(Game Test)是在游戏内运行单元测试的方式。该系统可扩展且支持并行,能高效运行大量不同测试。测试对象交互与行为,只是这套框架的众多用途之一。它既可以完全由代码实现,也可以通过数据包实现,下面将介绍这两种方式。

创建游戏测试

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

  1. 加载包含测试场景的结构(也称模板),在其中测试交互或行为。
  2. 准备供测试运行的环境。
  3. 使用已注册函数运行测试逻辑。达到成功状态时测试通过;否则测试失败,结果存放在场景旁的讲台中。
  4. 使用测试实例,将以上三个对象关联起来。

测试数据

所有测试实例都持有某个 TestData,定义游戏测试的运行方式,包括初始配置、环境和所用结构模板。TestData 通过 MapCodec 序列化,因此这些数据会与其他实例专用参数一同存储在文件根层级。

JSON

// For some game test examplemod:example_test
// In 'data/examplemod/test_instance/example_test.json'
{
    // `TestData`

    // The environment to run the test in
    // Points to 'data/examplemod/test_environment/example_environment.json'
    "environment": "examplemod:example_environment",

    // The structure used for the game test
    // Points to 'data/examplemod/structure/example_structure.nbt'
    "structure": "examplemod:example_structure",

    // The number of ticks that the game test will run until it automatically fails
    "max_ticks": 400,

    // The number of ticks that are used to setup everything required for the game test
    // This is not counted towards the maximum number of ticks the test can take
    // If not specified, defaults to 0
    "setup_ticks": 50,

    // Whether the test is required to succeed to mark the batch run as successful
    // If not specified, defaults to true
    "required": true,

    // Specifies how the structure and all subsequent helper methods should be rotated for the test
    // If not specified, nothing is rotated
    // Can be 'none', 'clockwise_90', '180', 'counterclockwise_90'
    "rotation": "clockwise_90",

    // When true, the test can only be ran through the `/test` command
    // If not specified, defaults to false
    "manual_only": true,

    // Specifies the maximum number of times that the test can be reran
    // If not specified, defaults to 1
    "max_attempts": 3,

    // Specifies the minimum number of successes that must occur for a test to be marked as successful
    // This must be less than or equal to the maximum number of attempts allowed
    // If not specified, defaults to 1
    "required_successes": 1,

    // Returns whether the structure boundary should keep the top empty
    // This is currently only used in block-based test instances
    // If not specified, defaults to false 
    "sky_access": false

    // ...
}

数据生成

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_INSTANCE, bootstrap -> {
            // Use this to get the test environments
            HolderGetter<TestEnvironmentDefinition<?>> environments = bootstrap.lookup(Registries.TEST_ENVIRONMENT);

            // Register a game test
            // Any fields not relevant to the test data are hidden
            bootstrap.register(..., new FunctionGameTestInstance(...,
                new TestData<>(
                    // The environment to run the test in
                    // Points to 'data/examplemod/test_environment/example_environment.json'
                    environments.getOrThrow(EXAMPLE_ENVIRONMENT),

                    // The structure used for the game test
                    // Points to 'data/examplemod/structure/example_structure.nbt'
                    Identifier.fromNamespaceAndPath("examplemod", "example_structure"),

                    // The number of ticks that the game test will run until it automatically fails
                    400,

                    // The number of ticks that are used to setup everything required for the game test
                    // This is not counted towards the maximum number of ticks the test can take
                    // If not specified, defaults to 0
                    50,

                    // Whether the test is required to succeed to mark the batch run as successful
                    // If not specified, defaults to true
                    true,

                    // Specifies how the structure and all subsequent helper methods should be rotated for the test
                    // If not specified, nothing is rotated
                    // Can be 'none', 'clockwise_90', '180', 'counterclockwise_90'
                    Rotation.CLOCKWISE_90,

                    // When true, the test can only be ran through the `/test` command
                    // If not specified, defaults to false
                    true,

                    // Specifies the maximum number of times that the test can be reran
                    // If not specified, defaults to 1
                    3,

                    // Specifies the minimum number of successes that must occur for a test to be marked as successful
                    // This must be less than or equal to the maximum number of attempts allowed
                    // If not specified, defaults to 1
                    1,

                    // Returns whether the structure boundary should keep the top empty
                    // This is currently only used in block-based test instances
                    // If not specified, defaults to false 
                    false
                )
            ));
        })
    );
}

结构模板

游戏测试在由结构(或模板)加载的场景中执行。所有模板都定义场景尺寸,以及初始加载的数据(方块和实体)。模板必须以 .nbt 文件存放在 data/<namespace>/structure 中。TestData#structure 使用相对 Identifier 引用 NBT 文件,例如 examplemod:example_structure 指向 data/examplemod/structure/example_structure.nbt。

测试环境

所有游戏测试都在某个 TestEnvironmentDefinition 中运行,由它决定如何设置当前 ServerLevel。测试完成后会清理环境,让后续一个或多个实例运行。环境会按批次处理:具有相同环境的多个测试实例会同时运行。测试环境文件位于 data/<namespace>/test_environment/<path>.json。

原版提供了不会修改 ServerLevel 的 minecraft:default。此外,还支持以下定义类型来构建环境。

游戏规则

此环境类型设置测试使用的游戏规则。清理环境时,游戏规则会恢复默认值。

JSON

// examplemod:example_environment
// In 'data/examplemod/test_environment/example_environment.json'
{
    "type": "minecraft:game_rules",

    // A map of game rules to their set values
    "rules": {
        "minecraft:fire_damage": false,
        "minecraft:players_sleeping_percentage": 50
    }
}

数据生成

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_ENVIRONMENT, bootstrap -> {

            // Register the environment
            bootstrap.register(
                EXAMPLE_ENVIRONMENT,
                new TestEnvironmentDefinition.SetGameRules(
                    new GameRuleMap.Builder()
                        // A map of game rules to their set values
                        .set(GameRules.FIRE_DAMAGE, false)
                        .set(GameRules.PLAYERS_SLEEPING_PERCENTAGE, 50)
                        .build()
                )
            );
        })
    );
}

时钟时间

此环境类型将指定 WorldClock 的时间设为某个非负整数,类似使用 /time of <clock> set <number> 命令。

JSON

// examplemod:example_environment
// In 'data/examplemod/test_environment/example_environment.json'
{
    "type": "minecraft:clock_time",

    // The clock to set the time of
    // Points to a registered clock at `data/<namespace>/world_clock/<path>.json`
    "clock": "minecraft:overworld",

    // Sets the time of the clock
    "time": 13000
}

数据生成

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_ENVIRONMENT, bootstrap -> {
            // Getting clocks
            HolderGetter<WorldClock> clocks = bootstrap.lookup(Registries.WORLD_CLOCK);

            // Register the environment
            bootstrap.register(
                EXAMPLE_ENVIRONMENT,
                new TestEnvironmentDefinition.ClockTime(
                    // The clock to set the time of
                    clocks.getOrThrow(WorldClocks.OVERWORLD),
                    // Sets the time of the clock
                    13000
                )
            );
        })
    );
}

时间线属性

此环境类型设置要应用于世界环境属性的时间线。

JSON

// examplemod:example_environment
// In 'data/examplemod/test_environment/example_environment.json'
{
    "type": "minecraft:timeline_attributes",

    // The timelines to apply to the level
    "timelines": [
        "minecraft:day",
        "minecraft:moon"
    ]
}

数据生成

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_ENVIRONMENT, bootstrap -> {
            // Getting timelines
            HolderGetter<Timeline> timelines = bootstrap.lookup(Registries.TIMELINE);

            // Register the environment
            bootstrap.register(
                EXAMPLE_ENVIRONMENT,
                new TestEnvironmentDefinition.Timelines(
                    // The timelines to apply to the level
                    List.of(
                        timelines.getOrThrow(Timelines.OVERWORLD_DAY),
                        timelines.getOrThrow(Timelines.MOON)
                    )
                )
            );
        })
    );
}

天气

此环境类型设置天气,类似使用 /weather 命令。

JSON

// examplemod:example_environment
// In 'data/examplemod/test_environment/example_environment.json'
{
    "type": "minecraft:weather",

    // Can be one of three values:
    // - clear   (No weather)
    // - rain    (Rain)
    // - thunder (Rain and thunder)
    "weather": "thunder"
}

数据生成

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_ENVIRONMENT, bootstrap -> {

            // Register the environment
            bootstrap.register(
                EXAMPLE_ENVIRONMENT,
                new TestEnvironmentDefinition.Weather(
                    // Can be one of three values:
                    // - clear   (No weather)
                    // - rain    (Rain)
                    // - thunder (Rain and thunder)
                    TestEnvironmentDefinition.Weather.Type.THUNDER
                )
            );
        })
    );
}

Minecraft 函数

此环境类型提供两个指向 mcfunction 的标识符,分别用于设置和清理世界。

JSON

// examplemod:example_environment
// In 'data/examplemod/test_environment/example_environment.json'
{
    "type": "minecraft:function",

    // The setup mcfunction to use
    // If not specified, nothing will be ran
    // Points to 'data/examplemod/function/example/setup.mcfunction'
    "setup": "examplemod:example/setup",

    // The teardown mcfunction to use
    // If not specified, nothing will be ran
    // Points to 'data/examplemod/function/example/teardown.mcfunction'
    "teardown": "examplemod:example/teardown"
}

数据生成

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_ENVIRONMENT, bootstrap -> {

            // Register the environment
            bootstrap.register(
                EXAMPLE_ENVIRONMENT,
                new TestEnvironmentDefinition.Functions(
                    // The setup mcfunction to use
                    // If not specified, nothing will be ran
                    // Points to 'data/examplemod/function/example/setup.mcfunction'
                    Optional.of(Identifier.fromNamespaceAndPath("examplemod", "example/setup")),

                    // The teardown mcfunction to use
                    // If not specified, nothing will be ran
                    // Points to 'data/examplemod/function/example/teardown.mcfunction'
                    Optional.of(Identifier.fromNamespaceAndPath("examplemod", "example/teardown"))
                )
            );
        })
    );
}

组合环境

组合环境类型可以合并多个环境。定义列表可以包含对现有定义的引用,也可以包含内联定义。

JSON

// examplemod:example_environment
// In 'data/examplemod/test_environment/example_environment.json'
{
    "type": "minecraft:all_of",

    // A list of test environments to use
    // Can either specified the registry name or the environment itself
    "definitions": [
        // Points to 'data/minecraft/test_environment/default.json'
        "minecraft:default",
        {
            // A raw environment definition
            "type": "..."
        }
        // ...
    ]
}

数据生成

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_ENVIRONMENT, bootstrap -> {
            // Getting existing environments
            HolderGetter<TestEnvironmentDefinition<?>> environments = bootstrap.lookup(Registries.TEST_ENVIRONMENT);

            // Register the environment
            bootstrap.register(
                EXAMPLE_ENVIRONMENT,
                new TestEnvironmentDefinition.AllOf(
                    List.of(
                        // Points to 'data/minecraft/test_environment/default.json'
                        environments.getOrThrow(GameTestEnvironments.DEFAULT_KEY),
                        Holder.direct(
                            // Create a new TestEnvironmentDefinition here
                            ...
                        )
                        // ...
                    )
                )
            );
        })
    );
}

自定义定义类型

自定义 TestEnvironmentDefinition<SavedDataType> 类型提供三个方法:setup 修改 ServerLevel 并返回之前的 SavedDataType 泛型状态;teardown 使用 SavedDataType 恢复被修改的内容;codec 提供用于编解码该类型的 MapCodec:

public record ExampleEnvironmentType(int value1, boolean value2) implements TestEnvironmentDefinition<Pair<Integer, Boolean>> {

    // Construct the map codec to register
    public static final MapCodec<ExampleEnvironmentType> CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
            Codec.INT.fieldOf("value1").forGetter(ExampleEnvironmentType::value1),
            Codec.BOOL.fieldOf("value2").forGetter(ExampleEnvironmentType::value2)
        ).apply(instance, ExampleEnvironmentType::new)
    );

    @Override
    public Pair<Integer, Boolean> setup(ServerLevel level) {
        // Setup whatever is necessary here
        // return the original values of the modified level data
    }

    @Override
    public void teardown(ServerLevel level, Pair<Integer, Boolean> originalState) {
        // Undo whatever was changed within the setup method
        // This use the original state to reset the data
    }

    @Override
    public MapCodec<ExampleEnvironmentType> codec() {
        return CODEC;
    }
}

接着,可以注册该 MapCodec:

public static final DeferredRegister<MapCodec<? extends TestEnvironmentDefinition>> TEST_ENVIRONMENT_DEFINITION_TYPES = DeferredRegister.create(
        BuiltInRegistries.TEST_ENVIRONMENT_DEFINITION_TYPE,
        "examplemod"
);

public static final Supplier<MapCodec<ExampleEnvironmentType>> EXAMPLE_ENVIRONMENT_CODEC = TEST_ENVIRONMENT_DEFINITION_TYPES.register(
    "example_environment_type",
    () -> ExampleEnvironmentType.CODEC
);

最后,就可以在环境定义中使用此类型:

JSON

// examplemod:example_environment
// In 'data/examplemod/test_environment/example_environment.json'
{
    "type": "examplemod:example_environment_type",

    "value1": 0,
    "value2": true
}

数据生成

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_ENVIRONMENT, bootstrap -> {

            // Register the environment
            bootstrap.register(
                EXAMPLE_ENVIRONMENT,
                new ExampleEnvironmentType(
                    0, true
                )
            );
        })
    );
}

测试函数

游戏测试的基本概念围绕一个接收 GameTestHelper、不返回值的方法展开。调用 GameTestHelper 中的方法,决定测试成功还是失败。每个测试函数都需要注册,以便在测试实例中引用:

public class ExampleFunctions {

    // Here is our example function
    public static void exampleTest(GameTestHelper helper) {
        // Do Stuff
    }
}

// Register our function for use
public static final DeferredRegister<Consumer<GameTestHelper>> TEST_FUNCTION = DeferredRegister.create(
        BuiltInRegistries.TEST_FUNCTION,
        "examplemod"
);

public static final DeferredHolder<Consumer<GameTestHelper>, Consumer<GameTestHelper>> EXAMPLE_FUNCTION = TEST_FUNCTION.register(
    "example_function",
    () -> ExampleFunctions::exampleTest
);

相对定位

所有测试函数都根据结构方块当前位置,将结构模板场景中的相对坐标转换为绝对坐标。为方便在相对和绝对位置间转换,可以分别使用 GameTestHelper#absolutePos 和 GameTestHelper#relativePos。

在游戏内,可以通过测试命令加载结构,将玩家移动到目标位置,再运行 /test pos,获取结构模板内的相对位置。该命令会根据玩家 200 格以内最近的结构,获取玩家的相对坐标,并在聊天中输出可复制的文本组件,供用作 final 局部变量。

建议 在 /test pos 末尾追加名称,可以指定生成局部变量的引用名称:

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

成功完成

测试函数负责一件事:在满足有效完成条件时,将测试标记为成功。如果在 TestData#maxTicks 定义的超时前没有达到成功状态,测试就会自动失败。

GameTestHelper 中有许多用于定义成功状态的抽象方法,其中四个尤其重要。

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

注意 游戏测试会每 tick 执行,直到被标记为成功。因此,安排在指定 tick 成功的方法,必须确保在此前所有 tick 都失败。

调度操作

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

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

断言

游戏测试期间可以随时断言,检查某个条件是否为真。GameTestHelper 包含众多断言方法,基本原则是在不满足相应状态时抛出 GameTestAssertException。

注册测试实例

准备好 TestData、TestEnvironmentDefinition 和测试函数后,就可以通过 GameTestInstance 将它们关联起来。每个测试实例代表一个待运行的游戏测试。所有实例位于 data/<namespace>/test_instance/<path>.json。

基于函数的测试

FunctionGameTestInstance 将 TestData 关联到已注册的测试函数。实例被调用时,会运行该测试函数。

JSON

// For some game test examplemod:example_test
// In 'data/examplemod/test_instance/example_test.json'
{
    // `TestData`

    "environment": "examplemod:example_environment",
    "structure": "examplemod:example_structure",
    "max_ticks": 400,
    "setup_ticks": 50,
    "required": true,
    "rotation": "clockwise_90",
    "manual_only": true,
    "max_attempts": 3,
    "required_successes": 1,
    "sky_access": false,

    // `FunctionGameTestInstance`
    "type": "minecraft:function",

    // Points to a 'Consumer<GameTestHelper>' in the test function registry
    "function": "examplemod:example_function"
}

数据生成

// The test instance key
public static final ResourceKey<GameTestInstance> EXAMPLE_TEST_INSTANCE = ResourceKey.create(
    Registries.TEST_INSTANCE,
    Identifier.fromNamespaceAndPath("examplemod", "example_test")
);

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_INSTANCE, bootstrap -> {
            // Use this to get the test environments
            HolderGetter<TestEnvironmentDefinition<?>> environments = bootstrap.lookup(Registries.TEST_ENVIRONMENT);

            // Register a game test
            // Any fields not relevant to the test data are hidden
            bootstrap.register(EXAMPLE_TEST_INSTANCE,
                new FunctionGameTestInstance(
                    // Points to a 'Consumer<GameTestHelper>' in the test function registry
                    EXAMPLE_FUNCTION.getKey()
                    new TestData<>(
                        environments.getOrThrow(EXAMPLE_ENVIRONMENT),
                        Identifier.fromNamespaceAndPath("examplemod", "example_structure"),
                        400,
                        50,
                        true,
                        Rotation.CLOCKWISE_90,
                        true,
                        3,
                        1,
                        false
                    )
            ));
        })
    );
}

基于方块的测试

BlockBasedTestInstance 是依赖 Blocks#TEST_BLOCK 收发红石信号的特殊测试实例。结构模板必须至少包含两个测试方块:恰好一个设为 TestBlockMode#START,另一个设为 TestBlockMode#ACCEPT。测试开始时,起始测试方块被触发,发出持续一个 tick、强度 15 的脉冲。预期该信号最终触发处于 LOG、FAIL 或 ACCEPT 状态的其他测试方块。LOG 方块激活时也会发出强度 15 的脉冲。ACCEPT 和 FAIL 方块分别使实例成功或失败。同一 tick 中,ACCEPT 始终优先于 FAIL。

JSON

// For some game test examplemod:example_test
// In 'data/examplemod/test_instance/example_test.json'
{
    // `TestData`

    "environment": "examplemod:example_environment",
    "structure": "examplemod:example_structure",
    "max_ticks": 400,
    "setup_ticks": 50,
    "required": true,
    "rotation": "clockwise_90",
    "manual_only": true,
    "max_attempts": 3,
    "required_successes": 1,
    "sky_access": false,

    // `BlockBasedTestInstance`
    "type": "minecraft:block_based"
}

数据生成

// The test instance key
public static final ResourceKey<GameTestInstance> EXAMPLE_TEST_INSTANCE = ResourceKey.create(
    Registries.TEST_INSTANCE,
    Identifier.fromNamespaceAndPath("examplemod", "example_test")
);

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_INSTANCE, bootstrap -> {
            // Use this to get the test environments
            HolderGetter<TestEnvironmentDefinition<?>> environments = bootstrap.lookup(Registries.TEST_ENVIRONMENT);

            // Register a game test
            // Any fields not relevant to the test data are hidden
            bootstrap.register(EXAMPLE_TEST_INSTANCE,
                new BlockBasedTestInstance(
                    new TestData<>(
                        environments.getOrThrow(EXAMPLE_ENVIRONMENT),
                        Identifier.fromNamespaceAndPath("examplemod", "example_structure"),
                        400,
                        50,
                        true,
                        Rotation.CLOCKWISE_90,
                        true,
                        3,
                        1,
                        false
                    )
            ));
        })
    );
}

自定义测试实例

如果需要自行实现测试逻辑,可以继承 GameTestInstance。必须实现两个方法:表示测试函数的 run,以及提供实例描述的 typeDescription。如果需要在数据生成中使用该实例,它就必须有一个需要注册的 MapCodec。

public class ExampleTestInstance extends GameTestInstance {

    public ExampleTestInstance(int value1, boolean value2, TestData<Holder<TestEnvironmentDefinition>> info) {
        super(info);
    }

    @Override
    public void run(GameTestHelper helper) {
        // Run whatever game test commands you want
        helper.assertBlockPresent(...);

        // Make sure you have some way to succeed
        helper.succeedIf(() -> ...);
    }

    @Override
    public MapCodec<ExampleTestInstance> codec() {
        return EXAMPLE_INSTANCE_CODEC.get();
    }

    @Override
    protected MutableComponent typeDescription() {
        // Provides a description about what this test is supposed to be
        // Should use a translatable component
        return Component.literal("Example Test Instance");
    }
}

// Register our test instance for use
public static final DeferredRegister<MapCodec<? extends GameTestInstance>> TEST_INSTANCE = DeferredRegister.create(
        BuiltInRegistries.TEST_INSTANCE_TYPE,
        "examplemod"
);

public static final Supplier<MapCodec<? extends GameTestInstance>> EXAMPLE_INSTANCE_CODEC = TEST_INSTANCE.register(
    "example_test_instance",
    () -> RecordCodecBuilder.mapCodec(instance -> instance.group(
            Codec.INT.fieldOf("value1").forGetter(test -> test.value1),
            Codec.BOOL.fieldOf("value2").forGetter(test -> test.value2),
            TestData.CODEC.forGetter(ExampleTestInstance::info)
        ).apply(instance, ExampleTestInstance::new)
    )
);

接着,就可以在数据包中使用测试实例:

JSON

// For some game test examplemod:example_test
// In 'data/examplemod/test_instance/example_test.json'
{
    // `TestData`

    "environment": "examplemod:example_environment",
    "structure": "examplemod:example_structure",
    "max_ticks": 400,
    "setup_ticks": 50,
    "required": true,
    "rotation": "clockwise_90",
    "manual_only": true,
    "max_attempts": 3,
    "required_successes": 1,
    "sky_access": false,

    // `ExampleTestInstance`
    "type": "examplemod:example_test_instance",

    "value1": 0,
    "value2": true
}

数据生成

// The test instance key
public static final ResourceKey<GameTestInstance> EXAMPLE_TEST_INSTANCE = ResourceKey.create(
    Registries.TEST_INSTANCE,
    Identifier.fromNamespaceAndPath("examplemod", "example_test")
);

// Let's assume we have some test environment
public static final ResourceKey<TestEnvironmentDefinition<?>> EXAMPLE_ENVIRONMENT = ResourceKey.create(
    Registries.TEST_ENVIRONMENT,
    Identifier.fromNamespaceAndPath("examplemod", "example_environment")
);

@SubscribeEvent // on the mod event bus
public static void gatherData(GatherDataEvent.Client event) {
    event.createDatapackRegistryObjects(
        new RegistrySetBuilder().add(Registries.TEST_INSTANCE, bootstrap -> {
            // Use this to get the test environments
            HolderGetter<TestEnvironmentDefinition<?>> environments = bootstrap.lookup(Registries.TEST_ENVIRONMENT);

            // Register a game test
            // Any fields not relevant to the test data are hidden
            bootstrap.register(EXAMPLE_TEST_INSTANCE,
                new ExampleTestInstance(
                    0,
                    true,
                    new TestData<>(
                        environments.getOrThrow(EXAMPLE_ENVIRONMENT),
                        Identifier.fromNamespaceAndPath("examplemod", "example_structure"),
                        400,
                        50,
                        true,
                        Rotation.CLOCKWISE_90,
                        true,
                        3,
                        1,
                        false
                    )
            ));
        })
    );
}

跳过数据包

如果不想通过数据包构建游戏测试,可以监听 Mod 事件总线上的 RegisterGameTestsEvent,分别通过 registerEnvironment 和 registerTest 注册环境与测试实例。

@SubscribeEvent // on the mod event bus
public static void registerTests(RegisterGameTestsEvent event) {
    Holder<TestEnvironmentDefinition<?>> environment = event.registerEnvironment(
        // The name of the test environment
        EXAMPLE_ENVIRONMENT.identifier(),
        // A varargs of test environment definitions
        new ExampleEnvironmentType(
            0, true
        )
    );

    event.registerTest(
        // The name of the test instance
        EXAMPLE_TEST_INSTANCE.identifier(),
        new ExampleTestInstance(
            0,
            true,
            new TestData<>(
                environment,
                Identifier.fromNamespaceAndPath("examplemod", "example_structure"),
                400,
                50,
                true,
                Rotation.CLOCKWISE_90,
                true,
                3,
                1,
                false
            )
        )
    );
}

运行游戏测试

可以使用 /test 命令运行游戏测试。该命令配置灵活,以下子命令与运行测试尤其相关:

子命令 描述
run 运行指定测试:run <test_name>。
runall 运行所有可用测试。
runclosest 运行玩家 15 格内最近的测试。
runthese 运行玩家 200 格以内的测试。
runfailed 运行上一次运行中失败的所有测试。

提示 子命令放在 test 命令之后:/test <subcommand>。

构建脚本配置

游戏测试在构建脚本(build.gradle)中提供额外配置,可运行测试并将其集成到不同环境。

游戏测试服务器运行配置

游戏测试服务器是一种用于运行构建服务器的特殊配置。构建服务器返回的退出码等于必需但失败的游戏测试数量。所有失败的测试(无论是否必需)都会记录到日志。可以使用 gradlew runGameTestServer 运行该服务器。

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

默认情况下,只有 client 和 gameTestServer 运行配置启用游戏测试。要让其他运行配置运行游戏测试,必须将 neoforge.enableGameTest 属性设为 true。

// Inside a run configuration
property 'neoforge.enableGameTest', 'true'

来源: NeoForged/Documentation(26.1)。中文翻译依据提交 4a680cdd4d15。