Skip to content

Testing Toolkit

Pétrus Pradella edited this page Nov 2, 2026 · 38 revisions

Testing Toolkit

What this page covers: evernifecore-common-tests - the test apparatus EverNifeCore uses on itself, published so your plugin can use the same one. Platform doubles, the PlayerData/storage lifecycle, the command harness, the senders, the fake plugin, and the conformance suites.

The point of publishing it is that a plugin testing against EverNifeCore otherwise has to invent a fake platform, a fake plugin handle and a fake storage layout - and every invented one drifts from what the core actually does.


Depending on it

repositories {
    maven { url = 'https://maven.petrus.dev/public' }
}

dependencies {
    testImplementation 'br.com.finalcraft:evernifecore-common-tests:3.0.1'

    // The platform classes common declares but does not carry (McFCScheduler and the
    // platoverride family) come from the platform jar itself, on the test classpath.
    testImplementation 'br.com.finalcraft:evernifecore-minecraft:3.0.1'
}

It brings JUnit 5, evernifecore-common and commons-lang3 along as api dependencies - every one of those shows up in its public signatures.

The one thing it cannot bring is the api-contracts stubs: those are compile-time-only placeholders that are never published and never packaged, because each platform ships the real class under the same fully-qualified name - see Platform Abstraction. Putting evernifecore-minecraft (or evernifecore-hytale plus evernifecore-common) on the test classpath is what supplies them.

It is compiled to Java 8 bytecode, like common, so a plugin whose tests run on an older toolchain can still use it.

Bukkit-side tests: evernifecore-minecraft-tests

The deployable evernifecore-minecraft jar relocates its Jackson stack and expects the server to download the rest at boot, so a plugin's Bukkit-side test cannot pair it with evernifecore-common-tests cleanly. For those tests depend on the Bukkit test kit instead:

dependencies {
    testImplementation 'br.com.finalcraft:evernifecore-minecraft-tests:3.0.1'
    testImplementation 'org.spigotmc:spigot-api:<the API your plugin compiles against>'
}

It carries:

  • the Jackson + snakeyaml stack the core downloads at boot, relocated the same way as in the deployable jar;
  • the whole evernifecore-common-tests engine (Platforms, Plugins, FinalCmdTestHarness, ...), rewritten onto the relocated names;
  • br.com.finalcraft.evernifecore.testing.minecraft.ItemWorld - a described server with an item factory, named worlds and the Bukkit config codecs:
try (ItemWorld ignored = ItemWorld.withMetadata(MCDetailedVersion.v1_21_R1)) {
    ItemStack sword = FCItemFactory.from(Material.DIAMOND_SWORD).displayName("&bDoom").build();
    // ...
}

ItemWorld.registerConfigTypes();          // the Bukkit config codecs, once per JVM, after a platform is installed
World lobby = ItemWorld.world("lobby");   // a named world, e.g. for a Location in a config
  • br.com.finalcraft.evernifecore.testing.minecraft.screen.BukkitScreenWorld - the real ChestScreenRenderer, its listener and the real Screens registry over a described server: containers that record every write (SurfaceDouble), players (PlayerDouble), a tick clock the test moves (SchedulerDouble) and clicks (ClickSimulator). install(dir) answers items that remember names, lore and flags, so a test reads the text the chest drew; over(screenWorld, dir) lays the same server over a ScreenWorld (close it first), on items with no metadata. A player of world.newPlayer(name) is online on the world's platform: EverNifeCore.getPlatform().getPlayer(uuid) finds them without Platforms.onlinePlayers(...):
try (BukkitScreenWorld world = BukkitScreenWorld.install(tempDir)) {
    PlayerDouble player = world.newPlayer("Steve");
    Screen<Layout> screen = Screen.of(1);
    screen.icon(0, Icon.of("minecraft:paper").displayName("§eHello").lore("§7World"));
    world.open(screen, player);
    world.advanceTicks(1);                                    // the batched commit lands
    ItemMeta meta = world.getSurface().getItem(0).getItemMeta();
    assertEquals("§eHello", meta.getDisplayName());
}

