mcpField
M
minecraft-testing

minecraft-testing

github.com/jahrome907测试与质量192244 次安装2 周前更新
代码仓库

关于 minecraft-testing

为 Minecraft 项目提供自动化测试策略与示例,适合开发模组和插件的工程人员。内容区分纯单元测试、模拟服务器测试、游戏内测试及集成服务器测试,并提醒按加载器和版本隔离测试布局。

主要功能

  1. 01JUnit 5 单元测试指南
  2. 02MockBukkit 插件测试
  3. 03Fabric 与 NeoForge 游戏测试
  4. 04集成服务器测试建议
  5. 05测试布局校验脚本
  6. 06按版本区分 Java 与加载器配置

适用场景

  1. 01为 Paper 插件测试事件、命令和物品栏
  2. 02为模组编写游戏内方块或实体测试
  3. 03将快速测试与加载器游戏测试拆分到 CI

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jahrome907/minecraft-agent-skills --skill minecraft-testing

安装后在 Claude Code 中输入/minecraft-testing确认已加载。

附带文件references/test-layouts.mdscripts/validate-test-layout.sh

详细介绍

Minecraft 测试技能

测试策略概览

方法最适合的场景需要游戏吗?
JUnit 5(纯单元测试)逻辑、数据结构、NBT 序列化不需要
MockBukkitBukkit/Paper 插件事件、命令、物品栏不需要(使用模拟服务)
NeoForge GameTests游戏内方块/实体/世界交互需要(测试环境)
Fabric GameTests游戏内方块/实体/世界交互需要(测试环境)
集成服务完整的插件/模组生命周期需要(专用测试服务)

当前 26.x 项目使用 Java 25。旧版 1.21.x 示例使用 Java 21,Forge 1.20.1 使用 Java 17。不要混用不同版本的源代码布局或 API。

适用范围边界
  • Use when:任务是为 Minecraft 项目设计或实现自动化测试(单元测试、模拟测试、GameTest、CI 测试作业)。
  • Do not use when:任务是实现游戏玩法功能,而非测试(minecraft-modding、minecraft-plugin-dev、minecraft-datapack)。
  • Do not use when:任务是发布自动化或发布流水线(minecraft-ci-release)。

随附参考资料和辅助工具

  • 布局指南:references/test-layouts.md
  • Fixture/布局验证器:./scripts/validate-test-layout.sh --root <project>

将测试布局复制到实际项目之前,请先使用验证器。它会检查可见的静态依赖、元数据和字面结构引用,但不会编译项目,也不能证明 GameTest 可以运行。


单元测试(JUnit 5 — 不需要 Minecraft)

JUnit Platform 任务
tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "skipped", "failed")
    }
}

对于需要加载器设置的 Fabric 代码,请使用 Fabric Loader JUnit,不要假设普通 JUnit 已初始化 Minecraft:

dependencies {
    testImplementation "net.fabricmc:fabric-loader-junit:${project.loader_version}"
}

test {
    useJUnitPlatform()
}

如果单元测试涉及依赖注册表的 Minecraft 类,请在测试设置中仅初始化所需的引导程序。当前 Fabric 指南针对这种情况使用 SharedConstants.tryDetectVersion() 和 Bootstrap.bootStrap()。

纯单元测试示例
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CooldownManagerTest {

    @Test
    void cooldown_tracksPlayersIndependently() {
        var manager = new CooldownManager(500L); // 500ms cooldown
        manager.startCooldown("steve");
        assertTrue(manager.isOnCooldown("steve"));
        assertFalse(manager.isOnCooldown("notExisting"));
    }

    @Test
    void cooldown_throwsIllegalArgument_onNegativeDuration() {
        assertThrows(IllegalArgumentException.class,
            () -> new CooldownManager(-1L));
    }
}

MockBukkit(Paper/Bukkit 插件测试)

build.gradle.kts
repositories {
    maven("https://repo.papermc.io/repository/maven-public/")
    mavenCentral()
}

dependencies {
    compileOnly("io.papermc.paper:paper-api:26.2.build.+")
    testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
    testImplementation("org.mockbukkit.mockbukkit:mockbukkit-v26.2:4.116.1")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
}
设置/清理模式
import org.mockbukkit.mockbukkit.MockBukkit;
import org.mockbukkit.mockbukkit.ServerMock;
import org.mockbukkit.mockbukkit.entity.PlayerMock;
import org.junit.jupiter.api.*;

class MyPluginTest {

    private static ServerMock server;
    private static MyPlugin plugin;

    @BeforeAll
    static void setUp() {
        // Start mock Bukkit server and load your plugin
        server = MockBukkit.mock();
        plugin = MockBukkit.load(MyPlugin.class);
    }

    @AfterAll
    static void tearDown() {
        MockBukkit.unmock();
    }
}
测试事件
@Test
void playerJoin_getsWelcomeMessage() {
    PlayerMock player = server.addPlayer("Steve");
    player.simulateJoin(); // fires PlayerJoinEvent

    // Assert the player received the expected message component
    player.assertSaid("Welcome, Steve!");
    // Or for Adventure components:
    assertTrue(player.nextMessage().contains("Welcome"));
}

