Integration Testing
Integration tests run game code in Godot with real frame progression. Use them when a system depends on the scene tree, Godot nodes, or Godot's frame loop. Pure Rust logic is still a good fit for unit tests.
Project layout
Tests live in the game crate and run in its Godot project:
my-game/
├── godot/
│ ├── addons/godot-bevy/
│ ├── project.godot
│ └── .godot/extension_list.cfg
└── rust/
├── Cargo.toml
├── run_godot.rs
└── src/
├── lib.rs
└── itests.rs
Install or symlink the addon into godot/addons/godot-bevy, and configure its BevyAppSingleton autoload. The generated GDExtension must be imported once so Godot records it in .godot/extension_list.cfg.
Setup
Add the optional test dependency and enable the frame signal used by the harness:
godot-bevy-test = { version = "0.12", optional = true }
itest = ["dep:godot-bevy-test", "godot-bevy-test/test-frame-signal"]
Register the runner in the game library. Keep #[bevy_app] fn build_app as the only GDExtension entry point.
#![allow(unused)] fn main() { #[cfg(feature = "itest")] mod itests; #[cfg(feature = "itest")] godot_bevy_test::declare_test_runner!(); }
Set up run_godot.rs as shown in Cargo Run Godot. Its itest path starts the runner scene and sets GODOT_BEVY_ITEST=1 for the child Godot process.
#[bevy_app] leaves build_app as a normal function, so TestApp::new(&ctx, build_app) works. Most tests should add only the plugins they cover.
Writing tests
Use #[itest] on an async function with an owned TestContext. TestApp initializes the autoload, waits for the initial scene-tree population, and gives the test explicit frame control.
#![allow(unused)] fn main() { #[itest] async fn gem_collected(ctx: TestContext) { let mut app = TestApp::new(&ctx, |app| { app.add_plugins(GemPlugin); }) .await; let (_, gem_entity) = app.add_node::<Area2D>("gem").await; let (_, player_entity) = app.add_node::<Area2D>("player").await; app.with_world_mut(|world| { world.entity_mut(gem_entity).insert(Gem); world.entity_mut(player_entity).insert(Player); world.trigger(godot_bevy::prelude::CollisionStarted { entity1: gem_entity, entity2: player_entity, }); }); app.update().await; app.with_world(|world| { assert_eq!(world.resource::<GemsCollected>().0, 1); assert!(!world.entities().contains(gem_entity)); }); app.cleanup().await; } }
Use with_world for read-only access. Use with_world_mut for mutations and queries, since creating a Bevy query needs mutable world access.
#![allow(unused)] fn main() { #[itest] async fn game_components_keep_their_defaults(ctx: TestContext) { let mut app = TestApp::new(&ctx, |app| { app.add_plugins((GodotCollisionsPlugin, DoorPlugin)); }) .await; app.with_world_mut(|world| { world.spawn((Gem, Door::default(), Player)); let mut doors = world.query::<&Door>(); assert_eq!(doors.iter(world).count(), 1); }); app.update().await; app.cleanup().await; } }
#[itest(async)] fn test(ctx: &TestContext) -> godot::task::TaskHandle remains available when a test returns an explicitly spawned task. Async functions use an owned context, because a reference cannot outlive the spawned task.
Running tests
Run the game crate's runner:
cargo run --features itest
After adding or changing the extension, import the project once. Then a direct invocation is:
godot --headless --path godot --import
GODOT_BEVY_ITEST=1 godot --headless --fixed-fps 60 --path godot --scene res://addons/godot-bevy/test/TestRunner.tscn --quit-after 10000
Godot can crash on exit after a headless import once a GDExtension is loaded (godot#111645). The .godot folder is written before that, so the crash is harmless and the runner skips the import on later runs.
ITEST_FILTER selects comma-separated, case-sensitive name substrings. ITEST_REPEAT repeats selected tests. ITEST_JSON_PATH writes a report. #[itest(skip)] reports a skipped test, while #[itest(focus)] selects focused tests; set ITEST_DENY_FOCUS=1 in CI to reject focus mode. The full configuration table is in the godot-bevy-test README.
Troubleshooting
BevyApp defined multiple times
The game and test dependencies resolved different copies of godot-bevy. Run cargo tree -d, then align their sources and versions. A second test crate that links the game as an rlib is unsupported because it creates duplicate GDExtension entry symbols.
IntegrationTests class not found
Godot did not load the extension. Run --import once and check that .godot/extension_list.cfg lists the generated GDExtension.
Must be run in headless mode
The test runner only runs headlessly. Pass --headless when starting Godot.
Game code runs before the first test
Without GODOT_BEVY_ITEST, the game autoload boots for a frame or two before the runner. Its startup logs and any nodes it adds under root appear before Run godot-bevy integration tests and leak into every test's scene scan. Set GODOT_BEVY_ITEST=1 in the process that launches Godot.
Benchmarks
#[bench] runs a function repeatedly and requires a return value so its work is not optimized away:
#![allow(unused)] fn main() { #[bench] fn name() -> i32 { 42 } #[bench(repeat = 50)] fn repeated_name() -> i32 { 42 } }
Build the Rust crate with --release, then launch the benchmark runner:
godot --headless --path godot --scene res://addons/godot-bevy/test/BenchRunner.tscn --quit-after 30000