Writing and running scripts/tests/e2e.lua.
E2E tests
An end-to-end test plays your game automatically and checks that it behaves: this button opens that scene, that enemy dies when hit, the HUD updates when health changes. E2E tests exist so regressions are caught by a command in seconds, instead of by a player after release. DemiEngine E2E tests are Lua scripts run through the real runtime — the same loader, input, and rendering path a player uses. This page explains how to write and run them, using "Skyforge" at games/my_game.
Where tests live
Project tests live in one file:
games/my_game/scripts/tests/e2e.lua
That file returns a table with a tests array. Each entry is one test: a name and a func:
local Test = require("demi.test")
local tests = {
{
name = "menu_opens_game",
func = function()
Test.expect_scene("scene://game/menu")
Test.touch("btn_play")
Test.expect_scene("scene://game/main", 5.0)
end
},
{
name = "player_health_bar_follows",
func = function()
Test.expect(Test.node_center("health_bar") ~= nil, "health bar exists")
end
}
}
return { tests = tests }
require("demi.test")— imports the test helper table. Available only in test runs.tests— the array of test entries the runner executes in order.name— the string printed in thePASS/FAILmarker line.func— the function that performs the test usingTestcalls. It returns nothing; assertions decide pass or fail.return { tests = tests }— what the file must return for the runner to find the suite.
The Test helpers
Every helper is stable-ID first: prefer names over coordinates so tests survive resolution, orientation, DPI, and safe-area changes.
Touch and gestures
Test.touch("btn_play")
Test.touch(node_id)— activates the HUD node with that stable ID as if a player tapped it. Returns nothing.
Test.tap(320, 240)
Test.tap(x, y)— taps a screen position in canvas coordinates. Use only when no node ID exists.
Test.swipe("card_hand", "card_discard")
Test.swipe_xy(300, 200, 360, 200)
Test.swipe(from_node_id, to_node_id, duration?)— swipes between the two HUD node centers over the optional duration (default 0.4 s). Returns nothing.Test.swipe_xy(x0, y0, x1, y1, duration?)— swipes between two canvas-space positions over the optional duration.
Waiting and measuring
Test.wait(0.5)
Test.wait(seconds)— advances game time by the given number of seconds. Bounded and deterministic; prefer it over wall-clock sleeps.
Test.expect_scene("scene://game/main", 5.0)
Test.expect_scene(scene_id, timeout?)— waits until the named scene is active. The optionaltimeoutis a maximum wait in seconds; without it, the suite's default bound applies. Fails the test if the scene does not appear in time.
Test.expect(health > 0, "player is still alive")
Test.expect(condition, message)— asserts thatconditionis true. On failure it reportsmessagein the test output. Returns nothing.
local center = Test.node_center("btn_play") -- { [1] = x, [2] = y } or nil
Test.node_center(node_id)— returns the node's center as aVec2numeric array (index[1]is x,[2]is y), ornilwhen no such node exists.
Output markers
Every run prints line-oriented markers so logs and continuous integration can parse the outcome:
[test] PASS menu_opens_game
[test] FAIL player_health_bar_follows
[test] SUMMARY passed=1 failed=1
[test] PASS name— one line per test that succeeded.[test] FAIL name— one line per test that failed.[test] SUMMARY passed=N failed=M— the final line of every run, always printed even when something crashed.
The command exits 0 when failed=0 and 1 when any test failed.
Running tests
Four runners cover the same suite at different levels of realism:
demi test linux
test linux— desktop end-to-end through the real runtime on this machine.
demi test android --serial <device>
test android --serial d— device end-to-end via adb on a connected physical device. Android qualification uses real hardware; do not assume an emulator is available.
demi run --e2e-tests
run --e2e-tests— runs the project E2E scripts during a normal launch. Useful when you want the test pass and a playable window in one command.
DEMI_HEADLESS=1 demi test
DEMI_HEADLESS=1— runs with no visible window. The fastest signal in a loop or on a build machine.
Related command details are in Build, cook & test.
Writing tests that survive change
- Prefer stable node IDs.
Test.touch("btn_play")outlives a redesign that moves the button;Test.tap(10, 10)does not. - Bound every wait. Pass a timeout to
expect_sceneand keepwaitcalls short. A test must finish, pass or fail. - Stay independent. Each test should start from a known scene or state so one failure does not cascade into false failures.
- Assert meaning, not pixels. "The scene opened" and "health is above zero" are stable claims; "the pixel at 300,200 is red" is not.
- Use deterministic simulation. The fixed timestep and random seed of Simulation & seeds make runs reproducible; tests should not need luck.
A Skyforge example
local Test = require("demi.test")
local tests = {
{
name = "arena_loads",
func = function()
Test.expect_scene("scene://game/arena", 5.0)
end
},
{
name = "fire_button_moves_hud",
func = function()
local before = Test.node_center("ammo_label")
Test.expect(before ~= nil, "ammo label is placed")
Test.touch("btn_fire")
Test.wait(0.2)
end
},
{
name = "score_screen_returns",
func = function()
Test.touch("btn_menu")
Test.expect_scene("scene://game/menu", 5.0)
end
}
}
return { tests = tests }
- The suite has three named tests executed in order: load the arena, fire through the HUD, and return to the menu.
expect_scenefails the test if the scene is not active within 5 seconds.node_centerreturns aVec2numeric array ([1],[2]) ornil; the test asserts the label exists before touching the fire button.- The run ends with
[test] SUMMARY passed=N failed=Mand the matching exit code.
When a test fails in the field, capture it as an input recording and replay it locally — see Replay & profiler.
Headless E2E runs advance the test runner and synthetic HUD input using the fixed simulation step. They finish with a pass/fail summary and a failing exit status for failed or unfinished tests. Without --max-frames, the suite has a 120-second simulated-time budget; ordinary headless smoke runs still default to one frame. An explicit frame limit that interrupts a test reports failure rather than silently skipping it.
Desktop tests forward --window-size WIDTHxHEIGHT, --max-frames N and --profile-frames PATH to the runtime. --timeout limits wall-clock seconds; --max-frames separately bounds simulation frames. Longer hauling or simulation tests may need both budgets increased.
DEMI_HEADLESS=1 demi test linux --project examples/colony_builder --timeout 180 --max-frames 24000
demi test linux --project examples/colony_builder --window-size 5120x2806 --timeout 300 --max-frames 24000