Testing model
Why you can run a full spin in Node, with zero renderer.
pixi-reels was designed so mechanics, not pixels, are what you test. The testing module gives you a real ReelSet running against a fake ticker and headless symbols. no canvas, no DOM, no WebGL. Every test in the library's suite runs in Node under vitest; each takes milliseconds.
The data flow
flowchart TB
TestFile["my-spin.test.ts<br/><i>describe · it · expect · vitest node environment</i>"]
subgraph TestingModule["pixi-reels/testing"]
direction TB
FakeTicker["FakeTicker<br/><i>.tick(ms) · .deltaMS</i>"]
HeadlessSymbol["HeadlessSymbol<br/><i>no sprite · no texture</i>"]
CreateTest["createTestReelSet({...})<br/><i>returns { reelSet, ticker, spinAndLand, advance }</i>"]
end
TestFile -->|import| TestingModule
subgraph RealReelSet["Real ReelSet (no renderer)"]
direction LR
SpinController["SpinController<br/><i>registered with FakeTicker</i>"]
Reels["Reel[] × N<br/><i>HeadlessSymbol × V+buffers</i>"]
Events["EventEmitter<br/><i>real typed events</i>"]
FrameBuilder["FrameBuilder<br/><i>real middleware pipeline</i>"]
Flow["reelSet.spin() → setResult() → slamStop() ⇒ synchronous landing (microtask)<br/><i>no ticker frames needed. slamStop() force-completes every phase</i>"]
end
TestingModule -->|constructs| RealReelSet
Assertions["Assertions you can make in Node<br/><i>expectGrid · captureEvents · countSymbol · debugSnapshot</i>"]
RealReelSet --> Assertions
Three parts, in detail
FakeTicker
Duck-compatible with PIXI.Ticker: same add/remove/deltaMSsurface, no rendering. Tests that want to drive time callticker.tick(16) or ticker.tickFor(1000, 16)manually. The library never distinguishes a real Ticker from a fake one. the TickerRef wrapper only calls the standard interface.
HeadlessSymbol
Extends ReelSymbol. Creates a PixiJS Containerfor view (so scene-graph code works) but never draws anything. Every symbol subclass requires the same lifecycle hooks (onActivate, resize, playWin…) so it slots into the factory the same way a SpriteSymbol would.
createTestReelSet
The one-liner. Builds a ReelSet wired to a FakeTicker with HeadlessSymbol-backed entries for every id you pass in. Returns a handle with two convenience methods:
advance(ms, stepMs?). run the FakeTicker for that many ms.spinAndLand(grid).spin() → setResult(grid) → slamStop(). Resolves synchronously becauseslamStop()force-lands without any animation frames, and unlikeskipSpin()it carries no speed-boost side effect. This is how every mechanic test asserts outcomes in milliseconds.
Why this composition is testable
The library has zero renderer assumptions anywhere except in the concrete ReelSymbol subclasses (Sprite/AnimatedSprite/Spine). Control flow sits in SpinController + phases + events, all of which are just JavaScript objects. Swapping the symbols for HeadlessSymbol and the ticker for FakeTicker is enough to run the whole thing in Node.
That's also why the test suite ships as a tree-shakeable sub-module (pixi-reels/src/testing/, exported from the barrel). Production bundles drop it automatically; test bundles keep it.
What a real test looks like
import { createTestReelSet, expectGrid, captureEvents } from 'pixi-reels/testing';
it('hold-and-win persists across spins', async () => {
const h = createTestReelSet({
reels: 5, visibleCells: 3,
symbolIds: ['a', 'b', 'coin'],
});
try {
await h.spinAndLand([
{ visible: ['coin','a','b'] },
{ visible: ['b','coin','a'] },
{ visible: ['a','b','coin'] },
{ visible: ['coin','a','b'] },
{ visible: ['b','coin','a'] },
]);
const log = captureEvents(h.reelSet, ['spin:complete']);
await h.spinAndLand(/* next stage */);
expect(log).toHaveLength(1);
} finally {
h.destroy();
}
});This is exactly the shape every mechanic test intests/integration/mechanics.test.ts follows.