Testing
factorio-rs lets you write familiar Rust unit tests that exercise Factorio game
APIs. Those tests are transpiled to Lua and executed inside Factorio by
factorio-rs test (headless by default).
| Command | What it does |
|---|---|
cargo test / cargo check --tests |
Typechecks #[cfg(test)] code against API stubs |
factorio-rs test |
Builds the mod, launches Factorio, runs simulations in-game |
Plain cargo test cannot run real simulations - the stubs are compile-only.
Use factorio-rs test whenever a test touches game, surfaces, entities, or
other runtime APIs. Mark those tests with
#[ignore = "requires Factorio (run with factorio-rs test)"] so cargo test
(and CI) skip them; factorio-rs test still discovers and runs #[ignore]
tests.
Authoring
Section titled “Authoring”Put tests in a #[cfg(test)] module (inline or mod tests;) and mark functions
with #[test]. Tests can live next to control-stage code in control_mod!, in
a #[factorio_rs::control] module, or in a sibling file module gated on
#[cfg(test)].
Normal builds skip #[cfg(test)] modules. Only the test runner lowers them.
Smoke tests
Section titled “Smoke tests”Pure logic does not need the Factorio world. Useful while wiring the runner:
factorio_rs::control_mod! { #[factorio_rs::event(OnSingleplayerInit)] pub fn on_singleplayer_init() { println!("hi"); }
#[cfg(test)] mod tests { #[test] fn arithmetic_smoke() { assert_eq!(1 + 1, 2); }
#[test] fn truth_holds() { assert!(true, "should never fail"); } }}See examples/hello_world.
Reading the game world
Section titled “Reading the game world”Inside a test you can call the same Factorio APIs as control-stage code. The
suite runs on a blank factorio-rs-test scenario after map init (and again on
the first tick as a fallback), so surface 1 (Nauvis) is available:
#[cfg(test)]mod tests { use factorio_rs::prelude::*;
#[test] #[ignore = "requires Factorio (run with factorio-rs test)"] fn nauvis_exists() { let surface = game.get_surface(1.into()); assert!(surface.is_some()); }
#[test] #[ignore = "requires Factorio (run with factorio-rs test)"] fn player_force_exists() { let force = game.forces.get("player".into()); assert!(force.is_some()); }}Placing entities and calling mod logic
Section titled “Placing entities and calling mod logic”For behavior that depends on entities, create them on the surface and call your mod functions directly - the same Rust functions players hit via events:
#[cfg(test)]mod tests { use factorio_rs::{factorio_api::classes::LuaSurfaceCreateEntityParams, prelude::*};
use crate::control;
fn nauvis() -> factorio_rs::factorio_api::classes::LuaSurface { if let Some(surface) = game.get_surface(1.into()) { return surface; } panic!("expected nauvis surface at index 1"); }
fn place_chest(x: f64, y: f64) -> factorio_rs::factorio_api::classes::LuaEntity { let surface = nauvis(); if let Some(entity) = surface.create_entity(LuaSurfaceCreateEntityParams { name: "iron-chest".into(), position: MapPosition { x, y }, force: Some("player".into()), raise_built: Some(false), create_build_effect_smoke: Some(false), ..Default::default() }) { return entity; } panic!("failed to place iron-chest"); }
#[test] #[ignore = "requires Factorio (run with factorio-rs test)"] fn isolated_building_survives() { let building = place_chest(10.0, 10.0); control::check_build_rules(building, 0); assert!(building.valid()); }
#[test] #[ignore = "requires Factorio (run with factorio-rs test)"] fn two_neighbors_destroy_the_building() { let _a = place_chest(30.0, 30.0); let _b = place_chest(32.0, 30.0); let building = place_chest(31.0, 30.0); control::check_build_rules(building, 0); assert!(!building.valid()); }}Tips for entity tests:
- Prefer distinct coordinates per test so leftover entities from earlier cases do not interfere (the suite shares one map).
- Set
raise_built: Some(false)when you want to call your logic yourself instead of going throughon_built_entity. - Assert with
entity.valid()after rules that maydie()the entity. - Prefer
if let/panic!over.unwrap()/.expect()(those lint deny by default - see Lints).
A fuller suite lives in
examples/mandatory_spaghetti
(src/control.rs).
Multi-tick steps
Section titled “Multi-tick steps”Sync #[test] bodies finish in a single pcall. When you need the game to
advance (belts, inserters, cooldowns), build a step sequence with
factorio_rs::test::steps():
#[cfg(test)]mod tests { use factorio_rs::prelude::*;
#[test] #[ignore = "requires Factorio (run with factorio-rs test)"] fn tick_advances_across_waits() { factorio_rs::test::steps() .step(|ctx| { ctx.set("t0", game.tick()); }) .wait(5) .step(|ctx| { assert!(game.tick() >= ctx.fetch_u32("t0") + 5); }); }}How it works:
steps()registers a pending queue for the current test (call the full pathfactorio_rs::test::steps()so it lowers to the harness intrinsic)..step(|ctx| { ... })runs on the current tick (or right after a wait)..wait(n)advances the map bynticks before the next step.TestCtx(ctx.set/ctx.fetch/ctx.fetch_u32) carries values between steps. Prefer this over outerlet mut- multiple step closures cannot both mutably borrow the same Rust binding.- Use
fetch/fetch_u32, not.get(...)-.getis reserved for mod-settings lowering.
Omit steps() entirely for ordinary sync tests; both styles can mix in one
suite. See examples/hello_world for a smoke
multi-tick case.
Assertions
Section titled “Assertions”Supported macros lower to Lua error(...) on failure:
| Macro | Notes |
|---|---|
assert!(cond) / assert!(cond, "...") |
Optional message |
assert_eq!(left, right) / assert_ne!(...) |
Same |
panic!("...") |
Always fails the test |
Anything that panics or returns Err from ? (where supported) also fails the
test via Lua’s pcall around each case.
Running
Section titled “Running”factorio-rs testfactorio-rs test smoke # name filter (substring)factorio-rs test --timeout 180factorio-rs test --gui # windowed; stays open after the suitefactorio-rs test --skip-typecheck # skip cargo check --tests (not recommended)factorio-rs test --listen # keep Factorio alive for hot-reload re-runsfactorio-rs test --rerun # rebuild + wait for next suite (Bacon)factorio-rs test will:
- Run
cargo check --tests(unless--skip-typecheck) - Build the mod into
.factorio-rs/test-run/mods/ - Launch Factorio with a blank
factorio-rs-testscenario- default: headless dedicated server (
--start-server-load-scenario) --gui: singleplayer window (--load-scenario)
- default: headless dedicated server (
- Print a colored report and exit non-zero on failures
--listen / --rerun keep a Factorio process warm and re-run the suite after
game.reload_mods() - see Hot reload with Bacon.
Example output:
running 6 tests[OK] tests::rails_are_on_adjacency_blacklist[OK] tests::poles_and_pipes_are_on_pattern_blacklist[OK] tests::isolated_building_survives_adjacency_check[OK] tests::building_survives_with_one_neighbor[OK] tests::building_explodes_with_two_neighbors[OK] tests::building_explodes_next_to_three_adjacent_buildings
test result: ok. 6 passed; 0 failed; 0 ignoredColors are enabled when stdout is a TTY. Set NO_COLOR=1 for plain text.
Watching with --gui
Section titled “Watching with --gui”factorio-rs test --guiOpens Factorio so you can see the scenario load. Sync tests finish almost
immediately; multi-tick .wait(n) cases take at least n ticks, which is
easier to spot with --gui. Factorio stays open after the suite so you
can inspect leftover entities. Close the window to finish the CLI. Increase
--timeout if graphics startup is slow on your machine.
How it works
Section titled “How it works”- Discover - the frontend finds
#[test]fns under#[cfg(test)]. - Emit - those tests become
dist/lua/factorio_rs_tests.lua, and a small harness is appended tocontrol.lua. - Isolate - an ephemeral write-data tree under
.factorio-rs/test-run/holds mods,config.ini, the scenario, andscript-output/. Your normal~/.factoriodata is untouched. Ignore this directory in git (new projects fromfactorio-rs initalready do). - Run - each test is wrapped in
pcall. Tests that callsteps()are driven across ticks by the harness. Pass/fail lines go to stdout vialocalised_print, with a results file as backup. - Report - the CLI parses the protocol and prints
[OK]/[FAIL].
Requirements
Section titled “Requirements”- A real Factorio binary (
FACTORIO_PATH, common Steam paths, orPATH). Steamsteam://protocol launches cannot pass scenario load flags. - On Linux Steam installs,
steam-runis used automatically when available. SetFACTORIO_RS_NO_STEAM_RUN=1to force a direct launch.
Limitations
Section titled “Limitations”- Tests share one map and run sequentially in discovery order; clean up or use unique positions when placing entities.
- There is no per-test Factorio restart / map reset.
- Host-only crates and
stdAPIs that are not lowered will not work inside tests (same rules as control-stage code). See Supported Rust.
See also
Section titled “See also”- CLI reference - full
factorio-rs testflags - Hot reload with Bacon - watch +
--rerun - hello_world - minimal smoke tests
- mandatory_spaghetti - adjacency
simulations with
create_entity - API types - typed params for
create_entity/ filters