Testing a Paper plugin with MockBukkit
MockBukkit is the standard mocking framework for Bukkit and Paper plugins. It boots a fake Server in-process so you can test command handlers, event listeners, services, and the plugin’s own lifecycle without spinning up a real Paper server.
pluggy test packages your plugin into a jar before running tests and hands the path off via a pluggy.test.mainJar system property. See Mocking-framework hand-off in the command reference. MockBukkit.loadJar(...) reads that property. The integration is not MockBukkit-specific.
1. Pick a MockBukkit version that matches your Paper API
MockBukkit publishes one artifact per Paper minor version. The artifact ID embeds the Minecraft version (mockbukkit-v1.21), and each release of that artifact targets a specific Paper API revision, readable from the Paper-Version line in the jar’s META-INF/MANIFEST.MF.
Mismatched versions print:
################### MockBukkit Version Mismatch ###################🔍 MockBukkit X.Y.Z (…) was built against Paper API version 1.21.N-R0.1-SNAPSHOTFor Paper 1.21.8, MockBukkit 4.90.0 is the last release that targets that exact API. 4.95.0 and later moved to 1.21.10 and 1.21.11. Check Maven Central metadata when picking a version:
curl -fsSL https://repo1.maven.org/maven2/org/mockbukkit/mockbukkit/mockbukkit-v1.21/maven-metadata.xml2. Declare it as a testDependency
{ "name": "demo", "version": "1.0.0", "main": "com.example.demo.Main", "compatibility": { "versions": ["1.21.8"], "platforms": ["paper"] }, "testDependencies": { "mockbukkit": { "source": "maven:org.mockbukkit.mockbukkit:mockbukkit-v1.21", "version": "4.90.0" } }}testDependencies is resolved against your registries plus an implicit Maven Central, so MockBukkit is fetched without any extra config. JUnit Platform Console Standalone is auto-injected. You never declare it.
3. Test the full plugin lifecycle
Load the plugin via the pluggy.test.mainJar system property, explicitly enable it (MockBukkit’s loadJar does not auto-enable), and go.
package com.example.demo;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertNotNull;import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.File;
import org.bukkit.plugin.Plugin;import org.junit.jupiter.api.AfterEach;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import org.mockbukkit.mockbukkit.MockBukkit;import org.mockbukkit.mockbukkit.ServerMock;
class MainPluginTest {
private ServerMock server; private Plugin plugin;
@BeforeEach void bootMockServer() { server = MockBukkit.mock(); File jar = new File(System.getProperty("pluggy.test.mainJar")); plugin = MockBukkit.loadJar(jar); server.getPluginManager().enablePlugin(plugin); }
@AfterEach void shutdown() { MockBukkit.unmock(); }
@Test void pluginEnables() { assertNotNull(plugin); assertTrue(plugin.isEnabled()); }
@Test void pluginNameMatches() { assertEquals("demo", plugin.getName()); }
@Test void canSpawnPlayers() { server.addPlayer("Alice"); server.addPlayer("Bob"); assertEquals(2, server.getOnlinePlayers().size()); }}Run it:
$ pluggy testtest demo com.example.demo.MainPluginTest ✓ canSpawnPlayers() 699ms ✓ pluginEnables() 5ms ✓ pluginNameMatches() 6ms
3 passedThe first MockBukkit-using test in a class pays a one-time warm-up cost (around 700 ms). Subsequent tests in the same class are fast.
4. Loading other declared plugins
Every dep declared in project.json (dependencies or testDependencies) is exposed to tests as a system property. See Mocking-framework hand-off for the full property list. The two patterns you’ll use most:
Pick one by name when a test cares about a specific dep:
@Testvoid integratesWithWorldEditWhenPresent() { File worldEdit = new File(System.getProperty("pluggy.test.dependency.worldedit")); MockBukkit.loadJar(worldEdit); plugin = MockBukkit.loadJar(new File(System.getProperty("pluggy.test.mainJar"))); server.getPluginManager().enablePlugin(plugin); // ...assert your integration hook ran}Boot everything when you want a maximal “production-like” mock server:
@Testvoid worksUnderFullStack() { for (String path : System.getProperty("pluggy.test.dependencies").split(File.pathSeparator)) { if (path.isEmpty()) continue; try { MockBukkit.loadJar(new File(path)); } catch (Exception ignored) {} } plugin = MockBukkit.loadJar(new File(System.getProperty("pluggy.test.mainJar"))); server.getPluginManager().enablePlugin(plugin);}The catch is required because library jars (Maven deps that aren’t plugins) appear in the catalog too, and loadJar errors with “no plugin.yml” on those.
Loading a real third-party plugin under MockBukkit can fail with JavaPlugin requires a valid classloader when that plugin’s entry class is already on the runtime classpath. For coverage that only needs to detect “is plugin X loaded?”, register a stub with the dep’s name via registerLoadedPlugin instead of booting the real plugin.
5. Test handlers without booting the plugin
For tests that exercise listeners, commands, or services without needing the full plugin lifecycle, drive ServerMock directly. The plugin reference can be a fake (MockBukkit.createMockPlugin()) or the real one if you’ve already loaded it:
class JoinListenerTest {
private ServerMock server; private JoinListener listener;
@BeforeEach void setUp() { server = MockBukkit.mock(); listener = new JoinListener(); server.getPluginManager().registerEvents(listener, MockBukkit.createMockPlugin()); }
@AfterEach void tearDown() { MockBukkit.unmock(); }
@Test void greetsJoiningPlayers() { var player = server.addPlayer("Alice"); assertTrue(player.nextMessage().contains("Welcome")); }}server.addPlayer() synchronously fires PlayerJoinEvent, so the listener runs and the assertion against nextMessage() reads the greeting your code sent.
Why loadJar and not load(Main.class)?
pluggy test does not put the plugin’s entry-point class (project.main) on the runtime classpath. The compile classpath includes it (so test code can import com.example.demo.Main), but the runtime classpath has it stripped out. That keeps the system classloader from resolving the entry-point first, which would trip Bukkit’s JavaPlugin requires a valid classloader check the moment MockBukkit tried to reload it through its own classloader.
Practical implications:
MockBukkit.loadJar(System.getProperty("pluggy.test.mainJar"))works. It loads the plugin from the jar.MockBukkit.load(Main.class)does not work in this layout. It requiresMain.classon the runtime classpath, which pluggy holds back.- Treat the loaded plugin as
Plugin(orJavaPlugin) in your test variable types. Direct runtime references toMain.classornew Main()throwNoClassDefFoundErrorbecause the .class isn’t on the runtime classpath.
Every other plugin class (utility classes, listeners, services) is on the runtime classpath as normal. Only the declared entry-point class is held back.
See test command Caveats for the mechanism.
Cleaning up between runs
When you delete or rename a test file, the old .class is still in the test staging directory and JUnit will keep finding it. Run with --clean to wipe the staging dir:
pluggy test --cleanYou don’t need --clean for normal edit-and-rerun cycles. javac overwrites changed classes in place, and the main jars are regenerated every run.
See also
pluggy test: full command reference, including the mocking-framework hand-off contract.project.jsonreference: declaringtestDependencies.- MockBukkit documentation: upstream docs and API reference.