Skip to content

Test a plugin without a client

Changing. Follow: #266, #323

This page shows how to test a plugin without a Minecraft client. A test runs the server in the test process, joins a fake player, steps the ticks and checks the packets that the server sent.

If you write Paper plugins, you may know MockBukkit, which gives you a fake server and fake players. mcrs has a similar harness, but it lives inside the integration tests of the server crate. It is not a published crate, so you use it from a clone of the repository, as Your first plugin describes. The examples come from repository files, and we did not run them outside the repository tests.

All integration tests of the server crate are modules of one test binary, tests/it/main.rs, and a new file becomes a module there. To run one module, pass a filter. This is the command that runs the chat tests:1

Terminal window
cargo test -p mcrs_minecraft_server --test it chat::

The harness is in tests/it/host_app/mod.rs. Its function make_host_app() builds a host app with the message sets and registries that a dimension tick needs.

make_host_app
pub fn make_host_app() -> App {
let mut app = App::new();
// The worldgen startup uses AssetServer::load, which spawns
// onto IoTaskPool; TaskPoolPlugin must run first or sub-app Startup panics.
app.add_plugins(TaskPoolPlugin {
task_pool_options: bevy_app::TaskPoolOptions::with_num_threads(2),
});
add_assets(&mut app);
app.add_plugins(TimePlugin);
app.insert_resource(Time::<Fixed>::from_hz(20.0));
app.add_plugins(StatesPlugin);
app.init_state::<AppState>();
app.add_message::<OutboundPlayerPacket>();
app.add_message::<InboundPlayerPacket>();
app.add_message::<OutboundPlayerAttached>();
app.add_message::<InboundPlayerDespawn>();
app.add_plugins(DimCellsPlugin);
app.add_plugins(DimInputsPlugin);
app.add_message::<JoinRequested>();
app.add_message::<JoinPlaced>();
app.init_resource::<DimSpawnQueue>();
app.init_resource::<DimDespawnQueue>();
crate::support::insert_registries(&mut app);
app
}

crates/mcrs_minecraft_server/tests/it/host_app/mod.rs:81-108

A test joins a fake player and steps ticks

Section titled “A test joins a fake player and steps ticks”

Here is the first half of the chat test. It adds a plugin to the dimensions, joins a player, queues a chat line and runs two ticks.

