The Minecraft test framework that got tired of watching you test everything by hand.
Anvil gives JUnit control over:
- real Paper, Spigot, NeoForge, Velocity, and BungeeCord processes
- the packaged plugin your users will actually install
- lightweight clients speaking the real Minecraft protocol
- complete environments that can also stay open for a developer to join
No mocked server. No full game client. No small orchestra of terminals waiting for you to conduct. The only fake part is Alice, and she is far more consistent than you are after the twentieth reconnect.
Suppose your plugin should show a message when a player meets some condition.
Testing it once is easy:
- Build the JAR.
- Start the server.
- Join the server.
- Recreate the condition.
- Look at chat.
Then something changes:
- a permission check
- a dependency
- the server platform
- the Minecraft version
Back to step one. The check did not become difficult; it simply acquired a permanent claim on a developer's time.
And that was one message.
A larger plugin accumulates whole player journeys. An authentication plugin may need to verify:
- new and returning players
- accepted and rejected credentials
- kicks and repeated reconnects
- direct joins and proxy joins
- backend transfers and forwarded identities
- restarts and persisted state
Now multiply that by the supported server platforms and Minecraft versions, then perform the sacred ritual before every release. Eventually “we tested it” means “we remembered most of it.” Inspiring.
With Anvil, the test does the boring part:
- Describe the real environment once.
- Create however many players the journey needs.
- Let them join, chat, run commands, click, move, reconnect, or cross a proxy.
- Verify what the players and servers actually observe.
Run the same journey after every change and before every release. Point it at another Minecraft version instead of reenacting it. If a developer genuinely needs to look around, start the same scenario in the foreground and hand them the join address.
When something fails, Anvil keeps the process logs. This is slightly more useful than remembering that the message definitely appeared yesterday.
Keep your unit tests. Anvil handles the ugly part where the packaged plugin must survive contact with an actual Minecraft server.
Anvil requires Java 21 or newer. It runs each Minecraft server on the LTS JDK that server's version prefers (the Java table lists them), because naturally the server and the test framework cannot always agree on one.
Add the repositories to settings.gradle.kts:
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://registry.whereareiam.me/maven/packages")
}
}
dependencyResolutionManagement {
repositories {
mavenCentral()
maven("https://registry.whereareiam.me/maven/packages")
maven("https://repo.opencollab.dev/main/")
maven("https://repo.opencollab.dev/maven-snapshots/")
}
}Choose the plugin that matches the workflow:
| Plugin ID | What it installs |
|---|---|
me.whereareiam.anvil |
anvilScenarios, anvilRun, anvilLogin, anvilLogout, and IDE discovery |
me.whereareiam.anvil.junit |
Automated anvilTest, suite tasks, and anvil to run them all |
Neither plugin installs capabilities or a protocol library. Apply capability units, such as
me.whereareiam.anvil.capability.default for the whole built-in set, and add a protocol library, such
as the bundled protocol-mcprotocol, to anvilRuntimeOnly when scenarios use simulated players.
Platform units are applied separately: me.whereareiam.anvil.platform.paper,
me.whereareiam.anvil.platform.spigot, me.whereareiam.anvil.platform.neoforge,
me.whereareiam.anvil.platform.velocity, and
me.whereareiam.anvil.platform.bungeecord. Single capability units such as
me.whereareiam.anvil.capability.inventory replace the default set when only some are wanted.
Each player speaks through one protocol library. anvil { engine { protocolLibrary("mcprotocol") } }
makes one library the default; without it, each player uses the installed library with the strongest
support for its Minecraft version, and a scenario or player can still choose another one.
Authentication belongs to the protocol library. Manage local account files from the Anvil IntelliJ
panel, sign in with ./gradlew anvilLogin --account=<id>, or place account files generated by the
library in the configured account directory. MCProtocolLib supplies Microsoft device-code
authentication through both of those entry points and keeps its own account files.
Global accounts default to ~/.anvil/accounts; project-specific account directories and account
pools are managed from the Accounts action in the Anvil tool window. Named pools are declared in
the account directory's pools.properties, which other machines can write by hand. Account IDs are referenced
from a player's PlayerLogin, while the library retrieves the real username and UUID.
Apply Anvil where the scenarios live. Capabilities, platforms, and the protocol library are always explicit:
plugins {
java
id("me.whereareiam.anvil") version "0.0.1"
id("me.whereareiam.anvil.capability.default") version "0.0.1"
id("me.whereareiam.anvil.platform.paper") version "0.0.1"
}
dependencies {
add("anvilRuntimeOnly", "me.whereareiam.anvil:protocol-mcprotocol:0.0.1")
}
anvil {
acceptEula()
engine {
protocolLibrary("mcprotocol")
}
artifact("plugin-under-test", project(":plugin"))
}The EULA setting records your acceptance of the Minecraft EULA. Anvil can start five servers before breakfast, but it will not accept legal agreements on your behalf.
The plugin creates a dedicated anvil source set under src/anvil. Production code stays in
src/main, ordinary unit tests stay in src/test, and server scenarios get their own little
dimension. In a single-project build, register the artifact from tasks.named("jar") instead of
project(":plugin").
For direct embedding without the Gradle plugin, use
me.whereareiam.anvil:launcher; do not depend on engine directly. The launcher distribution
contains the executable composition root and public contracts. Protocol libraries and platform
providers are selected runtime dependencies, so the launcher itself does not impose MCProtocolLib or a
platform SDK on consumers. Add the selected library, platform providers, and matching platform-agent
artifacts to the embedding application's runtime classpath, and set EngineOptions.protocolLibrary
when players should default to one library.
Create an API ScenarioEngine with AnvilLauncher.create(options); EngineOptions lives in
me.whereareiam.anvil.api.model.
Use AnvilLauncher.builder().options(options).extension(extension).build() to add application
diagnostics or scenario lifecycle observers. Engine extensions use the global API; capabilities,
platforms, protocols, and agents have their own extension contracts in the
documentation.
For another tooling integration that needs the foreground shell, use
me.whereareiam.anvil:tooling-runner. Supply AnvilRunner with terminal streams and a factory
for an owned ScenarioEngine. For executable CLI and IDE entry points using the default engine,
use me.whereareiam.anvil:tooling-launcher; the Gradle plugin supplies that runtime automatically.
Add the bundled MCProtocolLib library to anvilRuntimeOnly when using simulated players. Apply one
platform unit for every platform used by the scenario, alongside the Anvil plugin:
plugins {
id("me.whereareiam.anvil") version "0.0.1"
id("me.whereareiam.anvil.platform.paper") version "0.0.1"
id("me.whereareiam.anvil.platform.velocity") version "0.0.1"
}Each unit owns its provider and matching agent artifacts. The selected agent is installed automatically into the managed workspace; consumers do not configure agent ports, tokens, or plugin paths.
Custom capabilities can call typed operations installed in a server or proxy's platform agent. Package the native handler separately and install it as a workspace asset. Anvil supplies process-scoped request channels to the host capability and replaces their underlying agent connections when a process restarts. The documentation covers the complete extension workflow.
The umbrella plugin includes default, the aggregate of all built-in capabilities. For a smaller
compile and runtime surface, use me.whereareiam.anvil and apply individual capability
units:
plugins {
id("me.whereareiam.anvil") version "0.0.1"
id("me.whereareiam.anvil.capability.session") version "0.0.1"
id("me.whereareiam.anvil.capability.messages") version "0.0.1"
id("me.whereareiam.anvil.capability.players") version "0.0.1"
}The available built-in unit IDs are session, messages, movement, inventory, interaction,
commands, dialogs, cookies, screen, sounds, and entities for players, and dispatch and players for servers and proxies. The umbrella plugin uses the separate default unit internally to include the
aggregate artifact. Unit plugins own their capability artifact coordinates; the scenarios plugin has
no built-in capability catalog.
An external capability is installed on the Anvil source set:
dependencies {
add("anvilImplementation", "com.example:combat:1.4.0")
}Omit the block entirely when no simulated-player behavior is needed. A scenarios-only project can
then start a real environment for a developer to join. A JUnit-only project can test process,
artifact, or server lifecycle behavior without installing Session or any other player capability.
Create src/anvil/java/com/example/test/PaperScenario.java:
package com.example.test;
import me.whereareiam.anvil.api.model.process.Distribution;
import me.whereareiam.anvil.api.model.process.MinecraftServer;
import me.whereareiam.anvil.api.model.scenario.AnvilScenario;
import me.whereareiam.anvil.api.model.workspace.AssetSource;
import me.whereareiam.anvil.api.model.workspace.WorkspaceAsset;
import me.whereareiam.anvil.api.model.workspace.WorkspaceCache;
import me.whereareiam.anvil.api.model.workspace.WorkspaceCleanup;
import me.whereareiam.anvil.api.model.workspace.WorkspacePlan;
import me.whereareiam.anvil.api.scenario.AnvilScenarioDefinition;
import me.whereareiam.anvil.api.type.Platforms;
import me.whereareiam.anvil.api.type.AssetInstallMode;
import me.whereareiam.anvil.api.type.CachePolicy;
import me.whereareiam.anvil.api.type.CleanupPhase;
import me.whereareiam.anvil.api.type.WorkspaceMode;
import java.nio.file.Path;
public final class PaperScenario implements AnvilScenarioDefinition {
@Override
public AnvilScenario define() {
MinecraftServer paper = MinecraftServer.builder()
.name("server")
.platform(Platforms.PAPER)
.distribution(Distribution.remote("1.21.11", "132"))
.workspace(WorkspacePlan.builder()
.mode(WorkspaceMode.PERSISTENT)
.asset(WorkspaceAsset.builder()
.group("plugin")
.source(AssetSource.artifact("plugin-under-test"))
.target(Path.of("plugins", "plugin-under-test.jar"))
.mode(AssetInstallMode.ALWAYS)
.build())
.cache(WorkspaceCache.builder()
.group("plugin-libraries")
.path(Path.of("plugins", "ExamplePlugin", ".libraries"))
.key("example-plugin-libraries")
.policy(CachePolicy.RESTORE_AND_SAVE)
.build())
.cleanup(WorkspaceCleanup.builder()
.group("generated")
.path(Path.of("logs", "previous-run"))
.phase(CleanupPhase.BEFORE_START)
.build())
.build())
.memoryMegabytes(1024)
.build();
return AnvilScenario.builder()
.name("paper-1.21.11")
.entrypoint(paper.getName())
.server(paper)
.build();
}
}That class defines the environment. It does not reserve three players, predict their names, or ask how many friends you have. Players belong to the running test and are created when needed.
package com.example.test;
import me.whereareiam.anvil.api.player.SimulatedPlayer;
import me.whereareiam.anvil.api.scenario.ScenarioContext;
import me.whereareiam.anvil.capability.messages.Messages;
import me.whereareiam.anvil.capability.session.Session;
import me.whereareiam.anvil.integration.junit.AnvilTest;
import org.junit.jupiter.api.Test;
final class PluginTest {
@Test
@AnvilTest(PaperScenario.class)
void reportsThatItIsReady(ScenarioContext anvil) {
SimulatedPlayer alice = anvil.players().create("Alice");
Session session = alice.capability(Session.class);
Messages messages = alice.capability(Messages.class);
session.connect();
session.connected();
messages.command("myplugin status");
messages.received("Ready");
}
}Run it:
./gradlew anvilTestAnvil provisions the server, installs the asset, restores declared caches, waits until everything is ready, connects Alice, runs the test, and cleans up afterward. Alice never asks whether this can wait until tomorrow.
Assets, caches, and cleanup are separate declarations. Asset targets are process-workspace-relative:
file sources are copied to the target file and directory sources copy their contents into the target
directory. SEED_ONCE only installs into an empty persistent target; ALWAYS refreshes it on every
start. Cache paths are also relative and are stored below Anvil's global cache directory. Paper and
Velocity may contribute safe default caches such as libraries/; a scenario can add a custom cache,
replace a default by path, or disable it with CachePolicy.DISABLED. Cleanup declarations never imply
cache or asset behavior, and overlapping cache/cleanup paths are rejected.
SimulatedPlayer is not one enormous interface Anvil must keep feeding forever. A capability is a
global Anvil API concept, while a capability provider is only the runtime mechanism that supplies
its implementation. Anvil ships a useful starting set:
| Built-in capability | What your test can do |
|---|---|
Session |
Connect, disconnect, rejoin, inspect connection state, and verify kick reasons |
Messages |
Send chat and commands, capture messages, wait for output, and model the chat |
Movement |
Send position, rotation, and on-ground state |
Inventory |
Inspect containers, select held slots, and click inventory slots |
Interaction |
Use held items and interact with blocks or entities |
Commands |
Read the command tree and check commands as the client does |
Dialogs |
Observe dialogs, fill their inputs, and press their buttons (1.21.6+) |
Cookies |
Hold cookies, answer the server's requests, and observe them (1.20.5+) |
Screen |
Wait for titles, action bars, and boss bars the server shows |
Sounds |
Wait for the sounds the server plays, named or by registry ID |
Entities |
Follow spawned entities: positions, passengers, names, text, items and flags |
Servers and proxies have capabilities of their own: Dispatch runs commands as the console, and
Players reports who is on that process, the username and UUID it sees, and on a proxy the backend it
connected each player to:
Players lobby = anvil.processes().server("lobby").capability(Players.class);
PlayerWatch arrivals = lobby.watch();
alice.capability(Session.class).connect();
arrivals.joined(alice);These are what Anvil includes, not everything Alice will ever be allowed to learn. An external library can ship another capability wiring bundle as a normal dependency. Anvil discovers its provider, attaches the typed capability to compatible players, and exposes it through the same global API:
Combat combat = alice.capability(Combat.class);No core fork. No giant player interface. No waiting for the next Anvil release because somebody invented a packet you care about.
The built-in observations wait for a sensible timeout and fail with recent history when the
expected event never arrives. Add a Duration when your server needs special treatment.
Providers can retrieve only the capability dependencies declared in their CapabilityDescriptor.
Registered cleanup runs in reverse order, including when capability construction fails. Cleanup
failures are reported after the remaining resources are released.
The Players capability reads its process's platform agent and has no Session dependency. Agent
operations can be used with another protocol library or without any packet-capability adapter.
Players can be created dynamically, disconnected, rejoined, or destroyed independently. Installed capabilities decide what else they can do.
Publish the public capability separately from its implementation so tests can use your domain operations. A provider can compose declared capabilities, send typed requests to a native worker, or call a server/proxy agent. Native worker extensions bind to the actual client SDK and own their packet handling and listener cleanup.
Use your own Maven group and packages, register the provider's service descriptor, and install the
selected implementation through anvilImplementation. The documentation
walks through contracts, host and worker adapters, agent handlers, and packaged consumer tests.
A scenario can launch a proxy and multiple backends as one supervised environment. Players connect through the declared entrypoint while the test observes where they land, which identity each server sees, and whether transfers actually happened.
Velocity with Paper. Velocity with Spigot. BungeeCord with Paper. BungeeCord with Spigot. Modern forwarding, legacy forwarding, multiple backends, real processes. All the configuration normally spread across several directories and one developer's increasingly fragile patience.
Providers declare supported identity-forwarding modes. Anvil negotiates one mode for each connected proxy/server group before launch, including overlapping proxy routes, and supplies shared per-run forwarding settings to each provider. Incompatible groups fail preflight; provider implementations do not select forwarding by inspecting another platform's name.
Platform configuration writers parse existing YAML or TOML, preserve unrelated values, and replace runtime-owned settings structurally. Repeated configuration does not append duplicate YAML sections, and direct-server runs disable forwarding retained in persistent workspaces. Serialization rewrites formatting and comments. Malformed configuration and duplicate YAML keys fail with diagnostics.
Velocity .setting(key, value) values use TOML literal syntax: "128" represents an integer,
"true" a boolean, "[1, 2]" an array, and "'hello'" a string. Dotted keys update nested tables;
runtime-owned roots such as servers cannot be overridden. Quote string values that previously
relied on implicit string detection. BungeeCord settings use YAML values; server properties remain
plain strings.
Sometimes you genuinely want to join the server. Fine. Run the same scenario in the foreground:
./gradlew anvilScenarios
./gradlew anvilRun --scenario=manual-paperAnvil prints the vanilla-client join address and gives you status, logs, restarts, scenario switching, and routed console commands. Manual testing remains available; it simply stops being the entire quality strategy.
Prefer buttons? The Anvil plugin for IntelliJ IDEA lists the same scenarios in a tool window, starts whole environments or single servers, and keeps each process's console, commands, and players one click away. Install it from JetBrains Marketplace.
| Role | Platforms |
|---|---|
| Direct or backend server | Paper, Spigot |
| Direct server | NeoForge |
| Proxy | Velocity, BungeeCord |
Players speak Minecraft 1.16.5, 1.17.1, 1.18.2, 1.19.4, 1.20.6, 1.21.1, 1.21.11, 26.1.2, 26.2, and 26.3
through MCProtocolLib, plus 1.16.4, 1.20.5, 1.21, 26.1, and 26.1.1, which share those protocols. The
live matrix runs those versions on Paper and 1.21.1 and newer on NeoForge, and verifies 1.21.11
and 26.1.2 on Spigot and across every supported proxy/backend combination; the other supported
versions are compatible but not live-tested. Release data you supply can add newer versions. The
version support page
lists every release, platform range, and support level. Distributions may come from pinned remote
builds, local JARs, Maven artifacts, or Gradle project artifacts.
Anvil uses native Java protocol clients. Local JVM execution is the standard workflow; an embedded Docker setup requires explicit image mappings and local-daemon access. The documentation explains that setup and its limits. Kubernetes, SSH/hosted orchestration, Fabric, Sponge, and Bedrock are outside the current scope.
Good instinct. The repository contains an executable authentication use case:
- The plugin, which accepts patience where credentials would usually go
- Its ordinary unit test
- Its reusable scenario definitions
- The release journey Alice performs instead of you
Anvil is available under the Apache License 2.0. Break your plugin, not the license.