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
Three parts, in detail
FakeTicker
Duck-compatible with PIXI.Ticker: same add/remove/deltaMS
surface, no rendering. Tests that want to drive time call
ticker.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 Container
for 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) → skip(). Resolves synchronously becauseskip()force-lands without any animation frames. 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';
it('hold-and-win persists across spins', async () => {
const h = createTestReelSet({
reels: 5, visibleRows: 3,
symbolIds: ['a', 'b', 'coin'],
});
try {
await h.spinAndLand([
['coin','a','b'],
['b','coin','a'],
['a','b','coin'],
['coin','a','b'],
['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 in
tests/integration/mechanics.test.ts follows.