@Test
void onBlockBreak_cancelledForNonOp() {
    PlayerMock player = server.addPlayer();
    player.setOp(false);

    Block block = player.getWorld().getBlockAt(0, 64, 0);
    block.setType(Material.STONE);
    BlockBreakEvent event = new BlockBreakEvent(block, player);
    server.getPluginManager().callEvent(event);

    assertTrue(event.isCancelled(), "Non-op should not be able to break blocks");
}
测试命令
@Test
void mypluginInfo_returnsVersion() {
    PlayerMock player = server.addPlayer("Admin");
    player.setOp(true);

    boolean result = server.dispatchCommand(player, "myplugin info");

    assertTrue(result);
    player.assertSaid("Version: " + plugin.getDescription().getVersion());
}

@Test
void mypluginReload_requiresOp() {
    PlayerMock player = server.addPlayer("NonOp");
    player.setOp(false);

    server.dispatchCommand(player, "myplugin reload");

    player.assertSaid("No permission.");
}
测试物品栏/物品
@Test
void giveKitCommand_givesPlayerItems() {
    PlayerMock player = server.addPlayer();
    
    server.dispatchCommand(player, "kit starter");
    
    // Check inventory
    assertTrue(player.getInventory().contains(Material.STONE_SWORD));
    assertTrue(player.getInventory().contains(Material.BREAD, 16));
}
测试调度器任务
@Test
void repeatingTask_firesAfterDelay() {
    PlayerMock player = server.addPlayer();
    
    // Execute 40 ticks worth of scheduled tasks
    server.getScheduler().performTicks(40L);
    
    // Assert expected side effect happened
    assertEquals(2, plugin.getTaskCount());
}
测试 Folia 安全的调度器抽象

MockBukkit 不会模拟 Folia 的区域线程运行时环境。安全的做法是通过你自己的接口封装调度,并对抽象边界进行单元测试。

interface SchedulerFacade {
    void runPlayerTask(Player player, Runnable task);
    void runAsync(Runnable task);
}

@Test
void playerTask_delegatesThroughFacade() {
    List<String> calls = new ArrayList<>();
    SchedulerFacade facade = new SchedulerFacade() {
        @Override
        public void runPlayerTask(Player player, Runnable task) {
            calls.add("player");
            task.run();
        }

        @Override
        public void runAsync(Runnable task) {
            calls.add("async");
            task.run();
        }
    };

    facade.runPlayerTask(server.addPlayer(), () -> calls.add("ran"));
    assertEquals(List.of("player", "ran"), calls);
}
测试 PDC
import java.util.ArrayList;
import org.bukkit.damage.DamageSource;
import org.bukkit.damage.DamageType;
import org.bukkit.entity.LivingEntity;

@Test
void pdcKillCount_incrementsOnKill() {
    PlayerMock player = server.addPlayer();
    NamespacedKey key = new NamespacedKey(plugin, "kills");

    // EntityDeathEvent requires a living victim and an explicit damage source.
    LivingEntity victim = (LivingEntity) server.addMockEntity(EntityType.ZOMBIE);
    DamageSource damageSource = DamageSource.builder(DamageType.GENERIC)
        .withCausingEntity(player)
        .withDirectEntity(player)
        .build();
    EntityDeathEvent deathEvent = new EntityDeathEvent(
        victim, damageSource, new ArrayList<>(), 0
    );
    server.getPluginManager().callEvent(deathEvent);

    int kills = player.getPersistentDataContainer()
        .getOrDefault(key, PersistentDataType.INTEGER, 0);
    assertEquals(1, kills);
}

此操作会派发一个合成的死亡事件。对于玩家归因,被测监听器应读取 event.getDamageSource().getCausingEntity();实际战斗归因应在真实服务上单独测试。

测试物品或区块 PDC 写入
@Test
void itemPdc_roundTripsCustomId() {
    NamespacedKey key = new NamespacedKey(plugin, "custom_id");
    ItemStack item = new ItemStack(Material.STICK);

    item.editMeta(meta -> meta.getPersistentDataContainer().set(
        key, PersistentDataType.STRING, "wand"
    ));

    String value = item.getItemMeta().getPersistentDataContainer()
        .get(key, PersistentDataType.STRING);
    assertEquals("wand", value);
}

当前 NeoForge GameTests(26.x)

NeoForge 1.21.5 及更高版本使用数据驱动的测试环境和测试实例,而非旧式的 @GameTestHolder 方法注册 API。将资源存放在 data/<namespace>/test_environment/ 和 data/<namespace>/test_instance/ 下。test_instance 会选择其环境、结构、时序,以及已注册函数或基于方块的测试。

{
  "environment": "minecraft:default",
  "structure": "examplemod:example_structure",
  "max_ticks": 200,
  "setup_ticks": 0,
  "required": true,
  "type": "minecraft:function",
  "function": "examplemod:example_function"
}

