Lesson 15
Automated testing: catch boring game bugs before players do.
Games still need playtesting, but automation catches the failures that should never reach a player: syntax errors, missing UI elements, broken search, thin pages, bad canonical URLs and scripts that crash on first load.
1. Test the boring contract first
A browser game page has a basic contract: it has a useful title, description, canonical URL, navigation, controls, a canvas or play area, and no obvious draft text. These checks are not glamorous, but they protect the site from low-value pages and accidental broken templates.
const fs = require("fs");
const path = require("path");
const repoRoot = path.resolve(__dirname, "..");
const pages = [
"learn/index.html",
"learn/html5-canvas-game-loop.html",
"learn/flying-bird-javascript-tutorial.html"
];
for (const page of pages) {
const html = fs.readFileSync(path.join(repoRoot, page), "utf8");
if (!/<title>[^<]{20,}<\/title>/i.test(html)) {
throw new Error(`${page}: missing useful title`);
}
if (!/<link rel="canonical" href="https:\/\/supagamesai\.com\//i.test(html)) {
throw new Error(`${page}: missing canonical URL`);
}
if (!/<pre><code class="language-/.test(html)) {
throw new Error(`${page}: missing real code block`);
}
}
Source pattern: Supagames `scripts/test-learn-pages.js` checks Learn pages for SEO basics, useful content length, code samples and navigation before publishing.
Static tests are fast and cheap. They do not prove the game is fun, but they catch mistakes that are embarrassing precisely because they are easy to detect.
2. Run syntax checks on shared JavaScript
For standalone HTML games, syntax errors often appear only when the browser reaches the script. For shared `.js` files, use Node syntax checks. A single missing parenthesis can break every game that imports a shared widget.
node --check js/main.js
node --check js/game-ui.js
node --check js/ratings.js
node --check workers/ratings-api/src/index.js
Syntax checks are not unit tests. They do not know whether the game is correct. They do catch broken JavaScript before it becomes a console error on the live site.
3. Use small DOM smoke tests for site features
Supagames has features that are not full games but still affect players: homepage search, ratings, comments, level titles and catalog rendering. These can be tested with a tiny fake DOM in Node. The point is to simulate the minimum environment needed to run the function.
const assert = require("node:assert");
const vm = require("node:vm");
const input = { value: "piano tiles" };
const resultsGrid = { children: [], replaceChildren(...items) { this.children = items; } };
const document = {
getElementById(id) {
return { searchInput: input, searchResultsGrid: resultsGrid }[id] || null;
},
addEventListener() {},
createElement(tag) {
return { tagName: tag, children: [], appendChild(child) { this.children.push(child); } };
}
};
vm.runInNewContext(sourceCodeFromMainJs, {
document,
window: { SUPAGAMES_CATALOG: [{ title: "Piano Tiles", url: "games/lvl06/01-piano-tiles.html" }] }
});
assert.strictEqual(resultsGrid.children.length, 1);
Fake DOM tests should stay focused. Do not rebuild a browser in your test file. If a feature needs real layout, use browser testing. If it only needs a few DOM methods, a small harness is faster and easier to debug.
4. Test original bug reports directly
When fixing a specific bug, write a test that resembles the report. Supagames has had bugs like "after the first bomb defusal the next round does not appear," "search returns HTML entities instead of icons," and "space cannot be typed inside comments." Each one deserves a targeted check.
const html = fs.readFileSync("games/lvl03/14-bomb-diffusal.html", "utf8");
const script = html.match(/<script>\s*(const C=document\.getElementById[\s\S]*?state='menu';)\s*<\/script>/)[1];
const context = {
document: {
getElementById(id) {
return elements[id] || null;
}
},
localStorage: { getItem() { return "0"; }, setItem() {} },
Math: Object.assign(Object.create(Math), { random: () => 0 }),
setInterval() {},
setTimeout() {}
};
vm.createContext(context);
vm.runInContext(script, context);
vm.runInContext("init(); startRound();", context);
const round = vm.runInContext("({ clueMode, clueText, count: wires.length })", context);
assert.match(round.clueText, /RED/);
assert.strictEqual(round.count, 3);
That style of test is not pretty, but it is honest. It proves the script can initialize under controlled conditions and that the round state contains playable data.
5. Browser smoke tests catch console errors
Some game failures require an actual browser because canvas, layout, pointer events and third-party scripts behave differently outside Node. A browser smoke test should open the page, collect console errors, click Start if needed and verify that a visible game area exists. It does not need to play perfectly.
async function smokeGame(page, url) {
const errors = [];
page.on("console", msg => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", err => errors.push(err.message));
await page.goto(url);
await page.locator("canvas, .game-shell, #gameCanvas").first().waitFor({ timeout: 5000 });
const start = page.locator("button:has-text('Start'), button:has-text('Play')").first();
if (await start.count()) await start.click();
if (errors.length) {
throw new Error(`${url} console errors:\n${errors.join("\n")}`);
}
}
Ad blockers can produce external script warnings, so separate expected network blocks from your own JavaScript errors. A blocked ad script is different from `ReferenceError: player is not defined` inside the game.
6. What automation cannot replace
Automation will not tell you that easy mode is too hard, that a boss is boring, that a mobile joystick covers the action or that a story ending feels abrupt. Human playtesting remains essential. The best workflow combines both: automated checks for repeatable correctness, manual play for feel and design.
Before publishing a game, run static checks, syntax checks and at least one smoke test. Then play a complete loop: start, learn, fail, restart, win if possible. After deployment, open the live URL with a cache-busting query and check the console again. That habit is boring in the best possible way: it prevents avoidable drama.