It replaces Bukkit.server and the item engine for its life: one at a time, in a try-with-resources, never beside an open ItemWorld. A message sent to a player (a click's getViewer().sendMessage(...), a LocaleMessage.send(player.asFPlayer())) lands as legacy text in PlayerDouble.getMessages(). A title and an action bar (McHudAccess, which takes the Bukkit API when no core plugin runs) land in getTitles() (PlayerDouble.Title; Title.CLEARED for a cleared one) and getActionBars().

A whole sale runs on it: world.withEconomy(Economies.inMemory().balance(uuid, 100).build()) installs the economy for the life of the world (put back on close(), the same form as ECoreTestWorld.withEconomy), and the player's inventory answers getStorageContents, so FCInventoryUtil.getAmount/contains/removeFrom count and take from it - with a ComparableItem, or a ComparableItemComplex, which compares by ItemStack.isSimilar when there is no NMS bridge to read a tag with.

Its platform is Platforms.lenient(), where makePlayerExecuteCommand throws and createPlaceholderIntegration answers null. install(dir, platform) adjusts it with a UnaryOperator<Platforms>, the same form as ScreenWorld.open(dir, platform): BukkitScreenWorld.install(dir, Platforms::capturingCommands) for a button that runs a command as the player (read it in world.getPlatform().getSenderCommands()), p -> p.parsingWith((viewer, text) -> ...) for the placeholder of an icon with integrateToPAPI(true).

Its POM brings evernifecore-minecraft and junit-jupiter at compile scope, and commons-lang3, jackson-annotations and log4j-api at runtime (what a server would otherwise supply). spigot-api is provided, so it never reaches you - declare the server API your plugin compiles against yourself.

⚠️ Never put evernifecore-common-tests or evernifecore-common on the same test classpath. Their classes are already inside the kit, rewritten onto the relocated names; a second, canonical copy of the same class names means whichever loads first wins.

Parsers for platform types (Player, World) and the relocated storage drivers (H2/Hikari) are not in the kit. There is no Hytale counterpart and none is needed: evernifecore-hytale is published thin, with canonical names and a POM that brings evernifecore-common, so evernifecore-common-tests runs against it directly.

A :common module compiles against evernifecore-common-relocated, so a member whose signature names Jackson resolves to the relocated names there. If its main code names JsonNode/ObjectNode (ConfigSection.getNode), test it on evernifecore-minecraft-tests: under evernifecore-common-tests that call compiles and fails with NoSuchMethodError, because the engine runs on canonical Jackson. The kit brings evernifecore-minecraft along; declare spigot-api as on the Bukkit side.


The doubles are strict by default

ECoreTestWorld world = Platforms.strict().install();

An unconfigured method on a strict double throws, and names the builder call that fixes it. That is the default on purpose: a double that quietly answers "nothing" is a double that agrees with your code instead of describing a server.

Platforms.lenient() is the old no-op behaviour, opt-in.

The refusal is a PlatformNotConfiguredException (an UnsupportedOperationException). Core code that only reports a failure - a subscriber on the global bus, the login claim of Deliveries - would turn it into a log line and a green test, so ECoreTestWorld watches the global bus's exception handler for it and fails close():

The platform double refused a call this test never configured, and the core only reported it (nothing failed where it
happened): [IPlatform#getPlatformProviderId not configured on this test platform - use
Platforms.strict().platformProviderId("test") (or Platforms.lenient() for the old no-op defaults)]
ECoreTestWorld world = Platforms.strict()
        .platformProviderId("test")
        .onlinePlayers(Arrays.asList(steve, alex))
        .pluginsLoaded("Vault", "PlaceholderAPI")      // installed and running
        .pluginsDisabled("WorldGuard")                  // installed, not running
        .papiPresent(true)
        .hudWithout(Capability.BOSS_BAR) // getHud() records every call; this one refuses like a 1.7.10 server
        .mainThreadInline()          // ECScheduler.onMainThread/onMainThreadLater run right here
        .capturingCommands()         // record what the code asks the server to run
        .recordingShutdowns()
        .install();

pluginsLoaded(...) answers true to both isPluginLoaded and isPluginEnabled; pluginsDisabled(...) answers only isPluginLoaded - a plugin that failed to enable or was disabled. It needs pluginsLoaded(...) too, even an empty one. A strict double asked either question without them throws, naming both answers: pluginsLoaded("X") when X runs, pluginsLoaded() with no names when no plugin does.

Off a server nothing loads the core's own @FCLocale classes, so a static LocaleMessage of a core class nobody loaded is null in a test: load it first (FCLocaleManager.loadLocale(pluginData, true, TheClass.class)). FCTimeFrame is the exception - without its locale it formats in English (1 minute and 30 seconds). Without an NMS bridge (every unit test) FCItemFactory.from("minecraft:<id>") resolves a vanilla id with no snbt through the Bukkit Material of the same name, and refuses any other id with a message that names the Bukkit material to write instead.

install() registers the double as the live platform and hands back an ECoreTestWorld that must be close()d; build() returns the TestPlatform without installing it. close() is strict about the global event bus: a listener watch or native audience that was not there at install() and still is fails the close with an AssertionError naming each one, so a test that leaks bus state is caught by name instead of changing some later class's gate counts. It stops the region singletons (RegionManager.closeAll()) first - the engine started them, so they are not the test's leak - and puts the dropping DeferredEffectSink back (DeferredEffects.register(null)), so a test that started Deliveries leaves no sink behind.

The guard does not look at plain subscriptions, on purpose: about twenty production subscriptions on the global bus have no owner and live for the JVM (FlagRegistry, RegionPlaceholders, Notifier, Dialogues, Deliveries, QuestEngine, ...), each made once on first use, so a guard over them would accuse every one. A test that wants to prove it removed its own compares ECEventBus.global().getSubscriptions(type).size() before and after (as GroupWorld does for the quit listener).

The core's plugin data goes back on close(). A world that needs the core's ECPluginData (the core's log, its debug modules) and finds none lends one, built on a lenient double so a strict world needs no logging for it; withCorePluginData(data) lends your own instead, for a test that asserts on the core's log. Either way close() puts back whatever was there before, null included (EverNifeCore.onLoaderInstantiate(null) clears it), so no test leaves its plugin data behind for the rest of the JVM. FinalCmdTestHarness lends through its world and hands back the same way. Running the suite with -Dec.debug=LOGIC,QUESTS (any ECDebugModule names, comma-separated) switches the core's debug and those modules on in every world, after the core is lent - ./gradlew :common:test -Dec.debug=LOGIC.

What a TestPlatform records

Accessor What it holds
getCaptured(label) / registrationOrder() / getUnregisteredLabels() command registration and unregistration
getConsoleCommands() every command handed to makeConsoleExecuteCommand, in order
getSenderCommands() every command handed to makePlayerExecuteCommand, as DispatchedCommand(sender, command)
getTestHud() the TestHud behind getHud(): getActionBarCalls(), getTitleCalls(), getBossBarCalls() (+ getBossBar(player, id), the last state), getSidebarCalls(), getToastCalls(), getTrackerCalls(), getSoundCalls(), getCompassCalls(), each a List<HudCall> (player, action, text, extras)
getInfoMessages() / getLoggedMessages() what was logged through an adapter this platform created
getShutdownReasons() what asked the server to stop
reset() clears all of the above

The three capture lists are the answer to a specific problem: forwarding a command or writing an action bar is the whole observable effect of some code. An alias, a locale sweep or a priority queue has nothing else to assert on, so a double that swallowed the call left the entire feature untested.

Both makeConsoleExecuteCommand and makePlayerExecuteCommand answer true under capturingCommands(): a double that kept the command did take it.

Platforms.hud() gives getHud() a TestHud with every capability; hudWithout(Capability...) is the same without the ones named - that is how a test becomes "1.7.10" or "Hytale" for a notify chain; hudAccess(IHudAccess) installs any double (null kept, as the violation a conformance test needs). Platforms.lenient() calls hud(); the strict double without it refuses getHud() naming Platforms.strict().hud().


The senders

TestCommandSender console = Senders.console();
TestFPlayerSender steve   = Senders.player("Steve");
TestFPlayerSender alex    = Senders.player("Alex", someUuid);

steve.grant("myplugin.use");
steve.online(false);                    // ... and anything still ticking for them reads this
steve.playedBefore(true);               // hasPlayedBefore() - false until set
steve.hide(alex);                       // steve.canSee(alex) is false from now on, as a vanish
steve.op(true);                         // hasPermission answers true to every node; isPermissionSet only what grant gave

TestFPlayerSender.online(boolean) exists so a test can cover what happens after a player leaves - an auto-terminating queue, a scheduled message with nobody left to send it to. Before it, isOnline() was a hard-coded true and those branches were unreachable.

Both senders keep every message they were sent, so assertions read off getMessages() rather than a captured stream. TestFPlayerSender goes further and reads the rich text back: hoverTextOfMessageContaining(snippet), clickValueOfMessageContaining(snippet), anyMessageContains(snippet), plus assertAnyMessageContains / assertNoMessageSent.

Senders at a point: at(world, x, y, z)

A TestFPlayerSender has no location until a test places it: getLocation() returns null. The command paths never read it; region code does - RegionLookup.resolve(flag, player), at(player), the here verb - so a region test stands the player somewhere first:

TestFPlayerSender steve = Senders.player("Steve").at("world", 5, 64, 5);   // getLocation() answers this from now on
steve.teleportTo(otherLocation);                                         // moves it too, and records it in getTeleports()

Regions: RegionWorld

RegionWorld is one plugin that owns regions: its ECPluginData, an in-memory ECStorage that counts calls (getBackend().callsTo("save")), its RegionManager already loaded, and - shared by every RegionWorld open at once - a region clock that moves only when told and a region scheduler that runs only when told. It needs a platform with mainThreadInline() and worldEvents().

try (RegionWorld prot = RegionWorld.open("FakeProt", tempDir)) {
    Region arena = prot.cuboid("arena", "world", 0, 0, 0, 10, 10, 10);   // an AssertionError names a refusal
    prot.advance(Duration.ofMinutes(6));                                // the clock moves, nothing runs yet
    prot.runDue();                                                      // the due tasks run once (sweeper, writes)
    List<RegionRemovedEvent> removed = prot.recorded(RegionRemovedEvent.class);
}

openUnloaded(...) gives a manager that never loaded - what a plugin sees while its regions are still loading. flush() writes the owner's pending changes now; getLookup() is the one global lookup every manager publishes to, so two RegionWorlds open in one test are two owners over the same lookup.

Inside this repository, the older tests of common's region package (and a few of flag) build regions with TestRegions, a helper in common/src/test that reaches the package-private constructor and index directly. It is not published and is not the model: a plugin's test, and a new core test, uses RegionWorld, which goes through the same public path a plugin does.

Groups: GroupWorld

GroupWorld is one plugin that owns groups (and regions): a RegionWorld (regions(), same owner, storage and clock) plus a loaded GroupManager on that storage. kind(name, cardinality, roles...) registers a kind, group(name, kind) creates through the manager, player(name) is online and visible to the platform's getOnlinePlayers()/getPlayer(uuid), quit(player) posts ECPlayerQuitEvent while the player is still online and then takes it offline, login(player) puts it back online and posts ECPlayerFullyLoggedInEvent, notifier() wires a RecordingNotifier as the chat channel (what an edge or a kick sends lands there), recorded(type) lists the group events, advance/runDue move the shared clock and reopen() gives an unloaded manager to register kinds before load(). close() fails while a group listener is still on the bus. Platforms.withGlow() makes the platform able to glow (TestPlatform.getTestGlow(), a TestGlow that records glow/unglow); without it nothing glows, as on 1.7.10 or Hytale.

try (GroupWorld parties = GroupWorld.open("FakeParty", tempDir)) {
    GroupKind party = parties.kind("party", GroupKind.Cardinality.ONE);
    Group alpha = parties.group("alpha", party);
    alpha.setFlag(CoreFlags.MAX_MEMBERS, 2);
    assertTrue(alpha.join(parties.player("Steve")).isPassed());
}

Logic: LogicWorld

LogicWorld is one plugin that owns logic: its ECPluginData, an in-memory ECStorage that counts calls (getBackend().callsTo("save")), hands back the last entity a collection saved (lastSaved(collection, type)) and holds writes on demand (holdWrites() / releaseWrites() in arrival order, pendingSaves(); heldWrites() and releaseNewestWrite() let them through newest first, and lastLanded(collection, type) is the entity the backend took last - for code that must survive writes completing out of order), the core's stock types registered while any LogicWorld is open, and the clock and scheduler of TestClock - the same ones RegionWorld uses, so the two open in one test see the same hour and runDue() runs both. Beside a GroupWorld (what a reward test opens) it adds its players to the platform's online list instead of replacing it, and puts the stock - the reward pieces included - on a core plugin data the test already built, leaving it there when it closes. It needs a platform with ignoringPlatformRegistrations() and mainThreadInline(); platformProviderId(...) for a type that names platforms or capabilities, playerState() for what reads a player's state, and offWorldThread() to prove an effect is dispatched to the world thread.

try (LogicWorld shop = LogicWorld.open("FakeShop", tempDir)) {
    TestFPlayerSender alex = shop.player("Alex");             // online on the platform, at 0, 64, 0 of "world"
    List<String> trace = shop.trace(alex);                    // the lines /ecore logic trace would send
    GateResult paid = gate.pay(shop.context(alex).build());   // a context already carrying the FakeShop caller
    shop.advance(Duration.ofMinutes(10));
    shop.runDue();
    List<GatePaidEvent> events = shop.recorded(GatePaidEvent.class);
}

customVariables() and cooldowns() are Variables.custom(owner, storage) and Cooldowns.of(owner, storage) over the world's storage; boosters(), dynamicValues(), contributions() and auditLog() are the plugin's BoosterManager, DynamicValues, Contributions and AuditLog over it (the booster engine registered with the stock types); deliveries() brings up an in-memory PlayerData layer with the core's deliveries section and answers Deliveries.get(); quit(player) takes a player offline as a quit does and rejoin(player) puts it back online (posting the login event is the test's); awaitAudit() blocks until every write the owner's open audit log started landed (AuditLog.awaitWrites()), so a test reads the log without polling; close() stops the traces, closes the storage and forgets the plugin and its types. A test that holds player state with scope: held calls HeldPlayerState.forget(uuid, null) in its tear-down: the state is one per player, static.

Screens: ScreenWorld

ScreenWorld opens screens with no server: a strict platform with only what a screen asks for, a RecordingRenderer registered for the test owner, and a tick clock (TickScheduler) the test advances.

try (ScreenWorld world = ScreenWorld.open(tempDir)) {          // optional RendererCapability... for the recorder
    TestFPlayerSender steve = world.viewer("Steve");
    ScreenView view = world.open(screen, steve);
    world.click(view, "buy", ClickGesture.PRIMARY);              // or click(view, slot, gesture, kind); see below
    world.advanceTicks(1);                                       // the batched commit lands
    RecordingSurface surface = world.getRenderer().surfaceOf(view);
    RenderedIcon shown = surface.getSlot(13);                    // also getApplies(), getLastPatch(slot), getTitles()
    List<ScreenOpenEvent> opened = world.recorded(ScreenOpenEvent.class);
    view.close();
    world.advanceTicks(2);
}

prompts() hands the FakePromptChannels registered in the real PromptRegistry (one fake CHAT serving everybody; register(PromptKind, supports), type(text), closeWindow(), lastOpened(), each fake's getOpened()/ getDismissed(); a question is armed one tick after it is asked), answeringPromptsWith(...)/answerPrompt(text) answer through another platform's channel instead. click(...) answers whether the item move the gesture would make may go through, never whether a handler ran - assert the click's effect, not that boolean. getRenderer().refuseOpens(true) makes every open answer no window; refuseNextOpen() refuses only the next one, so a test of a screen refused on top still sees the way back to the screen below open. The world's platform answers platformProviderId("test"). withRenderer(other) adds a renderer of the test's own, tickingWith(...) hands the clock to another rig, and ScreenWorld.open(tempDir, Platforms::offWorldThread) adjusts the platform (a renderer proving it hops to the viewer's world thread - the Hytale page renderer's tests do). close() fails while a view is open, a question is still out, or a source a screen held (dependsOn) is still needed. It lives beside LogicWorld (the default deliveries screen reads LogicWorld.deliveries()). The same screens on the real Bukkit chest: BukkitScreenWorld in evernifecore-minecraft-tests.

Dialogues: DialogueTrees

DialogueTrees builds dialogue trees for a test (linear(owner, name, lines...), withLoopWithoutExit(owner, name), fromYaml(owner, name, yaml)) and reads what the default dialogue screen drew (openDialogueScreen(screenWorld, player, dialogue) registers, starts and answers the DrawnChoices - enabled or greyed, lore, reason -; drawn(screenWorld, player) reads them again). It needs a ScreenWorld and the owner's LogicWorld, nothing of its own.

Economy

ECoreTestWorld world = Platforms.strict().ignoringPlatformRegistrations().mainThreadInline()
        .withInventory(36).install();                        // a TestInventory as evernifecore:inventory

TestCurrency gems = Economies.currency("fakeshop:gems", CurrencyOwner.Kind.PLAYER, CurrencyOwner.Kind.GROUP)
        .balance(CurrencyOwner.player(alex.getUniqueId()), 100);
world.withCurrency(plugin, gems, "gems");                    // registered in Currencies until the world closes
gems.getTakes();                                             // takes that moved funds

TestInventory inventory = world.platform().getTestInventory();
inventory.add(alex, ItemSpec.of("minecraft:dirt", 64));      // what did not fit comes back
inventory.count(alex, ItemMatcher.of("minecraft:iron_ingot"));

Economies.currency(id[, kinds]) is an in-memory AbstractCurrency (every kind when none is named); Economies.inMemory().balance(uuid, amount).build() / Economies.absent() are the doubles of the money bridge (ECoreTestWorld.withEconomy). withInventory(slots) gives each player slots slots of 64 items of one id; without it an item cost or effect fails naming that builder.

Quests: QuestWorld

QuestWorld.open(pluginName, dataFolder) is a fake plugin's quest engine on a ScriptedStorage, its QuestWorld.TestQuestSection registered, on the shared test clock (advance, runDue): player(name) logs a player in with the section loaded, post(context) / pre(context) hand an action to the index (breakBlock, pickup, kill, contexts()), region(...) / move(player, pos) drive the region objectives, recorded(Class) reads the bus, getIndex() the dispatch, and close() fails on a leaked subscription. QuestDefinitionsFixture / registerFixtures(...) load the sample quests from quests/*.yml; RecordingQuestPresenter records what the presenter was asked to show.

Actions and provenance: ActionWorld

ActionWorld is one plugin's game actions with no Bukkit nor Hytale: a RegionWorld (owner, storage, clock) plus the two steps every native listener takes - pre(context) (the regions decide, then the source's .Pre; true = denied) and post(context) (the .Post, built only with a subscriber) -, contexts() to build what they carry (place, breakBlock, spawn, drop, pickup, kill), isNeeded(source) and provenance(), the owner's ProvenanceTracker on the world's storage. Same platform needs as RegionWorld.

try (ActionWorld actions = ActionWorld.open("FakeJobs", tempDir)) {
    ProvenanceTracker origins = actions.provenance();
    try (ProvenanceHold recording = origins.record()) {
        actions.post(actions.contexts().place(alice, "world", 1, 2, 3, "minecraft:stone"));
        assertTrue(origins.getOrigin(WorldBlockPos.of(1, 2, 3, "world")).join().isOwn(alice));
    }
}

close() closes the tracker, then the regions and the storage.

The Bukkit event pipeline: BukkitEventWorld

Inside this repository (not published), minecraft/src/test/.../testkit/BukkitEventWorld runs Bukkit's real SimplePluginManager over a Server double that answers the main thread and a scheduler that queues, with EverNifeCore enabled on top and its two native audiences on the global bus. install(tempDir) in a try-with-resources (it puts Bukkit.server, the shared handler lists and the global bus back on close()), then listen(type, handler) as a plugin would, subscribe(...) on the bus, registerECListener(listener) the way ECListener.register does, and getPlatform() for what was logged. The on-demand producers, the ProtectionAll bridge and the Bukkit side of the event bus are tested on it.


The fake plugin

IECPluginExtractor extractor = Plugins.fake("MyPlugin", dataFolder);

...answers for a single fake plugin, whatever object it is handed. That is convenient and it is also a lie: a real extractor answers plugin instanceof JavaPlugin, and refuses everything else.

IECPluginExtractor extractor = Plugins.fakeRecognisingOnly("MyPlugin", dataFolder, thePlugin);

...recognises only thePlugin. Anything else is refused by IECPluginExtractor.validateJavaPlugin, which is what registering a stray object looks like on a server - and the branch that says so is otherwise untestable.

Plugins.setLanguage(plugin, "pt_br") sets the language a plugin handle answers with.


The PlayerData / storage lifecycle

PlayerDataWorld world = PlayerDataWorld.with(Storages.h2("test"))
        .sections(CoinsSection.class, StatsSection.class)
        .accountSections(NetworkStatsSection.class)
        .boot(tempDir);

// ... exercise the code ...

world.reboot();     // flush, close, and load again from the same files
world.close();

sections / accountSections derive the section id from the class simple name; section(id, cls) and accountSection(id, cls) state it, for a test that asserts on the storage identity.

world.flushAll() writes every dirty row now (what fires onFlushed()), and world.remoteChange(section, key, op, version) delivers a change event as if another server had written (SAVE) or deleted (DELETE) that row - through the real cache-sync, so it reaches onRemoteChange of the live instance and the cell goes stale. It needs cache-sync on, which Storages.memory() has.

Storages writes the storage.yml a real server would have, with the same keys and the same validation:

Factory Backend
Storages.memory() in-memory
Storages.localFile() / Storages.groupedFile() file backends
Storages.h2(dbName) H2

...plus backendId, dataPath, extraBackend(...), extraBackendDisabled(...), networkBackendId, networkLines, withoutNetworkBlock, loadModeAll / loadModeRecent(days), playerdataLines and rawLines for the cases that need a hand-written block. toYaml(baseDir) renders it and writeTo(baseDir) puts it on disk.

No Docker is needed for any of this - the unit-test backends are in-memory, H2 and files.


The command harness

FinalCmdTestHarness harness = Commands.harness("mytest", dataFolder);
Call What it gives you
Commands.harness(prefix, dataFolder) registers commands, dispatches lines, and asserts on the resulting tree
Commands.referenceTree() a four-level reference tree to assert traversal against
Commands.shapeErrors() the whole catalogue of illegal command declarations, as cases
CommandShapeErrors.check(harness) runs that catalogue against a harness

So a plugin that builds command trees tests them without copying a fixture. See Command Framework.


Conformance suites

List<String> problems = PlatformConformance.check(myPlatform);
PlatformConformance.checkArgParsers(plugin, expectedContextualParsers);
PlatformConformance.summarize(myPlatform);

EconomyConformance.check(currency);                              // read-only
EconomyConformance.checkMutating(currency, scratchOwner, caller);  // moves money on a wallet you name

ECEventConformance.checkAll(MyEvent.class, MyOtherEvent.class);

List<String> failed = RendererConformance.check(myRenderer, screenWorld);

PlatformConformance is what a new IPlatform implementation is held to. Its thread probes observe the hop: runOnWorldThread("world", task) must hand back a future that completes within 5 s ("did not complete" otherwise - a task nobody runs), and one that completes normally must have run the task where isWorldThread("world") answers true; completing exceptionally is the answer of a platform without that world. runOnWorldThreadNextTick is held to the same only when the caller is not the world's thread, since that thread would wait on the tick it has to give itself - that the task lands in a later tick is proved in a live server (world-thread-next-tick, see In-Server Conformance). onWorldLoaded/onWorldUnloaded must refuse a null listener; the check registers no listener of its own, since one is kept for the life of the process. EconomyConformance is the same idea for a Currency - see Economy. ECEventConformance holds an ECEvent subtype to the names its platform base reserves; it checks the classes you name, so a new event only becomes guarded once you add it to the call - see Events. RendererConformance is what every ScreenRenderer is held to: one named scenario per failure (open-registers-view, patch-batches-per-tick, close-runs-onclose-once, prompt-sets-aside-and-restores, capability-refusal-teaches..., and one per element kind: label-draws-its-text, choice-disabled-carries-reason, progress-reports-ratio, input-asks-through-a-prompt, list-pages-with-arrows); the renderer must be registered in the ScreenWorld it is handed. check(renderer, world, Supplier<FPlayer> viewers) takes the player each scenario opens for, for a renderer whose supports needs a per-player handshake - see Screens.

ECEventConformance checks three things: the reserved names (isAsynchronous, getEventName, getHandlers, callEvent), isCancelled/setCancelled on an event that does not implement ECCancellable, and - for the one Bukkit member an event may declare - the shape of getHandlerList. Bukkit finds that method by name up the hierarchy, invokes it static and casts what comes back, so the guard accepts only a public, static, no-parameter, non-void one and names the ECEvent.getHandlerListOf(...) form in the failure line.

Every check/checkAll here reports rather than throws: it returns one line per violation and an empty list when the subject conforms, so the caller decides whether that is an assertion failure or a log line.

These run headless against whatever you hand them. In-Server Conformance runs the same three against the platform a real server registered, inside the server.


Other helpers

Helper What it does
Logs.capture(Runnable) the log lines a piece of code produced, as a list
Logs.captureThrowing(Runnable) the same, letting the exception through
Locales.perPlayerLocale(baseDir) a world where each reader has their own language; reader(name, lang) builds one
Locales.message(plugin, key, langAndText...) a LocaleMessage built inline
Economies.inMemory().balance(uuid, amount).build() a working money bridge; Economies.absent() is none at all; Economies.currency(id[, kinds]) a currency of its own (Economy)
EventBuses.mirroring() / mirroring(handler) an event bus that mirrors into its audiences without being the global one; the second form gives it an ECEventExceptionHandler of its own
EventBuses.installExceptionHandler(bus, handler) swaps the handler of any bus - the global one included - and hands back the previous one to restore
new FailingExceptionHandler() the handler that fails the test: a broken subscriber or watch callback becomes an AssertionError at the post, with the real failure as its cause
new RecordingExceptionHandler() the handler that writes failures down and throws nothing: getFailures() (subscription or watch, event, throwable), getFailureCount(), reset()
new RecordingAudience() a native audience with no native bus behind it: getDispatched(), getDispatchedOf(Class), setHasListeners(boolean), getGateChecks(), getGateCheckLog(), reset()
Plugins.fakePluginData(name, dir) / Plugins.forget(name) a ready ECPluginData for a plugin that exists only for the test - to own subscriptions and watches - and its removal; it installs its own extractor
Plugins.fakeOther(name, dir) the ECPluginData of a second fake plugin (the owner of a currency, a flag or a group kind) built without replacing the extractor the world installed, so every other lookup still names the world's plugin; Plugins.fakeCore(dir) is fakeOther(Plugins.CORE, dir) - over a core someone else registered (a BukkitScreenWorld's) it hands that one out, and forget(Plugins.CORE) then leaves it registered

