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:
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:
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
endHeadless 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
Heartbeatheadless, so effects only run when you callGraph.flush()yourself (and motion only steps when you callFlux.Motion.step()). - Roblox datatypes don't exist headless:
numbersprings and tweens work fully, but animating aUDim2/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 (atable.insertyou assert on, say) fires twice. CallFlux.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.
-- 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.