关于 minecraft-testing
为 Minecraft 项目提供自动化测试策略与示例,适合开发模组和插件的工程人员。内容区分纯单元测试、模拟服务器测试、游戏内测试及集成服务器测试,并提醒按加载器和版本隔离测试布局。
主要功能
- 01JUnit 5 单元测试指南
- 02MockBukkit 插件测试
- 03Fabric 与 NeoForge 游戏测试
- 04集成服务器测试建议
- 05测试布局校验脚本
- 06按版本区分 Java 与加载器配置
适用场景
- 01为 Paper 插件测试事件、命令和物品栏
- 02为模组编写游戏内方块或实体测试
- 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 序列化 | 不需要 |
| MockBukkit | Bukkit/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 线程安全,也无法验证真实服务器的引导过程。
参考资料
- MockBukkit GitHub:https://github.com/MockBukkit/MockBukkit
- MockBukkit 文档:https://docs.mockbukkit.org/
- Fabric 自动化测试:https://docs.fabricmc.net/develop/automatic-testing
- NeoForge 26.x 游戏测试:https://docs.neoforged.net/docs/misc/gametest/
- NeoForge 1.21.3 游戏测试:https://docs.neoforged.net/docs/1.21.3/misc/gametest/
- JUnit 5 用户指南:https://junit.org/junit5/docs/current/user-guide/