RecordingAudience implements the audience gate as hasListeners(Class<? extends IECEvent> eventType)

  • the bus asks per class, before any event exists - and records every ask in getGateCheckLog() (event type, thread, the codebase frames that led to it; getGateChecks() is its size), so zero is the proof the bus never even considered the audience, and a non-zero count names who asked. The bus asks on its own too - every listener-watch refresh (addNativeAudience, subscribe) and every post from any thread - so a test measuring one action calls reset() right before it. setHasListeners(...) flips the gate but does not tell the bus by itself: a test driving the listener watches calls bus.refreshListenerWatches() afterwards, exactly as a real native audience does when its own listeners appear or vanish.
ECEventBus bus = EventBuses.mirroring();
RecordingAudience audience = new RecordingAudience();
audience.setHasListeners(false);
bus.addNativeAudience(audience);

List<String> log = new ArrayList<>();
bus.watchListeners(() -> log.add("first"), () -> log.add("last"), SampleEvent.class);
assertEquals(Collections.emptyList(), log);

audience.setHasListeners(true);
bus.refreshListenerWatches();
assertEquals(Collections.singletonList("first"), log);

A broken subscriber fails the test. @ECoreTest installs a FailingExceptionHandler on the global bus for the length of the class and puts the previous handler back afterwards, so a subscriber that throws on the global bus fails the test at the post that drove it - instead of a SEVERE nobody reads. A test whose point is the breakage uses a scoped bus with a RecordingExceptionHandler and asserts on what the handler was handed:

