-
Notifications
You must be signed in to change notification settings - Fork 7
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.
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.
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.
| 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.
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 thisTestFPlayerSender.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.
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.
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.
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.
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.
| 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 callsbus.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);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
Inventorycarried by the open event to be the same object that was passed toopenInventory. 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:
-
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. - 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.
-
common/src/testandminecraft/src/testare 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 reportsUP-TO-DATEand reuses the previous result, including aSKIPPEDfrom a run when a backend was down. -
docker-compose.ymlstarts MariaDB/PostgreSQL/MongoDB/Valkey/Redis for manual testing of the network backends. The unit suite never needs it.
-
Command Framework -
Commands.harness, the shape-error catalogue -
Storage Backends - the
storage.ymlStorageswrites -
PlayerData and PDSections - what
PlayerDataWorldboots -
Platform Abstraction - what
PlatformConformanceholds anIPlatformto -
Economy -
EconomyConformance - Building from Source - the toolchains and the Gradle tasks
EverNifeCore · Home · made by Petrus Pradella
Getting Started
Commands & Text
Player Data & Storage
- PlayerData & PDSections
- Accounts
- Storage Backends
- Inline Backends for Plugins
- Block Data (SVWorldDataManager)
- Legacy Data Migration
- Cooldowns
Config & Minecraft Systems
- Configuration
- Logging & Debug Modules
- Events
- Scheduler & Threading
- Items & NBT
- GUI Framework
- Integrations
- Economy
- Version Compatibility
Architecture & Reference