Skip to content

Testing

Flux is built to be testable: components are plain functions, state lives in ordinary objects rather than a hidden component tree, and the reactive core has no Roblox dependencies. This page covers the patterns that keep tests deterministic.

Deterministic Effects with Flux.flush()

Effects and property bindings are batched and run once per Heartbeat. Inside a test you don't want to wait a frame; call Flux.flush() to run everything pending synchronously:

luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Flux = require(ReplicatedStorage.Flux)
local new = Flux.new

local count = Flux(0)
local label = new "TextLabel" {
    Text = function()
        return "Count: " .. count -- `.. count` reads the signal's current value (see [Signals](/guide/concepts/signals))
    end,
}

assert(label.Text == "Count: 0") -- the initial binding value is applied synchronously, no flush needed

count(5)
Flux.flush() -- run pending effects and property updates now

assert(label.Text == "Count: 5")

Isolating Tests with Scopes

Create everything in a test through a Scope and destroy it in teardown, so no state or instances leak between cases:

luau
local function testCounter()
    local scope, ui = Flux.scope(function()
        local count = Flux(0)
        local label = new "TextLabel" {
            Text = function()
                return "Count: " .. count
            end,
        }
        return { count = count, label = label }
    end)

    ui.count(3)
    Flux.flush()
    assert(ui.label.Text == "Count: 3")

    scope:Destroy() -- tears down the binding and the label; the count signal is left for the GC
end

Headless Testing (No Roblox)

The whole library, including the root Flux module, runs in the vanilla Luau CLI (0.640+ for require-by-string). Pure state logic can be tested on your machine or in CI with no Studio and no Roblox place.

Three caveats:

  • There is no Heartbeat headless, so effects only run when you call Graph.flush() yourself (and motion only steps when you call Flux.Motion.step()).
  • Roblox datatypes don't exist headless: number springs and tweens work fully, but animating a UDim2/Vector3/etc. needs the real engine.
  • Strict mode is on by default headless (the CLI runs below -O2), so every reactive body runs twice. That catches impure logic, but it also means a side effect inside a body (a table.insert you assert on, say) fires twice. Call Flux.strict(false) for a deterministic single run, or assert on a node's value rather than how many times an effect fired.

Graph is the dependency-free core that Flux() wraps. Graph.new(v) is a Signal, Graph.new(fn) a Computed, Graph.new(fn, true) an Effect, and :get()/:set() are the explicit forms of the call sugar.

luau
-- spec.luau (run with: luau spec.luau)
local Graph = require("./src/Graph")

Graph.strict(false) -- headless defaults to strict-on (double-runs bodies); off here for a single, deterministic run

local count = Graph.new(1)
local doubled = Graph.new(function()
    return count:get() * 2
end)

assert(doubled:get() == 2)
count:set(5)
assert(doubled:get() == 10)

local seen = {}
local effect = Graph.new(function()
    table.insert(seen, count:get())
end, true) -- the `true` makes this an Effect (see [Effects](/guide/concepts/effects))

Graph.flush()
assert(#seen == 1 and seen[1] == 5)

Flux's own spec, test/spec.client.luau, runs exactly this way and doubles as a reference for the graph's guaranteed semantics: laziness, memoization, diamond updates, effect batching, error recovery, and cycle containment.

Full-Engine Tests in CI

For tests that need real instances (component output, hydration, motion), build a test place with Rojo and execute the spec in the cloud with the Open Cloud Luau Execution API, with no Studio session required. Flux's own CI workflow does exactly this with the same test/spec.client.luau that runs headless: the suite detects its runtime, skipping Roblox-only tests under the CLI and running everything (instances, hydration, motion datatypes) in the cloud. rojo build produces a place file, a script uploads it, runs the spec task, and fails the build on any failed assertion.