minecraft-testing

SKILL.md

Minecraft Testing Skill

Testing Strategies Overview

Approach Best For Requires Game?
JUnit 5 (pure unit tests) Logic, data structures, NBT serialization No
MockBukkit Bukkit/Paper plugin events, commands, inventory No (mocked server)
NeoForge GameTests In-game block/entity/world interaction Yes (test environment)
Fabric GameTests In-game block/entity/world interaction Yes (test environment)
Integration server Full plugin/mod lifecycle Yes (dedicated test server)

Routing Boundaries

  • Use when: the task is designing or implementing automated tests (unit, mock, gametest, CI test jobs) for Minecraft projects.
  • Do not use when: the task is implementing gameplay features rather than testing them (minecraft-modding, minecraft-plugin-dev, minecraft-datapack).
  • Do not use when: the task is release automation or publishing pipelines (minecraft-ci-release).

Unit Testing (JUnit 5 — No Minecraft)

build.gradle.kts additions

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "skipped", "failed")
    }
}

Example pure unit test

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CooldownManagerTest {

    @Test
    void playerOnCooldown_returnsFalse_afterExpiry() {
        var manager = new CooldownManager(500L); // 500ms cooldown
        manager.startCooldown("steve");
        assertTrue(manager.isOnCooldown("steve"));
        // fast-forward time by sleeping or injecting a Clock
        assertFalse(manager.isOnCooldown("notExisting"));
    }

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

MockBukkit (Paper/Bukkit Plugin Tests)

MockBukkit provides a mock Bukkit server for unit-testing plugin logic without running a real Minecraft server.

build.gradle.kts

repositories {
    maven("https://repo.papermc.io/repository/maven-public/")
    maven("https://repo.mockbukkit.org/artifactory/mockbukkit/")
}

dependencies {
    compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
    testImplementation("com.github.seeseemelk:MockBukkit-v1.21:3.127.0")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
}

Setup / teardown pattern

import be.seeseemelk.mockbukkit.MockBukkit;
import be.seeseemelk.mockbukkit.ServerMock;
import be.seeseemelk.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();
    }

    @BeforeEach
    void beforeEach() {
        // Create a fresh mock player per test if needed
    }
}

Testing events

@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");
}

Testing commands

@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.");
}

Testing inventory / items

@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));
}

Testing scheduler tasks

@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());
}

Testing PDC

@Test
void pdcKillCount_incrementsOnKill() {
    PlayerMock player = server.addPlayer();
    NamespacedKey key = new NamespacedKey(plugin, "kills");
    
    // Simulate kill event
    EntityDeathEvent deathEvent = new EntityDeathEvent(
        server.addMockEntity(EntityType.ZOMBIE), new ArrayList<>(), 0
    );
    deathEvent.getEntity().setKiller(player);
    server.getPluginManager().callEvent(deathEvent);
    
    int kills = player.getPersistentDataContainer()
        .getOrDefault(key, PersistentDataType.INTEGER, 0);
    assertEquals(1, kills);
}

NeoForge GameTests

GameTests run inside a Minecraft world. They place a structure (the test environment), then run assertions using GameTestHelper.

Registration

// In your mod main class:
@Mod(MyMod.MOD_ID)
public class MyMod {
    public MyMod(IEventBus modEventBus) {
        modEventBus.register(MyGameTests.class);
    }
}

Test class

import net.minecraft.gametest.framework.*;
import net.neoforged.neoforge.gametest.GameTestHolder;
import net.neoforged.neoforge.gametest.PrefixGameTestTemplate;

@GameTestHolder(MyMod.MOD_ID)                // registers test namespace
@PrefixGameTestTemplate(false)               // don't prefix template names
public class MyGameTests {