RecordingExceptionHandler recording = new RecordingExceptionHandler();
ECEventBus bus = ECEventBus.create(recording);
bus.subscribe(SampleEvent.class, event -> { throw new IllegalStateException("broken on purpose"); });
bus.subscribe(SampleEvent.class, received::add);

bus.post(new SampleEvent());

assertEquals(1, received.size());                       // the queue behind the failure still ran
assertEquals(1, recording.getFailureCount());
assertTrue(recording.getFailures().get(0).getThrowable() instanceof IllegalStateException);

The rule this toolkit exists to enforce

A double that agrees with the code instead of imitating the server makes the guarded branch unreachable, and the suite stays green while the guarantee rots.

That is not a slogan. Two serious production defects were found in this repository within a day of each other, both invisible to a suite of over 1400 passing tests, and both for the same reason:

  • No GUI screen opened on the 1.7.10 floor. Production needed the Inventory carried by the open event to be the same object that was passed to openInventory. Real CraftBukkit builds its own container and hands out a different wrapper, so the check never matched. The test double built its view over the very instance it was given, synchronously - it could not fail that check even in principle.
  • A stored inventory came back empty from every backend. Production refuses an item write off the main thread. Storage answers on a worker thread, so the decode threw, the value was quietly dropped and the next save wrote the emptiness over the real data. The test double answered isPrimaryThread() by comparing against the test's own thread, so no test could ever be off it.

In both cases the test existed and passed. What was missing was not coverage - it was a double refusing to satisfy the condition by construction.

Two practical corollaries:

  1. A lenient double is the dangerous direction. One that is stricter than the server produces a false red, which somebody investigates. One that is more agreeable than the server produces a false green, which nobody does. This is why Platforms.strict() is the default.
  2. A knob is not enough - the double has to record. A flag that makes a double accept something it then silently discards closes nothing: the branch is reachable and still unobservable. Every capability added to this engine answers a question, it does not merely permit one.

Working in this repository

  • common/src/test and minecraft/src/test are JUnit 5 on the Java 25 test toolchain (main code stays capped at 8).
  • Reach for the engine before writing a @BeforeAll. A test that needs setup the engine does not have is a reason to enrich the engine, then use it.
  • Change the platform, update the engine in the same commit. Compatibility with an older engine is explicitly not a goal - the two move together.
  • Re-running a test task needs --rerun-tasks; otherwise Gradle reports UP-TO-DATE and reuses the previous result, including a SKIPPED from a run when a backend was down.
  • docker-compose.yml starts MariaDB/PostgreSQL/MongoDB/Valkey/Redis for manual testing of the network backends. The unit suite never needs it.

See also

Clone this wiki locally