— Tweet anónimo de un programador de Bevy, 2024.
El cap. 14B prometió este capítulo. Lo prometí con un spoiler: "App::new() sin DefaultPlugins es tu mejor amigo". Vamos a ver por qué.
El estigma: "los juegos son visuales, no se pueden testear automáticamente". Esto es falso para el 90% del código. Sí, no podés testear "este sprite se ve bonito en pantalla" sin un test visual (que también se puede, mira insta snapshot). Pero sí podés testear:
Idle a Walking cuando se presiona la tecla.A* encuentra el camino más corto en un mapa de 10×10.World::save() y World::load() preservan el estado.RonFile sin panic.Y testear te ahorra horas. La regla: testear lo que tiene reglas claras; no testear lo que es "se ve bien".
Tres niveles de test en Bevy:
1. Test unitario puro (Rust stdlib, sin Bevy).
2. Test de sistema con `App` mínima + `MinimalPlugins`.
3. Test de snapshot (compara el `World` entero contra un archivo de referencia).
Este capítulo cubre los tres. Y un bonus: cómo correr tests de un juego Bevy en CI sin GPU (importante para GitHub Actions).
Antes de meter a Bevy, extraé la lógica que no depende de él. Una función A* pura, una función de daño, una validación de input: podés testearla con cargo test sin nada de Bevy.
//! cap-14G — sección 14G.2: lógica pura testeable sin Bevy.
//! Archivo: src/pathfinding.rs.
pub struct Grid {
pub width: usize,
pub height: usize,
pub walls: Vec<bool>, // row-major.
}
impl Grid {
pub fn is_walkable(&self, x: usize, y: usize) -> bool {
x < self.width && y < self.height && !self.walls[y * self.width + x]
}
}
pub fn heuristic(a: (usize, usize), b: (usize, usize)) -> u32 {
((a.0 as i32 - b.0 as i32).abs() + (a.1 as i32 - b.1 as i32).abs()) as u32
}
// Tests en el mismo archivo o en tests/pathfinding.rs.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_walkable_empty_grid() {
let g = Grid { width: 3, height: 3, walls: vec![false; 9] };
assert!(g.is_walkable(0, 0));
assert!(g.is_walkable(2, 2));
}
#[test]
fn test_walkable_out_of_bounds() {
let g = Grid { width: 3, height: 3, walls: vec![false; 9] };
assert!(!g.is_walkable(5, 5));
}
#[test]
fn test_walkable_wall() {
let g = Grid { width: 3, height: 3, walls: vec![true; 9] };
assert!(!g.is_walkable(0, 0));
}
#[test]
fn test_heuristic_manhattan() {
assert_eq!(heuristic((0, 0), (3, 4)), 7);
}
}
cargo test corre esto. Sin GPU, sin ventana, sin async. Rápido y portable. La regla: si podés testearlo sin Bevy, testéalo sin Bevy. Más rápido, más simple.
App mínima y MinimalPluginsPara testear un sistema Bevy (un sistema que toca Query, Res, eventos), necesitás una App. Pero no necesitás DefaultPlugins (que abre una ventana, carga GPU, etc.). Usá MinimalPlugins y ScheduleRunnerPlugin.
//! cap-14G — sección 14G.3: test de un sistema Bevy sin ventana.
use bevy::prelude::*;
use bevy::app::ScheduleRunnerPlugin;
#[derive(Component)]
struct Health { current: f32, max: f32 }
fn damage_system(
mut query: Query<&mut Health>,
) {
for mut h in query.iter_mut() {
h.current = (h.current - 5.0).max(0.0);
}
}
#[test]
fn test_damage_system() {
let mut app = App::new();
// MinimalPlugins: sin window, sin render, sin audio.
// ScheduleRunnerPlugin: corre el schedule a un ritmo configurable.
app.add_plugins(MinimalPlugins);
app.add_plugins(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(0.0)));
app.add_systems(Update, damage_system);
// Crear una entidad de prueba.
let entity = app.world_mut().spawn(Health { current: 100.0, max: 100.0 }).id();
// Correr un tick del schedule Update.
app.update();
// Verificar.
let h = app.world().entity(entity).get::<Health>().unwrap();
assert_eq!(h.current, 95.0);
// Correr otro tick.
app.update();
let h = app.world().entity(entity).get::<Health>().unwrap();
assert_eq!(h.current, 90.0);
}
Tres detalles:
MinimalPlugins: el set mínimo de plugins para que un App exista. Sin él, App::new() puede no inicializar World correctamente.ScheduleRunnerPlugin::run_loop(0.0): corre el schedule a "tiempo real" pero con delta 0. Útil cuando no querés que el Time afecte tu lógica. Si querés simular el paso del tiempo, usá Time::<()>::set_delta(Duration::from_millis(16)).app.update(): avanza un frame. No corre en background, no es async. Tu test es síncrono.Time y AssetServer con Resources customA veces tu sistema usa Res<Time> o Res<AssetServer>. Para testearlo, tenés dos opciones:
Opción A: insertar el resource manualmente.
//! cap-14G — sección 14G.4: insertar Time custom.
#[test]
fn test_movement_uses_time() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.insert_resource(Time::<()>::from_duration(Duration::from_millis(16)));
// ... tu sistema ...
}
Opción B: hacer que tu sistema no dependa de Time directamente.
//! cap-14G — sección 14G.4: pasar el delta como parámetro.
fn movement_system(
mut query: Query<&mut Transform>,
time: Res<Time>,
) {
let delta = time.delta_secs();
for mut t in query.iter_mut() {
t.translation.x += 100.0 * delta;
}
}
Si el sistema lee Time directamente, en el test podés insertar uno falso. La opción B (pasar el delta) es más limpia pero requiere refactor. Para empezar, A.
Para AssetServer, en tests no necesitás cargar assets reales. Podés insertar un Handle<T> directo o usar mocks:
//! cap-14G — sección 14G.4: insertar un handle falso.
use bevy::prelude::*;
#[derive(Resource)]
struct MyHandle(Handle<Image>);
#[test]
fn test_uses_image() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
let handle = images.add(Image::default());
app.insert_resource(MyHandle(handle));
// ...
}
Para assets más complejos (escenas, configs RON), usá Assets<T>::add(...) directamente.
Los eventos son la pieza más fácil de testear. Enviás un evento, corrés el sistema que lo consume, verificás el side-effect.
//! cap-14G — sección 14G.5: test de eventos.
use bevy::prelude::*;
#[derive(Event)]
struct DamageEvent { amount: f32, target: Entity }
#[derive(Component)]
struct Health { current: f32 }
fn damage_listener(
mut events: EventReader<DamageEvent>,
mut query: Query<&mut Health>,
) {
for ev in events.read() {
if let Ok(mut h) = query.get_mut(ev.target) {
h.current -= ev.amount;
}
}
}
#[test]
fn test_damage_event_subtracts_health() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_event::<DamageEvent>();
app.add_systems(Update, damage_listener);
let entity = app.world_mut().spawn(Health { current: 100.0 }).id();
// Enviar el evento.
app.world_mut().send_event(DamageEvent { amount: 30.0, target: entity });
app.update();
let h = app.world().entity(entity).get::<Health>().unwrap();
assert_eq!(h.current, 70.0);
}
send_event y EventReader/EventWriter están separados para que Bevy pueda paralelizar. En tests, los enviás manualmente y corrés un update. Limpio.
Las FSM y los States (cap. 20, cap. 14D) son perfectos para tests: tienen transiciones claras y se pueden verificar.
//! cap-14G — sección 14G.6: test de transición de estado.
use bevy::prelude::*;
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
enum GameState { #[default] Idle, Running, Paused }
#[derive(Resource)]
struct Transition { next: GameState }
fn request_transition(
mut next_state: ResMut<NextState<GameState>>,
transition: Res<Transition>,
) {
next_state.set(transition.next);
}
#[test]
fn test_state_transition() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_state::<GameState>();
app.add_systems(Update, request_transition);
// Estado inicial: Idle.
assert_eq!(
app.world().resource::<State<GameState>>().get(),
&GameState::Idle,
);
// Insertar el recurso y correr.
app.insert_resource(Transition { next: GameState::Running });
app.update();
// Después del update, el estado cambió.
assert_eq!(
app.world().resource::<State<GameState>>().get(),
&GameState::Running,
);
}
El truco está en State<GameState> (el estado actual) y NextState<GameState> (el "request" para el próximo tick). Bevy hace el cambio real entre ticks. En un test, después de app.update(), el estado refleja la transición.
World enteroCuando tenés un sistema complejo (spawning, modificación de muchas entidades), testear cada campo a mano es tedioso. La opción profesional: snapshot test. Capturás el World entero (o un subset) y lo comparáis contra un archivo de referencia.
El crate insta (https://insta.rs) es el estándar de Rust para esto. Se integra con Bevy porque todo en Bevy se puede serializar vía Reflect.
//! cap-14G — sección 14G.7: snapshot test del World.
use bevy::prelude::*;
#[derive(Component, Reflect, Default, Debug)]
#[reflect(Component)]
struct PlayerName(String);
fn spawn_player(mut commands: Commands) {
commands.spawn((
PlayerName("Alice".to_string()),
Transform::from_xyz(1.0, 2.0, 3.0),
));
}
#[test]
fn test_world_snapshot() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_systems(Startup, spawn_player);
app.update();
// Serializar el mundo.
let world = app.world();
let snapshot = format!("{:#?}", world); // Debug format, no es ideal.
// Para serialización real, usá `bevy_scene` o `bevy::reflect::serde`.
insta::assert_yaml_snapshot!(snapshot);
}
insta automáticamente crea un archivo .snap con la salida esperada. La primera vez que corréis el test, falla y genera un .snap.new. Lo revisás, lo aprobás, y a partir de ahí compara. Si el output cambia, falla y te dice qué cambió.
Caveat: serializar un World entero es caro y a veces innecesario. Para tests más finos, extraé los datos a un struct simple y serializá ese:
//! cap-14G — sección 14G.7: snapshot de un subset.
#[derive(Serialize)]
struct TestSummary {
player_name: String,
player_x: f32,
player_y: f32,
}
#[test]
fn test_subset_snapshot() {
let mut app = App::new();
// ... setup ...
let player = app.world().query::<(&PlayerName, &Transform)>().single(&app.world());
let summary = TestSummary {
player_name: player.0 .0.clone(),
player_x: player.1.translation.x,
player_y: player.1.translation.y,
};
insta::assert_yaml_snapshot!(summary);
}
Más limpio, más legible, menos acoplado a la estructura interna del World.
El cap. 17 cubre física con Avian/Rapier. Para testear la lógica que vive sobre la física (un sistema de "el player está sobre el suelo y por eso puede saltar"), no necesitás la física real. Testeás el componente que la física tocaría.
//! cap-14G — sección 14G.8: test sin física real.
#[derive(Component)]
struct Grounded(bool);
fn jump_system(
input: Res<ButtonInput<KeyCode>>,
grounded: Query<&Grounded>,
mut velocity: Query<&mut Velocity>,
) {
if input.just_pressed(KeyCode::Space) {
for g in grounded.iter() {
if g.0 { /* saltar */ }
}
}
}
#[test]
fn test_jump_only_when_grounded() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_systems(Update, jump_system);
app.insert_resource(ButtonInput::<KeyCode>::default());
// Spawn de un player grounded.
let grounded = app.world_mut().spawn((Grounded(true), Velocity::zero())).id();
// Spawn de uno NO grounded.
let airborne = app.world_mut().spawn((Grounded(false), Velocity::zero())).id();
// Presionar espacio.
app.world_mut().resource_mut::<ButtonInput<KeyCode>>().press(KeyCode::Space);
app.update();
// El grounded ahora tiene velocidad hacia arriba.
assert!(app.world().entity(grounded).get::<Velocity>().unwrap().y > 0.0);
// El airborne sigue en cero.
assert_eq!(app.world().entity(airborne).get::<Velocity>().unwrap().y, 0.0);
}
El test no sabe nada de Avian ni de Rapier. Testeás tu lógica de gameplay, no la física. Esto es lo correcto: tu lógica de "puede saltar" debe funcionar igual con cualquier motor de física.
Los archivos RON/JSON/TOML de configuración (cap. 14B, cap. 27) son otro gran candidato. El test verifica que el archivo carga sin error y que tiene la forma esperada:
//! cap-14G — sección 14G.9: test de un config RON.
use serde::Deserialize;
#[derive(Deserialize, Debug, PartialEq)]
struct GameConfig {
pub title: String,
pub max_players: u32,
pub tickrate: u32,
}
#[test]
fn test_config_loads() {
let raw = std::fs::read_to_string("assets/config.ron").unwrap();
let config: GameConfig = ron::from_str(&raw).unwrap();
assert!(config.max_players >= 1);
assert!(config.tickrate >= 30);
}
Si el archivo está malformado, el test falla. Si agregás un campo obligatorio, los tests viejos rompen hasta que actualices el archivo. Esto es bueno: la configuración se vuelve código con tests.
Lo que no se puede testear bien con código: "¿se ve bien?". Para esto existen tests visuales con comparación de píxeles. Bevy tiene una integración experimental; el crate bevy_visual_testing o insta con snapshots de Image funcionan.
//! cap-14G — sección 14G.10: test visual con Image snapshot.
#[test]
fn test_main_menu_looks_right() {
let mut app = App::new();
app.add_plugins(DefaultPlugins); // sí, con render.
// ... setup del menú ...
// Capturar el framebuffer.
// ... comparar contra una imagen de referencia.
}
En la práctica, el 90% de los juegos no hacen esto. Lo que sí hacen: golden tests donde un humano aprueba una imagen y se commitea, y luego el CI compara. Es frágil (cambiar un asset rompe el test), pero útil para regresiones graves.
Recomendación: no empieces con tests visuales. Empezá con tests unitarios y de sistema. Cuando la base esté estable, sumá un par de golden tests para los assets más críticos.
GitHub Actions no tiene GPU por defecto. Si tu código intenta inicializar wgpu en un test, falla. La solución:
# .github/workflows/test.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --no-default-features --features "bevy/2d"
Clave: --no-default-features apaga lo que no necesitás. Para tests sin render, ni siquiera necesitás bevy/2d; con bevy_ecs y los plugins mínimos alcanza.
Si necesitás tests con render, las opciones son:
bevy/test feature si existe que mockea GPU. A fecha de 0.18/0.19, esto es parcial.La opción pragmática: tests sin render en CI, tests con render antes de hacer push. El CI atrapa los bugs de lógica, los visuales los cazás vos.
Test unitario: test de una función pura, sin Bevy ni estado externo. El más rápido.
Test de sistema: test de un sistema Bevy, usando `App` + `MinimalPlugins`.
Test de integración: test que combina varios sistemas, ejercita un flujo completo.
Snapshot test: test que compara un output serializado contra un archivo de referencia.
MinimalPlugins: set mínimo de plugins de Bevy. Sin ventana, sin GPU.
ScheduleRunnerPlugin: plugin que corre el schedule a un ritmo configurable (incluso delta 0).
Time (resource): resource de Bevy que lleva el delta y elapsed time. Insertable manualmente.
AssetServer (resource): resource para cargar assets. Mockeable con `Assets<T>::add`.
insta: crate de Rust para snapshot tests. Estándar de la comunidad.
bevy_scene: módulo de Bevy para serializar/deserializar el World entero.
golden test: test visual donde un humano aprueba una imagen de referencia.
CI (Continuous Integration): pipeline automático (GitHub Actions, GitLab CI, etc.).
GitHub Actions: el CI más común en proyectos open source.
Self-hosted runner: máquina propia que corre los tests del CI (puede tener GPU).
Atari 2600 (1977) — los programadores testeban los juegos en hardware custom con LEDs, porque no había forma de ver la pantalla. Cuando se entregaba un cartucho, a veces la primera vez que el cliente lo veía era en la TV. Los tests visuales eran imposibles; los bugs eran inevitables.
Hacker News / "How we test our indie game" (varios, 2018-2024) — la conversación de cómo testear juegos en la industria indie es eterna. La respuesta común: "el 60% del código es testeable automáticamente; el 40% se testea jugando". Bevy permite subir ese 60% al 80% con `MinimalPlugins`.
insta (Armin Ronacher, 2019) — el crate que popularizó los snapshot tests en Rust. Hoy es estándar.
bevy_ecs (0.14) — el momento en que la comunidad descubrió que `MinimalPlugins` permitía tests de Bevy sin ventana. Antes de eso, testear Bevy era casi imposible.
Bevy + insta (2023+) — la combinación de `MinimalPlugins` + `insta` es el patrón dominante para tests serios de Bevy. No es magia, es metodología.
❌ Usar `DefaultPlugins` en un test y quejarse de que "el CI no tiene GPU".
✅ Usar `MinimalPlugins` + `ScheduleRunnerPlugin`.
💡 Por qué: `DefaultPlugins` abre una ventana, inicializa `wgpu`, etc. En CI, todo eso falla. `MinimalPlugins` te da un `App` funcional sin nada de eso.
❌ Testear que un sprite "se ve correcto" con aserciones de color de píxel.
✅ Testear la lógica que produce ese sprite; dejar el visual a la revisión humana.
💡 Por qué: el color de un píxel cambia con cada versión de wgpu y de los drivers. Tests de píxeles son frágiles. Tests de lógica son robustos.
❌ Asumir que el `Time` real del test funciona como en un juego.
✅ Insertar un `Time` manual con `Time::<()>::from_duration(...)`.
💡 Por qué: el `Time` real en un test avanza errático (depende de qué tan rápido corra el test). Tu lógica debe depender de un delta explícito, no del wall-clock.
❌ No correr tests en CI "porque ya los corro yo en local".
✅ Correr tests en CI en cada push.
💡 Por qué: la disciplina de "lo corro yo" se rompe cuando hay presión. El CI es la red de seguridad; sin él, los tests no son tests, son buenas intenciones.
Problema: tu juego tiene bugs. Los cazás jugando, no automáticamente. Cada vez que arreglás uno, rompés otro. El feedback loop es lento.
Solución: identificá el "corazón" de tu juego (la lógica que define el gameplay) y testéalo en serio. Dejá los "brazos" (render, input, audio) para la revisión humana. La regla:
Corazón (testear):
- Sistemas de gameplay (daño, FSM, scoring, IA).
- Lógica de validación de input.
- Algoritmos (pathfinding, generación procedural).
- Configuración y assets (carga, validación).
- Save/load y migraciones.
Brazos (revisión humana):
- Visuales (cómo se ve).
- Audio (cómo suena).
- Game feel (cómo se siente al jugarlo).
- Balance (¿es divertido?).
Cuándo sí:
Cuándo no:
App, de snapshot.MinimalPlugins + ScheduleRunnerPlugin para tests sin GPU ni ventana.Time, AssetServer y eventos.insta.--no-default-features.Capítulo 14C (revisitado): catálogos de arquitecturas ECS por género 2D. Ya cubrimos RPG, plataformero y bullet hell de pasada. Ahora vamos a profundizar: cómo cada género "resuelve" los problemas comunes con ECS, y qué crates/plugins te resuelven la vida en cada caso. Spoiler: el mejor consejo sigue siendo "copiá la arquitectura de un juego existente del género, no inventes".