    // Default template: 3x3x3 air structure called "mymod:empty"
    @GameTest(template = "mymod:empty")
    public static void testBlockInteraction(GameTestHelper helper) {
        // Place a block
        helper.setBlock(1, 1, 1, net.minecraft.world.level.block.Blocks.FURNACE);
        
        // Run after 1 tick
        helper.runAfterDelay(1, () -> {
            // Assert block state
            helper.assertBlock(new net.minecraft.core.BlockPos(1, 1, 1),
                b -> b.is(net.minecraft.world.level.block.Blocks.FURNACE),
                "Expected furnace");
            
            helper.succeed();
        });
    }

    @GameTest(template = "mymod:empty", timeoutTicks = 200)
    public static void testEntitySpawn(GameTestHelper helper) {
        // Spawn entity
        var entity = helper.spawnWithNoFreeWill(
            net.minecraft.world.entity.EntityType.ZOMBIE, new net.minecraft.core.BlockPos(2, 2, 2)
        );
        
        helper.runAfterDelay(5, () -> {
            helper.assertEntityPresent(
                net.minecraft.world.entity.EntityType.ZOMBIE,
                new net.minecraft.core.BlockPos(2, 2, 2), 1.0
            );
            helper.succeed();
        });
    }
}

Structure templates (.nbt files)

Place empty structure files at:
src/main/resources/data/mymod/structures/empty.nbt

Generate them in-game using /test create mymod:empty 3 3 3 (NeoForge test command). Commit the .nbt files to version control.

Running GameTests

# Start the test server and run all tests
./gradlew runGameTestServer

# In-game (dev environment):
# /test runall
# /test run mymod:test_block_interaction

Fabric GameTests

import net.fabricmc.fabric.api.gametest.v1.FabricGameTest;
import net.minecraft.core.BlockPos;
import net.minecraft.gametest.framework.GameTest;
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.world.level.block.Blocks;

public class MyFabricGameTests implements FabricGameTest {

    @GameTest(template = EMPTY_STRUCTURE)
    public void testCustomBlock(GameTestHelper helper) {
        helper.setBlock(1, 1, 1, Blocks.GOLD_BLOCK.defaultBlockState());
        
        helper.runAfterDelay(2, () -> {
            helper.assertBlock(
                new BlockPos(1, 1, 1),
                b -> b.is(Blocks.GOLD_BLOCK),
                "Gold block should be placed"
            );
            helper.succeed();
        });
    }
}

Register in fabric.mod.json

{
  "entrypoints": {
    "fabric-gametest": [
      "com.example.mymod.fabric.MyFabricGameTests"
    ]
  }
}

GameTestHelper Assertions Reference

// Block assertions
helper.assertBlock(pos, predicate, "message");
helper.assertBlockState(pos, state -> state.is(Blocks.STONE), "Expected stone");
helper.assertBlockPresent(Blocks.GOLD_BLOCK, pos);
helper.assertBlockNotPresent(Blocks.TNT, pos);

// Entity assertions
helper.assertEntityPresent(EntityType.ZOMBIE, pos, radius);
helper.assertEntityNotPresent(EntityType.ZOMBIE);
helper.assertEntityCount(EntityType.ZOMBIE, expectedCount);
helper.assertEntityProperty(entity, entity -> entity.getHealth() > 0, "alive");

// Item assertions
helper.assertContainerContains(pos, Items.DIAMOND);
helper.assertContainerEmpty(pos);

// Control flow
helper.succeed();        // mark test as passed — REQUIRED at end
helper.fail("reason");   // mark test as failed
helper.runAfterDelay(ticks, runnable); // schedule assertion
helper.onEachTick(runnable);          // run every tick (use with care)
helper.succeedWhen(() -> { /* assertions */ }); // poll until assertions pass or timeout
helper.succeedOnTickWhen(tick, () -> { /* assertions */ });

CI: Running Tests in GitHub Actions

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      - uses: gradle/actions/setup-gradle@v3
      - name: Run unit tests
        run: ./gradlew test

  game-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      - uses: gradle/actions/setup-gradle@v3
      - name: Run GameTests (headless)
        run: ./gradlew runGameTestServer
        env:
          # Required for headless rendering
          CI: true

References

Weekly Installs
1
GitHub Stars
3
First Seen
6 days ago
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1