The best practice for automated testing of Phaser.js HTML5 games in 2026 is to test in layers. Fast unit tests cover the rules, data tests cover levels and tuning, and a smaller set of end-to-end tests plays the real build in three browser engines. The trick that makes Phaser testable is architectural: keep game rules out of scenes, and give end-to-end tests a way to read game state, because a canvas has no DOM for a test to inspect. This guide shows that setup with Vitest and Playwright, then covers how to debug HTML5 games in Chrome and on phones. It is the testing chapter of our HTML5 game development best practices checklist.
What to test in an HTML5 game
| Layer | Tool | What it covers | Speed |
|---|---|---|---|
| Unit | Vitest in Node | Rules, scoring, economy, random generation, save migrations | Milliseconds per test |
| Data | Vitest in Node | Level files, tuning JSON, translations, solvability | Fast |
| Browser components | Vitest browser mode | Code that needs a real DOM, canvas or Web Audio | Seconds |
| End to end | Playwright | Boot, menus, a full level, input per device, no errors | Seconds to minutes |
| Manual | Real phones and tablets | Feel, audio, gestures, frame rate, in-app browsers | Slow, but irreplaceable |
Put most of your tests at the top of the table. A unit test that checks a scoring rule runs in a millisecond and fails with a clear message; an end-to-end test that discovers the same bug takes a minute and fails with "timed out waiting for results screen".
Best practices for automated testing of Phaser games in 2026
1. Keep the rules out of Phaser scenes
A Phaser scene that calculates scores, checks win conditions and moves sprites can only be tested by running Phaser. Split it. Put the rules in plain TypeScript modules that never import Phaser, and let the scene read their state and draw it. Then the rules run in Node under Vitest with no browser at all.
export interface Match { size: number; combo: number }
export function scoreFor(m: Match): number {
const multiplier = Math.min(1 + m.combo * 0.5, 4); // combos add 50%, capped at 4x
return m.size * 10 * multiplier;
}import { describe, expect, it } from "vitest";
import { scoreFor } from "../../src/sim/score.ts";
describe("scoreFor", () => {
it("scores a plain match of three", () => {
expect(scoreFor({ size: 3, combo: 0 })).toBe(30);
});
it("caps the combo multiplier at 4x", () => {
expect(scoreFor({ size: 3, combo: 99 })).toBe(120);
});
});The same split helps far beyond testing: a pure simulation can be replayed, run faster than real time, or moved to another engine. Our Phaser tutorial uses the same structure from the first scene.
2. Make randomness and time injectable
Tests need the same result every run. Never call Math.random() or Date.now() deep inside game rules; pass in a random function and a clock. A seeded generator gives you reproducible levels, reproducible bugs and daily challenges that are the same for everyone.
// mulberry32: a tiny, fast seeded generator, fine for gameplay (not for security)
export function seeded(seed: number): () => number {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}3. Test your data, not only your code
Our games keep tuning (costs, waves, levels and economy) in JSON files, which is good for designers and dangerous without tests. A data test loads every level and checks invariants: unique IDs, references that resolve, prices that rise, no enemy wave with zero enemies, every translation key present in every language.
Puzzle games can go one step further with a solver: a test that searches each level for a solution, so you never ship an impossible level and you learn the minimum number of moves for star ratings. Four puzzle games in our catalog are tagged solver-proven: Shape Factory Flow, Magnet Maze, Cannon Courier and Bounce Architect.
4. Give end-to-end tests a test hook
Playwright finds HTML elements by role, text or test ID. A Phaser game draws its sprites into a canvas, so there is nothing for a locator to find. Don't fall back to clicking hard-coded pixel positions; they break on every layout change. Instead, expose a small, read-mostly test hook when the URL contains a flag, and let tests ask the game where things are.
// Installed only when the page URL contains ?test=1, so players never see it.
export function installTestHook(game: Phaser.Game, sim: Sim): void {
if (!new URLSearchParams(location.search).has("test")) return;
(window as any).__game = {
state: () => ({ booted: sim.booted, level: sim.levelId, moves: sim.moves, busy: sim.busy }),
tapPoint: (id: string) => {
// world position -> page position (camera zoom omitted for brevity)
const obj = sim.find(id);
const cam = game.scene.getScene("Play").cameras.main;
const rect = game.canvas.getBoundingClientRect();
const scale = rect.width / game.scale.width;
return { x: rect.left + (obj.x - cam.scrollX) * scale, y: rect.top + (obj.y - cam.scrollY) * scale };
},
};
}Keep HTML parts of the game (menus, dialogs, results screens) testable the normal way, with data-testid attributes and page.getByTestId().
5. Wait for state, never sleep
Games are full of animations, tweens and loading. A test that clicks during a tween, or sleeps for "long enough", passes on your machine and fails in a slower engine. Wait for the condition you need instead:
import { expect, test } from "@playwright/test";
test("a tap on the board counts as a move, with no errors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); });
await page.goto("/?test=1&mute=1");
await page.waitForFunction(() => (window as any).__game?.state().booted === true);
await expect(page.locator("canvas")).toBeVisible();
const p = await page.evaluate(() => (window as any).__game.tapPoint("tile-3-4"));
await page.mouse.click(p.x, p.y);
await expect.poll(() => page.evaluate(() => (window as any).__game.state().moves)).toBe(1);
await page.waitForFunction(() => (window as any).__game.state().busy === false); // animations done
expect(errors).toEqual([]);
});Two habits in that test are worth copying everywhere. Collecting pageerror and console errors turns every end-to-end test into a crash detector for free. And expect.poll or waitForFunction replace every waitForTimeout: if a test needs to wait, it should say what it is waiting for.
6. Control time instead of waiting for it
A level with a two-minute timer shouldn't take two minutes to test. You have two options. A game-side option is a time-scale setting on the test hook that runs the simulation faster. A browser-side option is Playwright's Clock API, which overrides Date, timers, requestAnimationFrame and performance, so page.clock.install() followed by page.clock.runFor() can advance a game by minutes in one call. The game-side option is usually simpler for long simulations; the clock is ideal for daily challenges and date-dependent rewards.
7. Test the production build, muted
Run end-to-end tests against the built game, not the dev server, because minification, asset paths and code splitting break in ways the dev server never shows. Playwright's webServer option can start vite preview for you. Start games muted from the URL so audio autoplay rules don't add noise; our games accept a mute parameter for exactly this, and for portals.
8. Cover three engines and two kinds of device
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "tests/e2e",
use: { baseURL: "http://localhost:4173", screenshot: "only-on-failure", trace: "retain-on-failure" },
projects: [
{ name: "desktop-chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "desktop-firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "desktop-webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chromium", use: { ...devices["Pixel 7"] } },
{ name: "mobile-webkit", use: { ...devices["iPhone 15"] } },
],
webServer: { command: "npx vite preview --port 4173 --strictPort", url: "http://localhost:4173" },
});Decide which tests run where by input type, not by browser name: mouse and keyboard tests on the desktop projects, touch tests on the mobile ones. Then every desktop test runs in all three engines automatically. Firefox and WebKit surface real bugs that Chromium hides, especially around storage, audio and timing. With trace: "retain-on-failure", a failing run leaves a trace you can open in Playwright's Trace Viewer to step through every action, network request and console message.
Know what this doesn't prove. Playwright's own browsers page explains that its WebKit is built from WebKit's main branch with patches and is not branded Safari. Device profiles emulate the viewport, user agent and touch, not a phone's CPU, GPU or operating system. A pass on the mobile-webkit project is good evidence that the game works on iPhones, not proof.
9. Use screenshots sparingly
Screenshot comparisons (toHaveScreenshot in Playwright, or toMatchScreenshot in Vitest 4's browser mode) catch layout regressions in menus and HUDs. On a WebGL canvas they are fragile: GPU drivers, font rendering and anti-aliasing differ between machines. If you use them, generate baselines on the same operating system as your CI, mask animated regions, freeze time first, and allow a small pixel tolerance. For gameplay, assert on state through the test hook instead.
How to debug HTML5 games using Chrome
Tests tell you something broke. Chrome DevTools tells you why.
Breakpoints that don't drown you
A breakpoint in a game loop fires 60 times a second, which is useless. Chrome's breakpoint types solve that:
- Conditional breakpoints pause only when an expression is true, such as
enemy.hp < 0orplayer.y > 2000. - Logpoints print a message without pausing and without editing your code, which beats sprinkling
console.logthrough a loop. - Event listener breakpoints pause on the next
pointerdownorkeydown, the fastest way to find which handler eats an input. - Exception breakpoints pause where an error is thrown, including caught ones, which is how you find a save error that a
try/catchswallowed.
Add a debugger; statement in code for a one-off pause, and remove it before you commit.
Slow frames and stutter
Record a few seconds in the Performance panel and look for long frames in the Frames section, then read the Main track's flame chart to see what ran. Turn on Frame rendering stats in the Rendering tab for a live FPS and dropped-frame overlay. Our HTML5 game performance guide covers what to fix once you find the slow part.
Memory leaks between scenes
The classic Phaser leak is a scene that adds listeners, timers or tweens on create and never removes them on shutdown. Each restart adds another copy, and the game slows down after a few levels. Take a heap snapshot in the Memory panel, restart the level five times, take another, and compare: objects whose count grows by exactly five are your leak.
Draw what the player can't see
Many bugs are invisible: a hitbox larger than its sprite, a trigger in the wrong place, a body that never sleeps. In Phaser's Arcade Physics, set debug: true in the physics config to draw bodies and velocities. For your own systems, add a debug overlay behind a URL flag that draws hit areas and prints key state on screen.
Debugging on phones
For Android, enable USB debugging on the phone, open chrome://inspect#devices in desktop Chrome, and inspect the phone's tab with full DevTools, as described in Chrome's remote debugging guide. For iPhones and iPads, enable Web Inspector in Safari's advanced settings on the device and open the page from the Develop menu of Safari on a Mac; Apple documents the steps in inspecting iOS and iPadOS.
Inspecting WebGL frames
When a WebGL scene draws wrong, Spector.js captures a single frame and shows every draw call, texture and shader state. It is available as an extension for Chrome and Firefox and works with any WebGL engine, including Phaser and Three.js.
Reproduce before you fix
The fastest bug fix starts with a reliable reproduction. If your game records its random seed and the player's inputs per simulation step, any bug report becomes a replay you can run in a test. That is one more payoff of injectable randomness and a fixed-step loop.
What automation can't tell you
Automated tests prove the game boots, plays and doesn't crash across engines. They don't tell you whether it's fun, whether the audio starts on an iPhone with the ringer switch off, whether a swipe fights the browser's own gestures, or whether a three-year-old Android phone holds its frame rate. Before release, play every game on at least one real iPhone and one real Android phone, including inside an in-app browser if your players arrive from social media.
If you want to see a complete test setup rather than build one, every game in our catalog of HTML5 games ships with its tests/ folder, so you can read and run the same unit and end-to-end tests we do.
Frequently asked questions
What is the best way to test a Phaser game automatically?
Keep game rules in plain TypeScript modules and unit-test them with Vitest, which is fast and needs no browser. Add data tests for levels and tuning files, and a small set of Playwright end-to-end tests that boot the production build and play through a level in Chromium, Firefox and WebKit.
Can Playwright test a canvas game?
Yes, but not by finding sprites with locators, because canvas content isn't in the DOM. Expose a test hook behind a URL flag that reports game state and screen positions, click at those positions with page.mouse, and assert on state. HTML menus and dialogs can still use normal locators.
Do I need Phaser's headless mode for unit tests?
Usually not. Phaser has a HEADLESS renderer type that creates no Canvas or WebGL renderer, but the game still runs as a full Phaser instance in a browser-like environment. If the rules live in plain modules, you can test them directly in Node, which is faster and simpler.
How do I debug an HTML5 game on an iPhone?
Enable Web Inspector in Safari's advanced settings on the iPhone, connect it to a Mac, and open the page from the Develop menu in desktop Safari. You get the console, network and storage tools for the page running on the phone.
We make and sell HTML5 game source code; links to our games are marked as ours.