使用当前的 BuiltInRegistries.TEST_FUNCTION 注册表,通过 DeferredRegister 注册 Consumer<GameTestHelper>,然后将该注册表附加到模组事件总线。下面的函数会让上方的 JSON 引用生效。只有在改用代码而非数据文件注册环境和测试实例时,才使用 RegisterGameTestsEvent。将引用的结构放在 data/<namespace>/structure/<path>.nbt 下,并显式标记成功。

import java.util.function.Consumer;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.level.block.Blocks;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.common.Mod;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;

@Mod(ExampleGameTests.MOD_ID)
public final class ExampleGameTests {
    public static final String MOD_ID = "examplemod";
    private static final DeferredRegister<Consumer<GameTestHelper>> TEST_FUNCTIONS =
        DeferredRegister.create(BuiltInRegistries.TEST_FUNCTION, MOD_ID);
    public static final DeferredHolder<Consumer<GameTestHelper>, Consumer<GameTestHelper>>
        EXAMPLE_FUNCTION = TEST_FUNCTIONS.register(
            "example_function", () -> ExampleGameTests::exampleTest
        );

    public ExampleGameTests(IEventBus modBus) {
        TEST_FUNCTIONS.register(modBus);
    }

    public static void exampleTest(GameTestHelper helper) {
        helper.assertBlockPresent(Blocks.AIR, 0, 0, 0);
        helper.succeed();
    }
}

运行 ./gradlew runGameTestServer;服务会以必需测试的失败数量作为退出状态。此命令适用于实际项目,而非此技能仓库。

当前 Fabric 游戏测试(26.x)

使用 Fabric Loom 专用的游戏测试源集。在现有的 fabricApi 代码块中进行配置,并将其元数据和代码放在 src/gametest 下, 与普通单元测试分开。

fabricApi {
    configureTests {
        createSourceSet = true
        modId = "example-mod-test-${project.name}"
        enableGameTests = true
        enableClientGameTests = true
        eula = true
    }
}

将 fabric.mod.json 放在 src/gametest/resources/ 中,并在 fabric-gametest 下注册服务端测试;客户端测试使用 fabric-client-gametest。使用 Fabric 的 net.fabricmc.fabric.api.gametest.v1.GameTest 实现服务端方法;如果需要在方法运行前进行设置,则使用 CustomTestMethodInvoker。

package com.example.mymod;

import java.lang.reflect.Method;
import net.fabricmc.fabric.api.gametest.v1.CustomTestMethodInvoker;
import net.fabricmc.fabric.api.gametest.v1.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.level.block.Blocks;

public final class ExampleGameTest implements CustomTestMethodInvoker {
    @GameTest
    public void testBlock(GameTestHelper context) {
        context.assertBlockPresent(Blocks.AIR, 0, 0, 0);
        context.succeed();
    }

    @Override
    public void invokeTestMethod(GameTestHelper context, Method method)
            throws ReflectiveOperationException {
        method.invoke(this, context);
    }
}
src/gametest/resources/fabric.mod.json
{
  "entrypoints": {
    "fabric-gametest": [
      "com.example.mymod.ExampleGameTest"
    ]
  }
}

确保 fabric-gametest 入口点与具体的游戏测试类保持同步。Fabric 的服务端游戏测试通过 build 运行;客户端测试使用 runClientGameTest。项目特定的 Loom 选项和无头客户端 CI 请遵循当前 Fabric 文档。


旧版 NeoForge 游戏测试(仅限 1.21.3)

基于注解的测试仅用于 Minecraft 1.21.3。可以通过 @GameTestHolder(MOD_ID) 或 RegisterGameTestsEvent 监听器注册该类。不要再通过 modEventBus.register(MyGameTests.class) 注册 @GameTestHolder 类。

import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;

@GameTestHolder("examplemod")
@PrefixGameTestTemplate(false)
public final class ExampleGameTests {
    @GameTest(template = "example_structure")
    public static void smoke(GameTestHelper helper) {
        helper.succeed();
    }
}

对于 RegisterGameTestsEvent,请在模组事件总线上注册该类,并为每个 @GameTest 设置 templateNamespace = MOD_ID。旧版模板是位于 data/<namespace>/structure/ 下的 .nbt 文件;@PrefixGameTestTemplate(false) 控制是否将类名添加到模板路径中。省略 template 时,路径会使用小写的方法名;除非禁用该前缀,否则还会使用小写的简单类名,后接一个点。template 仅为路径名称;请通过 templateNamespace 或 @GameTestHolder 配置其命名空间。


CI

将快速单元测试/模拟测试与加载器的游戏测试任务分开,并为每个 Minecraft 版本选择 Java 版本:26.x 使用 25,1.21.x 使用 21,Forge 1.20.1 使用 17。面向运行时的作业失败时,请上传测试报告。不要假设其他加载器的任务名称适用:Fabric 服务端游戏测试通过 build 运行,而 NeoForge 使用 runGameTestServer。MockBukkit 无法证明 Folia 线程安全,也无法验证真实服务器的引导过程。


参考资料

评论

支持 Markdown