Automated Game Testing: Unit Tests, Integration Tests, and CI for Web Games
Why Automate Game Tests
Manual testing does not scale. A small game with 20 features, each with 3 test cases, requires 60 manual checks. A medium game with 100 features requires 500 checks. Running all 500 every time you change a line of code is impractical, so developers skip regression testing and hope nothing broke. Automated tests eliminate this tradeoff: 500 test cases run in seconds on every commit, providing instant feedback. A developer who breaks something learns about it immediately, not three weeks later when a player reports it.
Automated tests also serve as executable documentation. A test case that verifies calculateDamage(attack: 10, defense: 3, critMultiplier: 2) returns 14 tells any developer exactly how the damage formula works without reading the source code or hunting for design documents. When the formula changes, the test must change too, keeping the documentation synchronized with the code. Comments and wikis drift out of date; tests that fail force updates.
The investment pays off most during refactoring. When you restructure the codebase, rename functions, split modules, or swap out a physics engine, automated tests tell you immediately whether the refactored code produces the same results as the original. Without tests, refactoring is a gamble that often introduces subtle bugs that are only discovered weeks later by players.
Unit Testing Game Logic
Unit tests verify individual functions and modules in isolation. The key insight for game testing is that game logic separates into two categories: pure logic (no browser APIs needed) and rendering/IO (requires browser context). Pure logic is ideal for unit testing because it can run in Node.js without a browser, making tests fast and reliable.
Game systems that are straightforward to unit test include: damage calculations, armor reduction formulas, and critical hit logic; inventory management (add item, remove item, stack limits, weight limits); crafting recipes (input items produce output items, resource consumption); economy systems (currency conversion, shop pricing, discount stacking); pathfinding algorithms (A* returns the shortest path, obstacles are avoided); state machine transitions (idle to running to jumping to falling, valid and invalid transitions); score calculations (combo multipliers, time bonuses, penalty deductions); save/load serialization (game state round-trips through JSON without data loss); and collision detection math (AABB overlap, circle intersection, ray casting).
A unit test for a damage formula using Vitest looks like this in concept: define the function under test, call it with known inputs, and assert the output matches the expected value. Test the happy path (normal damage), edge cases (zero defense, zero attack), boundary conditions (defense equal to attack), and special modifiers (critical hits, elemental damage). Each test should be independent, testing exactly one behavior, and should run in under 10 milliseconds.
Testing frameworks for JavaScript include Vitest (fast, ESM-native, Vite-compatible), Jest (widely used, large ecosystem, slower startup), and Mocha (flexible, minimal, pair with Chai for assertions). Vitest is the current best choice for game projects because it supports ESM imports natively (important for modern game code), runs tests in parallel, and provides a watch mode that re-runs only the tests affected by your code changes.
Integration Testing Game Systems
Integration tests verify that multiple systems work together correctly. A unit test verifies that the damage formula calculates correctly in isolation. An integration test verifies that when an enemy's attack collides with the player, the damage formula is called with the correct values, the player's health decreases, the health UI updates, and the hit animation plays. Integration tests exercise the connections between systems, catching bugs that live in the glue code.
Save/load integration testing is especially valuable. Create a game state with diverse data: player position, inventory with multiple item types, unlocked abilities, current quest progress, settings. Save it to the storage backend (LocalStorage or IndexedDB). Load it back. Compare every field in the loaded state against the original. This test catches serialization bugs (data types that do not survive JSON round-tripping), schema migration failures (loading a save from a previous version), and storage limit issues (save data too large for LocalStorage).
Physics integration tests place objects in known configurations and advance the simulation for a fixed number of frames. Verify that a ball dropped from height Y reaches the ground after the expected number of frames, that two objects on a collision course trigger the collision callback, that a character standing on a platform does not fall through, and that a projectile launched at a specific angle lands at the expected distance. These tests require a headless physics simulation (no rendering), which is possible with libraries like Cannon-es, Rapier (WASM), or the physics module of your game engine.
Integration tests often require a simulated browser environment. jsdom provides a lightweight DOM implementation that runs in Node.js, supporting enough of the browser API for UI tests and event handling. For tests that require Canvas or WebGL, use a headless browser via Playwright or Puppeteer, which provides a real browser engine (Chromium, Firefox, or WebKit) running without a visible window.
End-to-End Testing with Playwright
End-to-end (E2E) tests drive the game through complete scenarios using browser automation. Playwright launches a real browser, navigates to the game URL, simulates keyboard and mouse input, and verifies the resulting game state. E2E tests are the closest automated approximation of a human playing the game.
A basic E2E test for a web game loads the game URL, waits for the loading screen to disappear (by polling for the absence of a loading indicator or waiting for a specific DOM element), simulates gameplay input (keyboard presses, mouse clicks, touch gestures), and verifies outcomes by reading game state exposed to the test harness. The verification step is the challenge: Playwright can read DOM elements and JavaScript variables, but it cannot directly inspect what is rendered on a canvas. The game must expose testable state through DOM elements (a score counter div), JavaScript globals (window.__gameState), or custom events.
Playwright supports Chromium, Firefox, and WebKit, making it valuable for cross-browser E2E testing. A single test script can run on all three browsers, verifying that the game works correctly on each. The WebKit support is especially valuable because it provides Safari-like behavior without requiring a macOS machine, catching Safari-specific bugs in CI environments that run on Linux.
E2E tests are slow (seconds per test) and brittle (they break when the game's visual layout or timing changes). Use them sparingly for critical paths: game loads successfully, core gameplay loop works (move, jump, collect, score), save/load round-trips correctly, multiplayer connection establishes, and purchase/monetization flows complete. Do not try to test every feature with E2E tests; the maintenance cost is too high.
Continuous Integration for Game Projects
Continuous integration (CI) runs your test suite automatically on every code push. When a developer pushes code that breaks a test, CI reports the failure before the code merges into the main branch. This keeps the main branch always working, which means any developer can pull the latest code and expect it to build and run correctly.
A CI pipeline for a web game project typically runs in this order: install dependencies (npm install), lint the code (ESLint checks for common errors and style violations), run unit tests (fast, high signal, catch logic bugs), run integration tests (medium speed, catch system interaction bugs), build the game bundle (webpack, vite, or rollup), optionally run E2E tests on a headless browser (slow, catch full-stack bugs), and optionally deploy to a staging environment for manual testing.
GitHub Actions is the most common CI service for open-source and small-team game projects. A workflow file in .github/workflows/test.yml defines the pipeline. The workflow triggers on push and pull request events, runs on a Linux virtual machine (ubuntu-latest), checks out the code, installs Node.js, installs dependencies, and runs the test commands. For E2E tests that require a browser, Playwright provides a GitHub Actions setup command that installs browser binaries automatically.
Performance regression detection in CI compares benchmark results against a baseline. Run a standardized benchmark (a specific game scene rendered for 1000 frames) and record the average frame time. If the frame time increases by more than a threshold (such as 10%) compared to the previous successful build, fail the CI pipeline. This catches performance regressions before they reach players. Store benchmark results as CI artifacts so you can graph performance trends over time.
Test Architecture for Games
The most testable game code separates logic from rendering. Game logic (physics, AI, economy, scoring, state management) should be pure functions or classes that take input data and produce output data without touching the DOM, canvas, or browser APIs. Rendering code takes the output of game logic and draws it. This separation is often called the "logic/view split" or "model-view" pattern.
A damage system designed for testability looks like this: a pure function takes attacker stats, defender stats, and optional modifiers, and returns a damage result object (damage dealt, was critical, was blocked, remaining health). This function can be unit tested exhaustively. A separate rendering function takes the damage result and plays the appropriate animation, particle effect, and sound. The rendering function is harder to test automatically but rarely contains logic bugs.
Dependency injection makes integration testing practical. Instead of game systems creating their own dependencies (a physics system that directly instantiates a collision resolver), pass dependencies in from outside (the physics system receives a collision resolver as a constructor parameter). During testing, you can inject mock or stub dependencies that return controlled values, isolating the system under test from its collaborators. This eliminates the need to set up a full game engine just to test one system.
Test fixtures provide reusable game states for tests. A fixture might be "a level with the player at position (100, 200), three enemies at known positions, and a full inventory." Instead of rebuilding this state in every test, create a factory function that produces it. Tests that need a specific starting state call the factory, modify the parts relevant to their specific scenario, and run their assertions. This reduces test setup boilerplate and makes tests more readable.
Separate game logic from rendering, test the logic with unit tests using Vitest or Jest, test system interactions with integration tests, use Playwright for critical-path E2E tests, and run everything in CI on every commit. This combination catches the majority of bugs automatically while keeping the test suite fast enough to run constantly.