Тест плагина без клиента
На этой странице показано, как протестировать плагин без клиента Minecraft. Тест запускает сервер в своем процессе, подключает фиктивного игрока, прогоняет тики и проверяет пакеты, которые отправил сервер.
Если вы пишете плагины для Paper, вам может быть знаком MockBukkit, который дает фиктивный сервер и фиктивных игроков. В mcrs есть похожее окружение, но оно живет внутри интеграционных тестов крейта сервера. Отдельным крейтом оно не опубликовано, поэтому пользоваться им можно только из клона репозитория, как описано в туториале Первый плагин. Примеры взяты из файлов репозитория, вне репозиторных тестов мы их не запускали.
Тесты - модули одного тестового бинарника
Заголовок раздела «Тесты - модули одного тестового бинарника»Все интеграционные тесты крейта сервера - модули одного тестового бинарника, tests/it/main.rs, и новый файл становится там модулем. Чтобы запустить один модуль, передайте фильтр. Вот команда, которая запускает тесты чата:1
cargo test -p mcrs_minecraft_server --test it chat::Окружение лежит в tests/it/host_app/mod.rs. Его функция 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
Тест подключает фиктивного игрока и прогоняет тики
Заголовок раздела «Тест подключает фиктивного игрока и прогоняет тики»Вот первая половина теста чата. Он добавляет плагин в измерения, подключает игрока, ставит в очередь строку чата и прогоняет два тика.
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
Тест работает через PlayServer, обертку над приложением. Он использует такие вызовы:
| Вызов | Что делает |
|---|---|
PlayServer::start(app, ops, default_level) |
Завершает сборку приложения и доводит его до состояния игры с одним верхним миром |
join(uuid, name) |
Размещает фиктивного игрока и выполняет три обновления |
queue(anchor, &packet) |
Отправляет пакет от клиента к серверу так, как будто он пришел из сокета |
ticks_all(n) |
Выполняет n обновлений и возвращает все пакеты, которые отправил сервер |
Тики считаются точно, и настенных часов ничто не ждет. Один тик хоста - это один update() плюс передача данных между хостом и измерениями, и ticks_all(1) выполняет и то и другое.
Вторая половина теста сравнивает услышанное с ожидаемым текстом:
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
Тест может заглянуть и внутрь мира. server.world() дает мир первого измерения, а server.world_in(dim) - мир того измерения, которое вы укажете. Тест наблюдателей чата добавляет туда наблюдателя и потом читает из мира ресурс.
#[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
Один тест ловит неупорядоченную пару систем
Заголовок раздела «Один тест ловит неупорядоченную пару систем»Если две системы пишут одни и те же данные и порядок между ними не задан, они могут выполниться в любом порядке, и одни и те же входные данные дадут разные тики. В Bevy такую пару называют неоднозначностью. Ее находит планировщик, когда собирает расписание, а компилятор это правило не проверяет.
Один тест из репозитория собирает главное приложение и мир измерения с полным плагином сервера и превращает любую неоднозначность в расписаниях симуляции в ошибку.2 Когда неоднозначность появляется, тест падает и называет обе системы.
#[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
Тест собирает MinecraftServerPlugins::embedded(). Если вашего плагина в этом приложении нет, добавьте его в тест, иначе тест не увидит ваши системы. В репозитории неоднозначность устраняют, упорядочивая системы или разделяя данные, и общим игнорированием не пользуются.
Чтобы измерение тикало, нужен игрок
Заголовок раздела «Чтобы измерение тикало, нужен игрок»Дальше: Перехват чата, где разобран тест с настоящим событием. Команды сборки и тестов репозитория есть в разделе Как внести вклад.
Footnotes
Заголовок раздела «Footnotes»Источники и проверка
Проверено: 26.4-snapshot-7, 51d45c26a, 2026-11-09
Переведено с версии 51d45c26a