DEMIENGINEDOCUMENTATION Everything is free

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 the PASS/FAIL marker line.
  • func — the function that performs the test using Test calls. 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 optional timeout is 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 that condition is true. On failure it reports message in 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 a Vec2 numeric array (index [1] is x, [2] is y), or nil when 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_scene and keep wait calls 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_scene fails the test if the scene is not active within 5 seconds.
  • node_center returns a Vec2 numeric array ([1], [2]) or nil; the test asserts the label exists before touching the fire button.
  • The run ends with [test] SUMMARY passed=N failed=M and 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