Skip to content

Testing Toolkit

Petrus Pradella edited this page Aug 16, 2026 · 4 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.


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.

ECoreTestWorld world = Platforms.strict()
        .platformProviderId("test")
        .onlinePlayers(Arrays.asList(steve, alex))
        .pluginsLoaded("Vault", "PlaceholderAPI")
        .papiPresent(true)
        .actionBarSupported(true)
        .mainThreadInline()          // a "hop to the main thread" runs right here
        .capturingCommands()         // record what the code asks the server to run
        .recordingShutdowns()
        .install();

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.

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)
getActionBars() every action bar handed to the platform, as ActionBarSend(player, text)
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.

actionBarSupported(...) says whether this server can show an action bar at all - a 1.7.10 server without NecroTempus cannot, and the code that sends one asks before building anything. It does not decide what is recorded: an action bar that does reach the platform is kept either way.


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

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.


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)
        .boot(tempDir);

// ... exercise the code ...

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

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(provider);                          // read-only
EconomyConformance.checkMutating(provider, scratchAccount);  // moves money on an account you name

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

PlatformConformance is what a new IPlatform implementation is held to. EconomyConformance is the same idea for an IEconomyProvider - 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.

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.


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 economy provider; Economies.absent() is none at all
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(), 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

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

  • the bus asks per class, before any event exists - and counts every ask in getGateChecks(), so zero is the proof the bus never even considered the audience. 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