minecraft-testing
Design and implement automated tests for current Minecraft 26.x or legacy 1.21.x mods and plugins using JUnit, MockBukkit, NeoForge Game Tests, or Fabric Game Tests. Use for test code and test execution, not release publishing or gameplay implementation.
Install
npx skills add https://github.com/Jahrome907/minecraft-agent-skills/tree/main/.codex/skills/minecraft-testing
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jahrome907-minecraft-agent-skills@llmmart
git clone https://github.com/Jahrome907/minecraft-agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jahrome907/minecraft-agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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) |
Use Java 25 for current 26.x projects. Keep legacy 1.21.x examples on Java 21 and Forge 1.20.1 on Java 17. Do not combine source layouts or APIs across versions.
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).
Bundled References And Helpers
- Layout guide:
references/test-layouts.md - Fixture/layout validator:
./scripts/validate-test-layout.sh --root <project>
Use the validator before copying a test layout into a real project. It checks visible static dependencies, metadata, and literal structure references. It does not compile the project or prove a Game Test can run.
Unit Testing (JUnit 5 — No Minecraft)
JUnit Platform task
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "skipped", "failed")
}
}
For Fabric code that needs loader setup, use Fabric Loader JUnit rather than assuming ordinary JUnit initialized Minecraft:
dependencies {
testImplementation "net.fabricmc:fabric-loader-junit:${project.loader_version}"
}
test {
useJUnitPlatform()
}
When a unit test reaches registry-dependent Minecraft classes, initialize only
the required bootstrap in test setup. The current Fabric guide uses
SharedConstants.tryDetectVersion() and Bootstrap.bootStrap() for that case.
Example pure unit test
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 Plugin Tests)
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()
}
Setup / teardown pattern
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();
}
}
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 Folia-safe scheduler abstractions
MockBukkit does not emulate Folia's region-threaded runtime. The safe pattern is to wrap scheduling behind your own interface and unit test the abstraction boundary.
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);
}
Testing 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);
}
This dispatches a synthetic death event. For player attribution, the listener
under test should read event.getDamageSource().getCausingEntity(); test actual
combat attribution separately on a real server.
Testing item or chunk PDC writes
@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);
}
Current NeoForge Game Tests (26.x)
NeoForge 1.21.5 and later uses data-driven test environments and test instances,
not the old @GameTestHolder method-registration API. Store resources under
data/<namespace>/test_environment/ and data/<namespace>/test_instance/.
A test_instance selects its environment, structure, timing, and either a
registered function or a block-based test.
{
"environment": "minecraft:default",
"structure": "examplemod:example_structure",
"max_ticks": 200,
"setup_ticks": 0,
"required": true,
"type": "minecraft:function",
"function": "examplemod:example_function"
}
Register the Consumer<GameTestHelper> with a DeferredRegister for the
current BuiltInRegistries.TEST_FUNCTION registry, then attach that register to
the mod event bus. The function below makes the JSON reference above usable.
Use RegisterGameTestsEvent only when registering environments and test
instances in code instead of data files. Keep the referenced structure in
data/<namespace>/structure/<path>.nbt and mark success explicitly.
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();
}
}
Run ./gradlew runGameTestServer; the server exits with the count of required
failed tests. This command is for a real project, not this skills repository.
Current Fabric Game Tests (26.x)
Use Fabric Loom's dedicated Game Test source set. Configure it in the existing
fabricApi block and keep its metadata and code under src/gametest, separate
from ordinary unit tests.
fabricApi {
configureTests {
createSourceSet = true
modId = "example-mod-test-${project.name}"
enableGameTests = true
enableClientGameTests = true
eula = true
}
}
Place fabric.mod.json in src/gametest/resources/ and register server tests
under fabric-gametest; use fabric-client-gametest for client tests. Implement
server methods with Fabric's net.fabricmc.fabric.api.gametest.v1.GameTest and,
when setup is needed before a method runs, 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"
]
}
}
Keep the fabric-gametest entrypoint in sync with the concrete Game Test class.
Fabric's server Game Tests run with build; use runClientGameTest for client
tests. Follow the current Fabric documentation for project-specific Loom options
and headless client CI.
Legacy NeoForge Game Tests (1.21.3 only)
Keep annotation-based tests isolated to Minecraft 1.21.3. The class can
be registered by either @GameTestHolder(MOD_ID) or a
RegisterGameTestsEvent listener. Do not register a @GameTestHolder class
again with modEventBus.register(MyGameTests.class).
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();
}
}
For RegisterGameTestsEvent, register the class on the mod event bus and set
templateNamespace = MOD_ID on each @GameTest. Legacy templates are .nbt
files under data/<namespace>/structure/; @PrefixGameTestTemplate(false)
controls whether the class name is added to the template path. When template
is omitted, the path uses the lowercase method name and, unless that prefix is
disabled, the lowercase simple class name followed by a dot. template is the
path name only; configure its namespace through templateNamespace or
@GameTestHolder.
CI
Keep fast unit/mock tests separate from a loader's Game Test task, and select
the Java version for each Minecraft version: 25 for 26.x, 21 for 1.21.x, and 17 for Forge 1.20.1.
Upload test reports when a runtime-facing job fails. Do not assume a task name
from another loader: Fabric server Game Tests run with build, while NeoForge
uses runGameTestServer. MockBukkit does not prove Folia thread safety or real
server bootstrap.
References
- MockBukkit GitHub: https://github.com/MockBukkit/MockBukkit
- MockBukkit docs: https://docs.mockbukkit.org/
- Fabric automated testing: https://docs.fabricmc.net/develop/automatic-testing
- NeoForge 26.x Game Tests: https://docs.neoforged.net/docs/misc/gametest/
- NeoForge 1.21.3 Game Tests: https://docs.neoforged.net/docs/1.21.3/misc/gametest/
- JUnit 5 user guide: https://junit.org/junit5/docs/current/user-guide/
Files (minecraft-agent-skills)
-
references
-
test-layouts.md 3 KB
# Minecraft Testing Layouts Choose the target platform and version. `src/test` is for JUnit or MockBukkit; it is optional for a Game Test-only project. ## Unit + MockBukkit plugin ```text src/ main/ java/com/example/myplugin/ resources/ plugin.yml test/ java/com/example/myplugin/ MyPluginTest.java CommandExecutorTest.java ``` Checklist: - `build.gradle(.kts)` declares compatible JUnit Jupiter and MockBukkit versions - `tasks.test { useJUnitPlatform() }` is enabled ## Current Fabric 26.x Fabric Loom's recommended Game Test layout uses a separate source set. ```text src/ main/ java/com/example/mymod/ resources/ fabric.mod.json gametest/ java/com/example/mymod/ ExampleGameTest.java resources/ fabric.mod.json data/mymod/structure/ example_structure.nbt test/ # only when JUnit tests exist java/com/example/mymod/ SerializerTest.java ``` Configure `fabricApi.configureTests { createSourceSet = true }`. Register server tests in `src/gametest/resources/fabric.mod.json` under `fabric-gametest`, and client tests under `fabric-client-gametest`. The current Fabric API annotation is `net.fabricmc.fabric.api.gametest.v1.GameTest`. ## Current NeoForge 26.x ```text src/ main/ java/com/example/mymod/ GameTestFunctions.java resources/ META-INF/neoforge.mods.toml data/mymod/structure/ empty.nbt data/mymod/test_instance/ example_test.json test/ # only when JUnit tests exist java/com/example/mymod/ CooldownManagerTest.java ``` Checklist: NeoForge 1.21.5+ models Game Tests as registered test environments, functions, and test instances. A `test_instance` must reference an existing structure. Add a `test_environment` resource when `minecraft:default` is not sufficient. Register custom test functions with a `DeferredRegister` for `BuiltInRegistries.TEST_FUNCTION` and attach it to the mod event bus. Use `RegisterGameTestsEvent` to register environments and instances in code. ## Legacy NeoForge 1.21.3 Only use the annotation route for a clearly labelled 1.21.3 project. ```text src/ main/ java/com/example/mymod/ LegacyGameTests.java resources/ META-INF/neoforge.mods.toml data/mymod/structure/ example_structure.nbt ``` `@GameTestHolder(MOD_ID)` registers the methods in the annotated type. The alternative is an event-bus `RegisterGameTestsEvent` listener that calls `event.register(LegacyGameTests.class)`; then each test provides its `templateNamespace`. Do not require both mechanisms. ## Validator Usage ```bash ./scripts/validate-test-layout.sh --root . ./scripts/validate-test-layout.sh --root . --strict ``` What it checks: - build file exists - JUnit Platform is enabled when unit or MockBukkit tests are present - MockBukkit tests have the dependency - GameTests have committed structure fixtures that match referenced templates - Fabric GameTests include their metadata and entrypoints
-
-
scripts
-
validate-test-layout.sh 14.9 KB
#!/usr/bin/env bash set -euo pipefail PASS='[PASS]' WARN='[WARN]' FAIL='[FAIL]' ROOT='.' STRICT=0 while [[ $# -gt 0 ]]; do case "$1" in --root) ROOT="${2:-}" shift 2 ;; --strict) STRICT=1 shift ;; --help|-h) cat <<'USAGE' Usage: validate-test-layout.sh [--root <path>] [--strict] Checks common Minecraft testing layout expectations: - build.gradle(.kts) exists - unit or MockBukkit tests enable JUnit Platform - MockBukkit tests have the MockBukkit dependency - GameTests have committed structure fixtures that match referenced templates - Fabric GameTests include their required registration metadata USAGE exit 0 ;; *) echo "$FAIL unknown arg: $1" >&2 exit 1 ;; esac done if [[ ! -d "$ROOT" ]]; then echo "$FAIL root path does not exist: $ROOT" >&2 exit 1 fi FAILURES=0 WARNINGS=0 pass() { echo "$PASS $*"; } warn() { echo "$WARN $*"; WARNINGS=$((WARNINGS + 1)); } fail() { echo "$FAIL $*"; FAILURES=$((FAILURES + 1)); } extract_package_name() { local file="$1" awk '/^[[:space:]]*package[[:space:]]+/ { gsub(/;/, "", $2); print $2; exit }' "$file" } extract_class_name() { local file="$1" sed -nE 's/.*(^|[[:space:]])(class|object)[[:space:]]+([A-Za-z_][A-Za-z0-9_]*).*/\3/p' "$file" | head -n 1 } extract_fqcn() { local file="$1" local package_name local class_name package_name="$(extract_package_name "$file")" class_name="$(extract_class_name "$file")" if [[ -z "$class_name" ]]; then return 1 fi if [[ -n "$package_name" ]]; then printf '%s.%s\n' "$package_name" "$class_name" else printf '%s\n' "$class_name" fi } template_fixture_exists() { local root="$1" local namespace="$2" local path="$3" local structure_roots=() if [[ -d "$root/src/test/resources" ]]; then structure_roots+=("$root/src/test/resources") fi if [[ -d "$root/src/main/resources" ]]; then structure_roots+=("$root/src/main/resources") fi if [[ -d "$root/src/gametest/resources" ]]; then structure_roots+=("$root/src/gametest/resources") fi if [[ "${#structure_roots[@]}" -eq 0 ]]; then return 1 fi local structure_rel="data/$namespace/structure/$path.nbt" local structure_root for structure_root in "${structure_roots[@]}"; do if [[ -f "$structure_root/$structure_rel" ]]; then return 0 fi done return 1 } append_legacy_neoforge_implicit_templates() { local source_file="$1" local class_name="$2" local holder_namespace='' local holder_present=0 local class_prefix_disabled=0 local line local annotation local method local method_line local namespace local path local template_name local method_prefix_disabled local index local candidate_index local lookahead_index local -a source_lines=() if grep -E -q '@GameTestHolder[[:space:]]*\(' "$source_file"; then holder_present=1 fi holder_namespace="$({ grep -oE '@GameTestHolder[[:space:]]*\([[:space:]]*"[^"]+' "$source_file" || true; } | head -n 1 | sed -E 's/.*"//')" mapfile -t source_lines < "$source_file" for ((index = 0; index < ${#source_lines[@]}; index++)); do if [[ "${source_lines[$index]}" =~ @PrefixGameTestTemplate[[:space:]]*\([[:space:]]*false[[:space:]]*\) ]]; then for ((lookahead_index = index + 1; lookahead_index < ${#source_lines[@]}; lookahead_index++)); do line="${source_lines[$lookahead_index]}" if [[ "$line" =~ (^|[[:space:]])(class|object)[[:space:]]+ ]]; then class_prefix_disabled=1 break 2 fi if [[ "$line" =~ @GameTest([[:space:]]|\(|$) ]]; then break fi done fi done for ((index = 0; index < ${#source_lines[@]}; index++)); do line="${source_lines[$index]}" [[ "$line" =~ @GameTest([[:space:]]|\(|$) ]] || continue annotation="$line" method='' method_prefix_disabled=0 if (( index > 0 )) && [[ "${source_lines[$((index - 1))]}" =~ @PrefixGameTestTemplate[[:space:]]*\([[:space:]]*false[[:space:]]*\) ]]; then method_prefix_disabled=1 fi for ((candidate_index = index; candidate_index < ${#source_lines[@]}; candidate_index++)); do method_line="${source_lines[$candidate_index]}" if (( candidate_index > index )); then annotation+=" $method_line" fi if [[ "$method_line" =~ (^|[[:space:]])(public|protected|private|internal|static)[[:space:]].*\( || "$method_line" =~ (^|[[:space:]])fun[[:space:]]+ ]]; then if [[ "$method_line" =~ ([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\( ]]; then method="${BASH_REMATCH[1]}" fi break fi done [[ -n "$method" ]] || continue # Java annotations may appear in either order before the method. The # collected annotation block covers both @PrefixGameTestTemplate(false) # before and after @GameTest. if [[ "$annotation" =~ @PrefixGameTestTemplate[[:space:]]*\([[:space:]]*false[[:space:]]*\) ]]; then method_prefix_disabled=1 fi template_name="$(printf '%s' "$method" | tr '[:upper:]' '[:lower:]')" if [[ "$annotation" =~ template[[:space:]]*= ]]; then if [[ "$annotation" =~ template[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then template_name="${BASH_REMATCH[1]}" else warn "legacy NeoForge GameTest has a non-literal template name; fixture path not verified: ${source_file#$ROOT/}" continue fi fi if [[ "$template_name" == *:* ]]; then warn "legacy NeoForge GameTest template must be an un-namespaced name; configure its namespace with templateNamespace or @GameTestHolder: ${source_file#$ROOT/}" continue fi namespace="$holder_namespace" if [[ "$annotation" =~ templateNamespace[[:space:]]*= ]]; then if [[ "$annotation" =~ templateNamespace[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then namespace="${BASH_REMATCH[1]}" else warn "legacy NeoForge GameTest has a non-literal templateNamespace; fixture path not verified: ${source_file#$ROOT/}" continue fi elif [[ "$holder_present" -eq 1 && -z "$namespace" ]]; then warn "legacy NeoForge GameTest has a non-literal @GameTestHolder value; fixture path not verified: ${source_file#$ROOT/}" continue elif [[ -z "$namespace" ]]; then namespace='minecraft' fi path="$template_name" if [[ "$class_prefix_disabled" -eq 0 && "$method_prefix_disabled" -eq 0 ]]; then path="$(printf '%s' "$class_name" | tr '[:upper:]' '[:lower:]').$path" fi GAME_TEST_TEMPLATES+=("$namespace:$path") done } legacy_neoforge_event_registers_class() { local root="$1" local class_name="$2" local source_file local event_variable while IFS= read -r -d '' source_file; do while IFS= read -r event_variable; do if grep -E -q "${event_variable}[[:space:]]*\\.[[:space:]]*register[[:space:]]*\\([[:space:]]*([A-Za-z_][A-Za-z0-9_]*\\.)*${class_name}\\.class[[:space:]]*\\)" "$source_file"; then return 0 fi done < <(grep -oE 'RegisterGameTestsEvent[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' "$source_file" | sed -E 's/.*[[:space:]]([A-Za-z_][A-Za-z0-9_]*)$/\1/') done < <(find "$root/src/main" "$root/src/test" -type f \( -name '*.java' -o -name '*.kt' \) -print0 2>/dev/null) return 1 } BUILD_FILE='' if [[ -f "$ROOT/build.gradle.kts" ]]; then BUILD_FILE="$ROOT/build.gradle.kts" elif [[ -f "$ROOT/build.gradle" ]]; then BUILD_FILE="$ROOT/build.gradle" else fail "missing build.gradle or build.gradle.kts" fi TEST_ROOT='' if [[ -d "$ROOT/src/test/java" ]]; then TEST_ROOT="$ROOT/src/test/java" elif [[ -d "$ROOT/src/test/kotlin" ]]; then TEST_ROOT="$ROOT/src/test/kotlin" fi if [[ -n "$BUILD_FILE" ]]; then pass "found build file: ${BUILD_FILE#$ROOT/}" fi if [[ -n "$TEST_ROOT" ]]; then pass "found test source root: ${TEST_ROOT#$ROOT/}" fi HAS_MOCKBUKKIT_TESTS=0 if [[ -n "$TEST_ROOT" ]] && grep -R -E -q 'MockBukkit|ServerMock|PlayerMock' "$TEST_ROOT"; then HAS_MOCKBUKKIT_TESTS=1 pass "MockBukkit-style tests detected" fi if [[ "$HAS_MOCKBUKKIT_TESTS" -eq 1 && -n "$BUILD_FILE" ]]; then if grep -R -E -q 'be\.seeseemelk|com\.github\.seeseemelk' "$BUILD_FILE" "$TEST_ROOT"; then warn "legacy MockBukkit 3.x coordinate or package detected; prefer org.mockbukkit.mockbukkit 4.x" fi if grep -Eiq 'MockBukkit|mockbukkit' "$BUILD_FILE"; then pass "build file declares MockBukkit dependency" else fail "MockBukkit tests detected but build file is missing MockBukkit dependency" fi fi HAS_GAMETESTS=0 declare -a SOURCE_SCAN_ROOTS=() for candidate_root in \ "$ROOT/src/main/java" \ "$ROOT/src/main/kotlin" \ "$ROOT/src/test/java" \ "$ROOT/src/test/kotlin" \ "$ROOT/src/gametest/java" \ "$ROOT/src/gametest/kotlin"; do if [[ -d "$candidate_root" ]]; then SOURCE_SCAN_ROOTS+=("$candidate_root") fi done declare -a GAME_TEST_FILES=() declare -a GAME_TEST_TEMPLATES=() declare -a NEOFORGE_GAMETEST_CLASSES=() declare -a NEOFORGE_EVENT_REGISTERED_CLASSES=() declare -a FABRIC_GAMETEST_CLASSES=() if [[ "${#SOURCE_SCAN_ROOTS[@]}" -gt 0 ]]; then while IFS= read -r -d '' source_file; do if grep -E -q '@GameTest|FabricGameTest|GameTestHelper' "$source_file"; then GAME_TEST_FILES+=("$source_file") fqcn="$(extract_fqcn "$source_file" || true)" if [[ -n "$fqcn" ]]; then # Only the 1.21.3 annotation API needs holder or event registration. # Current data-driven NeoForge test-function classes also use # GameTestHelper, but have no @GameTest annotation to register. if grep -E -q '@GameTest([[:space:]]|\(|$)' "$source_file" \ && { grep -E -q 'net\.neoforged|@GameTestHolder|PrefixGameTestTemplate' "$source_file" \ || { [[ -f "$ROOT/src/main/resources/META-INF/neoforge.mods.toml" ]] && ! grep -E -q 'FabricGameTest|fabric\.api\.gametest' "$source_file"; }; }; then NEOFORGE_GAMETEST_CLASSES+=("$fqcn") if ! grep -E -q '@GameTestHolder' "$source_file"; then NEOFORGE_EVENT_REGISTERED_CLASSES+=("$fqcn") fi append_legacy_neoforge_implicit_templates "$source_file" "${fqcn##*.}" else while IFS= read -r template; do [[ -n "$template" ]] && GAME_TEST_TEMPLATES+=("$template") done < <(grep -oE '@GameTest\([^)]*template[[:space:]]*=[[:space:]]*"[^"]+"' "$source_file" | sed -E 's/.*template[[:space:]]*=[[:space:]]*"([^"]+)"/\1/') fi if grep -E -q 'FabricGameTest|fabric\.api\.gametest' "$source_file"; then FABRIC_GAMETEST_CLASSES+=("$fqcn") fi fi fi done < <(find "${SOURCE_SCAN_ROOTS[@]}" -type f \( -name '*.java' -o -name '*.kt' \) -print0) fi declare -a RESOURCE_SCAN_ROOTS=() for candidate_root in \ "$ROOT/src/main/resources" \ "$ROOT/src/test/resources" \ "$ROOT/src/gametest/resources"; do if [[ -d "$candidate_root" ]]; then RESOURCE_SCAN_ROOTS+=("$candidate_root") fi done HAS_CURRENT_NEOFORGE_GAMETESTS=0 if [[ "${#RESOURCE_SCAN_ROOTS[@]}" -gt 0 ]]; then while IFS= read -r -d '' instance_file; do HAS_CURRENT_NEOFORGE_GAMETESTS=1 while IFS= read -r template; do [[ -n "$template" ]] && GAME_TEST_TEMPLATES+=("$template") done < <(grep -oE '"structure"[[:space:]]*:[[:space:]]*"[^"]+"' "$instance_file" | sed -E 's/.*"structure"[[:space:]]*:[[:space:]]*"([^"]+)"/\1/') done < <(find "${RESOURCE_SCAN_ROOTS[@]}" -type f -path '*/data/*/test_instance/*.json' -print0) fi HAS_JUNIT_STYLE_TESTS=0 if [[ -n "$TEST_ROOT" ]] && grep -R -E -q 'org\.junit|@Test|@ParameterizedTest' "$TEST_ROOT"; then HAS_JUNIT_STYLE_TESTS=1 fi if [[ "$HAS_JUNIT_STYLE_TESTS" -eq 1 || "$HAS_MOCKBUKKIT_TESTS" -eq 1 ]]; then if grep -Eq 'useJUnitPlatform' "$BUILD_FILE"; then pass "test task enables JUnit Platform" else fail "unit or MockBukkit tests require useJUnitPlatform()" fi fi if [[ "${#GAME_TEST_FILES[@]}" -gt 0 || "$HAS_CURRENT_NEOFORGE_GAMETESTS" -eq 1 ]]; then HAS_GAMETESTS=1 pass "GameTest-style tests detected" fi if [[ "$HAS_GAMETESTS" -eq 1 ]]; then if [[ "${#GAME_TEST_TEMPLATES[@]}" -gt 0 ]]; then for template in "${GAME_TEST_TEMPLATES[@]}"; do if [[ "$template" =~ ^([a-z0-9_.-]+):([a-z0-9_./-]+)$ ]]; then if template_fixture_exists "$ROOT" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"; then pass "GameTest template fixture exists: $template" else fail "GameTest template fixture missing: $template" fi else warn "GameTest template uses a non-literal or unsupported format: $template" fi done else pass "no literal GameTest structure reference found; skipping fixture-path validation" fi if [[ "${#NEOFORGE_GAMETEST_CLASSES[@]}" -gt 0 || "$HAS_CURRENT_NEOFORGE_GAMETESTS" -eq 1 ]]; then if [[ -f "$ROOT/src/main/resources/META-INF/neoforge.mods.toml" ]]; then pass "NeoForge metadata found for GameTests" else fail "NeoForge GameTests detected but src/main/resources/META-INF/neoforge.mods.toml is missing" fi for fqcn in "${NEOFORGE_EVENT_REGISTERED_CLASSES[@]}"; do class_name="${fqcn##*.}" if legacy_neoforge_event_registers_class "$ROOT" "$class_name"; then pass "legacy NeoForge GameTest class has an event registration path: $fqcn" else fail "legacy NeoForge GameTest class needs @GameTestHolder or RegisterGameTestsEvent registration: $fqcn" fi done fi if [[ "${#FABRIC_GAMETEST_CLASSES[@]}" -gt 0 ]]; then FABRIC_MOD_JSON='' if [[ -f "$ROOT/src/gametest/resources/fabric.mod.json" ]]; then FABRIC_MOD_JSON="$ROOT/src/gametest/resources/fabric.mod.json" elif [[ -f "$ROOT/src/main/resources/fabric.mod.json" ]]; then FABRIC_MOD_JSON="$ROOT/src/main/resources/fabric.mod.json" fi if [[ -f "$FABRIC_MOD_JSON" ]]; then pass "Fabric metadata found for GameTests" if grep -Fq '"fabric-gametest"' "$FABRIC_MOD_JSON"; then pass "fabric.mod.json declares fabric-gametest entrypoints" else fail "fabric.mod.json is missing the fabric-gametest entrypoint block" fi for fqcn in "${FABRIC_GAMETEST_CLASSES[@]}"; do if grep -Fq "$fqcn" "$FABRIC_MOD_JSON"; then pass "fabric.mod.json registers GameTest entrypoint: $fqcn" else fail "fabric.mod.json is missing the fabric-gametest entry for $fqcn" fi done else if [[ -d "$ROOT/src/gametest" ]]; then fail "Fabric GameTests detected but src/gametest/resources/fabric.mod.json is missing" else fail "Fabric GameTests detected but src/main/resources/fabric.mod.json is missing" fi fi fi fi if [[ "$HAS_MOCKBUKKIT_TESTS" -eq 0 && "$HAS_GAMETESTS" -eq 0 ]]; then warn "no MockBukkit or GameTest fixtures detected; layout only covers plain unit tests" fi echo "" if [[ "$FAILURES" -gt 0 ]]; then echo "$FAIL testing layout validation failed with $FAILURES error(s) and $WARNINGS warning(s)" exit 1 fi if [[ "$STRICT" -eq 1 && "$WARNINGS" -gt 0 ]]; then echo "$FAIL testing layout validation strict mode failed on $WARNINGS warning(s)" exit 1 fi echo "$PASS testing layout validation passed with $WARNINGS warning(s)"
-
-
SKILL.md 15 KB
--- name: minecraft-testing description: "Design and implement automated tests for current Minecraft 26.x or legacy 1.21.x mods and plugins using JUnit, MockBukkit, NeoForge Game Tests, or Fabric Game Tests. Use for test code and test execution, not release publishing or gameplay implementation." --- # 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) | Use Java 25 for current 26.x projects. Keep legacy 1.21.x examples on Java 21 and Forge 1.20.1 on Java 17. Do not combine source layouts or APIs across versions. ### 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`). ## Bundled References And Helpers - Layout guide: `references/test-layouts.md` - Fixture/layout validator: `./scripts/validate-test-layout.sh --root <project>` Use the validator before copying a test layout into a real project. It checks visible static dependencies, metadata, and literal structure references. It does not compile the project or prove a Game Test can run. --- ## Unit Testing (JUnit 5 — No Minecraft) ### JUnit Platform task ```kotlin tasks.test { useJUnitPlatform() testLogging { events("passed", "skipped", "failed") } } ``` For Fabric code that needs loader setup, use Fabric Loader JUnit rather than assuming ordinary JUnit initialized Minecraft: ```groovy dependencies { testImplementation "net.fabricmc:fabric-loader-junit:${project.loader_version}" } test { useJUnitPlatform() } ``` When a unit test reaches registry-dependent Minecraft classes, initialize only the required bootstrap in test setup. The current Fabric guide uses `SharedConstants.tryDetectVersion()` and `Bootstrap.bootStrap()` for that case. ### Example pure unit test ```java 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 Plugin Tests) ### `build.gradle.kts` ```kotlin 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() } ``` ### Setup / teardown pattern ```java 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(); } } ``` ### Testing events ```java @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 ```java @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 ```java @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 ```java @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 Folia-safe scheduler abstractions MockBukkit does not emulate Folia's region-threaded runtime. The safe pattern is to wrap scheduling behind your own interface and unit test the abstraction boundary. ```java 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); } ``` ### Testing PDC ```java 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); } ``` This dispatches a synthetic death event. For player attribution, the listener under test should read `event.getDamageSource().getCausingEntity()`; test actual combat attribution separately on a real server. ### Testing item or chunk PDC writes ```java @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); } ``` --- ## Current NeoForge Game Tests (26.x) NeoForge 1.21.5 and later uses data-driven test environments and test instances, not the old `@GameTestHolder` method-registration API. Store resources under `data/<namespace>/test_environment/` and `data/<namespace>/test_instance/`. A `test_instance` selects its environment, structure, timing, and either a registered function or a block-based test. ```json { "environment": "minecraft:default", "structure": "examplemod:example_structure", "max_ticks": 200, "setup_ticks": 0, "required": true, "type": "minecraft:function", "function": "examplemod:example_function" } ``` Register the `Consumer<GameTestHelper>` with a `DeferredRegister` for the current `BuiltInRegistries.TEST_FUNCTION` registry, then attach that register to the mod event bus. The function below makes the JSON reference above usable. Use `RegisterGameTestsEvent` only when registering environments and test instances in code instead of data files. Keep the referenced structure in `data/<namespace>/structure/<path>.nbt` and mark success explicitly. ```java 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(); } } ``` Run `./gradlew runGameTestServer`; the server exits with the count of required failed tests. This command is for a real project, not this skills repository. --- ## Current Fabric Game Tests (26.x) Use Fabric Loom's dedicated Game Test source set. Configure it in the existing `fabricApi` block and keep its metadata and code under `src/gametest`, separate from ordinary unit tests. ```groovy fabricApi { configureTests { createSourceSet = true modId = "example-mod-test-${project.name}" enableGameTests = true enableClientGameTests = true eula = true } } ``` Place `fabric.mod.json` in `src/gametest/resources/` and register server tests under `fabric-gametest`; use `fabric-client-gametest` for client tests. Implement server methods with Fabric's `net.fabricmc.fabric.api.gametest.v1.GameTest` and, when setup is needed before a method runs, `CustomTestMethodInvoker`. ```java 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` ```json { "entrypoints": { "fabric-gametest": [ "com.example.mymod.ExampleGameTest" ] } } ``` Keep the `fabric-gametest` entrypoint in sync with the concrete Game Test class. Fabric's server Game Tests run with `build`; use `runClientGameTest` for client tests. Follow the current Fabric documentation for project-specific Loom options and headless client CI. --- ## Legacy NeoForge Game Tests (1.21.3 only) Keep annotation-based tests isolated to Minecraft 1.21.3. The class can be registered by either `@GameTestHolder(MOD_ID)` or a `RegisterGameTestsEvent` listener. Do not register a `@GameTestHolder` class again with `modEventBus.register(MyGameTests.class)`. ```java 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(); } } ``` For `RegisterGameTestsEvent`, register the class on the mod event bus and set `templateNamespace = MOD_ID` on each `@GameTest`. Legacy templates are `.nbt` files under `data/<namespace>/structure/`; `@PrefixGameTestTemplate(false)` controls whether the class name is added to the template path. When `template` is omitted, the path uses the lowercase method name and, unless that prefix is disabled, the lowercase simple class name followed by a dot. `template` is the path name only; configure its namespace through `templateNamespace` or `@GameTestHolder`. --- ## CI Keep fast unit/mock tests separate from a loader's Game Test task, and select the Java version for each Minecraft version: 25 for 26.x, 21 for 1.21.x, and 17 for Forge 1.20.1. Upload test reports when a runtime-facing job fails. Do not assume a task name from another loader: Fabric server Game Tests run with `build`, while NeoForge uses `runGameTestServer`. MockBukkit does not prove Folia thread safety or real server bootstrap. --- ## References - MockBukkit GitHub: https://github.com/MockBukkit/MockBukkit - MockBukkit docs: https://docs.mockbukkit.org/ - Fabric automated testing: https://docs.fabricmc.net/develop/automatic-testing - NeoForge 26.x Game Tests: https://docs.neoforged.net/docs/misc/gametest/ - NeoForge 1.21.3 Game Tests: https://docs.neoforged.net/docs/1.21.3/misc/gametest/ - JUnit 5 user guide: https://junit.org/junit5/docs/current/user-guide/
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.