Test a plugin without a client
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.
Tests are modules of one test binary
Section titled “Tests are modules of one test binary”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
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.
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.
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:
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]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]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.
A dimension needs a player to tick
Section titled “A dimension needs a player to tick”Next: Intercept chat, which goes through a test with a real event. Contributing has the build and test commands of the repository.
Footnotes
Section titled “Footnotes”Sources and verification
Verified: 26.4-snapshot-7, 51d45c26a, 2026-11-09