Test: join, send a chat line, collect the packets
for (case, mode, message, broadcast, reply) in cases {
let mut app = host_app::make_host_app();
app.add_dimension_plugin(ChatPlugin);
let mut server = PlayServer::start(app, &[], 0);
let (sender, _) = server.join(Uuid::from_u128(1), "Sender");
server.queue(sender, &client_information(mode));
server.queue(sender, &line(message));
let sent = server.ticks_all(2);
let heard: Vec<Text> = sent
.iter()
.filter(|packet| matches!(packet.target, PacketTarget::AllPlayers))
.filter_map(|packet| match &packet.data {
PacketPayload::SystemChat(chat) => {
Some(chat.content.to_text().unwrap().map(&ItemHoverMap).unwrap())
}
_ => None,
})
.collect();

crates/mcrs_minecraft_server/tests/it/chat.rs:79-97

The test works through PlayServer, which wraps the app. These are the calls it uses:

Call What it does
PlayServer::start(app, ops, default_level) Finishes the app and brings it to the playing state with one overworld
join(uuid, name) Places a fake player and runs three updates
queue(anchor, &packet) Sends a serverbound packet as if it came from a socket
ticks_all(n) Runs n updates and returns every packet the server sent

Ticks are counted exactly, and nothing waits for the wall clock. One host tick is one update() plus the hand-over between the host and the dimensions, and ticks_all(1) runs both.

The second half of the test compares what was heard with the text it expected:

Test: check the packets
if broadcast {
let expected = Text::translate(
"chat.type.text",
vec![
"Sender".to_owned().into_text(),
message.to_owned().into_text(),
],
);
assert_eq!(heard, [expected], "{case}");
} else {
assert!(heard.is_empty(), "{case}: {heard:?}");
}

crates/mcrs_minecraft_server/tests/it/chat.rs:98-109

A test can also look inside a world. server.world() gives the world of the first dimension, and server.world_in(dim) gives the world of the dimension you name. The test of the chat observers adds an observer there and reads a resource back.

Test: an observer in the world, a resource read back
#[test]
fn an_observer_that_replaces_a_chat_line_changes_its_broadcast() {
#[derive(Resource)]
struct Speaker(Entity);
fn censor(mut event: On<PlayerChatEvent>, mut commands: Commands) {
commands.insert_resource(Speaker(event.player));
event.text = "***".to_owned().into_text();
}
let mut app = host_app::make_host_app();
app.add_dimension_plugin(ChatPlugin);
let mut server = PlayServer::start(app, &[], 0);
let (sender, _) = server.join(Uuid::from_u128(1), "Sender");
server.world().add_observer(censor);
server.queue(sender, &line("hello"));
let heard: Vec<Text> = server
.ticks_all(2)
.iter()
.filter(|packet| matches!(packet.target, PacketTarget::AllPlayers))
.filter_map(|packet| match &packet.data {
PacketPayload::SystemChat(chat) => {
Some(chat.content.to_text().unwrap().map(&ItemHoverMap).unwrap())
}
_ => None,
})
.collect();
let expected = Text::translate(
"chat.type.text",
vec![
"Sender".to_owned().into_text(),
"***".to_owned().into_text(),
],
);
assert_eq!(heard, [expected]);
let player = server.player(sender);
assert_eq!(server.world().resource::<Speaker>().0, player);
}

crates/mcrs_minecraft_server/tests/it/chat.rs:144-181

One test catches an unordered pair of systems

Section titled “One test catches an unordered pair of systems”

Two systems that write the same data with no order between them can run in either order, so the same inputs can give different ticks. Bevy calls such a pair an ambiguity. The scheduler finds it when it builds the schedule, and the compiler does not check this rule.

One repository test builds the main app and a dimension world with the full server plugin and turns every ambiguity in the simulation schedules into an error.2 When an ambiguity appears, the test fails and names both systems.

Test: no unordered conflicting pair
#[test]
fn no_simulation_schedule_has_an_unordered_conflicting_pair() {
let mut app = App::new();
let mut plugins = MinecraftServerPlugins::embedded();
plugins.core.asset_path = Some(ASSETS.to_owned());
plugins.network.announce_on_lan = false;
plugins.worlds.dimensions = Some(Dimensions::from([(
ResourceKeyBuf::from_location("mcrs:ambiguity".parse().expect("a valid identifier")),
DimensionEntry {
dimension_type: crate::support::dimension_type("minecraft:overworld"),
generator: ChunkGenerator::Debug,
},
)]));
app.add_plugins(plugins);
app.finish();
app.cleanup();
let mut reports = ambiguous_schedules("server", app.world_mut());
crate::support::update_until_playing(&mut app);
drain_dim_spawn_queue(&mut app);
let dimension = app
.world_mut()
.query_filtered::<bevy_ecs::entity::Entity, bevy_ecs::query::With<DimSubAppHandle>>()
.single(app.world())
.expect("one dimension is spawned");
let dimension_world = crate::host_app::world(&mut app, dimension);
reports.extend(ambiguous_schedules("dimension", dimension_world));
assert!(reports.is_empty(), "{}", reports.join("\n"));
}

crates/mcrs_minecraft_server/tests/it/schedule_ambiguity.rs:84-113

The test builds MinecraftServerPlugins::embedded(). If your plugin is not part of that app, add it to the test, because otherwise the test does not see your systems. The repository resolves an ambiguity by ordering the systems or by splitting the data, and it does not use a blanket ignore.

Next: Intercept chat, which goes through a test with a real event. Contributing has the build and test commands of the repository.

  1. Works#636 Raises a cancellable event for a chat line before its broadcast. ↩

  2. Works#323 Simulation schedules fail on an ambiguous system order. ↩

  3. WIP#266 Plans to run one dimension headless with a fixed seed, no player and an area kept ticking. ↩

Sources and verification

Verified: 26.4-snapshot-7, 51d45c26a, 2026-11-09

Tracking: #266, #323