game-qa
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseGame QA with Playwright
基于Playwright的游戏QA测试
You are an expert QA engineer for browser games. You use Playwright to write automated tests that verify visual correctness, gameplay behavior, performance, and accessibility.
你是一名浏览器游戏的资深QA工程师。你使用Playwright编写自动化测试,验证视觉正确性、玩法行为、性能及可访问性。
Reference Files
参考文件
For detailed reference, see companion files in this directory:
- — Screenshot comparison tests, masking dynamic elements, performance/FPS tests, accessibility tests, deterministic testing patterns
visual-regression.md - — Playwright Clock API patterns for frame-precise testing
clock-control.md - — MCP server setup, when to use MCP vs scripted tests, inspection flow
playwright-mcp.md - — Standalone iterate client usage, action JSON format, output interpretation
iterate-client.md - — Mobile input simulation and responsive layout test patterns
mobile-tests.md
如需详细参考,请查看此目录中的配套文件:
- — 截图对比测试、动态元素遮罩、性能/FPS测试、可访问性测试、确定性测试模式
visual-regression.md - — 用于帧精度测试的Playwright Clock API模式
clock-control.md - — MCP服务器设置、MCP与脚本化测试的适用场景、检查流程
playwright-mcp.md - — 独立迭代客户端的使用、动作JSON格式、输出解读
iterate-client.md - — 移动端输入模拟与响应式布局测试模式
mobile-tests.md
Tech Stack
技术栈
- Test Runner: Playwright Test ()
@playwright/test - Visual Regression: Playwright built-in
toHaveScreenshot() - Accessibility:
@axe-core/playwright - Build Tool Integration: Vite dev server via config
webServer - Language: JavaScript ES modules
- 测试运行器: Playwright Test ()
@playwright/test - 视觉回归: Playwright内置的
toHaveScreenshot() - 可访问性:
@axe-core/playwright - 构建工具集成: 通过配置集成Vite开发服务器
webServer - 语言: JavaScript ES模块
Project Setup
项目设置
When adding Playwright to a game project:
bash
npm install -D @playwright/test @axe-core/playwright
npx playwright install chromiumAdd to scripts:
package.jsonjson
{
"scripts": {
"test": "npx playwright test",
"test:ui": "npx playwright test --ui",
"test:headed": "npx playwright test --headed",
"test:update-snapshots": "npx playwright test --update-snapshots"
}
}当在游戏项目中添加Playwright时:
bash
npm install -D @playwright/test @axe-core/playwright
npx playwright install chromium在的scripts中添加以下内容:
package.jsonjson
{
"scripts": {
"test": "npx playwright test",
"test:ui": "npx playwright test --ui",
"test:headed": "npx playwright test --headed",
"test:update-snapshots": "npx playwright test --update-snapshots"
}
}Required Directory Structure
必需的目录结构
tests/
├── e2e/
│ ├── game.spec.js # Core game tests (boot, scenes, input, score)
│ ├── visual.spec.js # Visual regression screenshots
│ └── perf.spec.js # Performance and FPS tests
├── fixtures/
│ ├── game-test.js # Custom test fixture with game helpers
│ └── screenshot.css # CSS to mask dynamic elements for visual tests
├── helpers/
│ └── seed-random.js # Seeded PRNG for deterministic game behavior
playwright.config.jstests/
├── e2e/
│ ├── game.spec.js # 核心游戏测试(启动、场景、输入、得分)
│ ├── visual.spec.js # 视觉回归截图测试
│ └── perf.spec.js # 性能与FPS测试
├── fixtures/
│ ├── game-test.js # 带有游戏辅助函数的自定义测试夹具
│ └── screenshot.css # 用于视觉测试的动态元素遮罩CSS
├── helpers/
│ └── seed-random.js # 用于确定性游戏行为的种子式伪随机数生成器
playwright.config.jsPlaywright Config
Playwright配置
js
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
expect: {
toHaveScreenshot: {
maxDiffPixels: 200,
threshold: 0.3,
},
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
});Key points:
- auto-starts Vite before tests
webServer - reuses a running dev server locally
reuseExistingServer - matches the Vite port configured in
baseURLvite.config.js - Screenshot tolerance is generous (games have minor render variance)
js
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
expect: {
toHaveScreenshot: {
maxDiffPixels: 200,
threshold: 0.3,
},
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
});关键要点:
- 会在测试前自动启动Vite
webServer - 在本地环境会复用已运行的开发服务器
reuseExistingServer - 与
baseURL中配置的Vite端口一致vite.config.js - 截图容错设置较为宽松(游戏存在轻微的渲染差异)
Testability Requirements
可测试性要求
For Playwright to inspect game state, the game MUST expose these globals on in :
windowmain.js为了让Playwright能够检查游戏状态,游戏必须在的对象上暴露以下全局变量:
main.jswindow1. Core globals (required)
1. 核心全局变量(必需)
js
// Expose for Playwright QA
window.__GAME__ = game;
window.__GAME_STATE__ = gameState;
window.__EVENT_BUS__ = eventBus;
window.__EVENTS__ = Events;js
// Expose for Playwright QA
window.__GAME__ = game;
window.__GAME_STATE__ = gameState;
window.__EVENT_BUS__ = eventBus;
window.__EVENTS__ = Events;2. render_game_to_text()
(required)
render_game_to_text()2. render_game_to_text()
(必需)
render_game_to_text()Returns a concise JSON string of the current game state for AI agents to reason about the game without interpreting pixels. Must include coordinate system, game mode, score, and player state.
js
window.render_game_to_text = () => {
if (!game || !gameState) return JSON.stringify({ error: 'not_ready' });
const activeScenes = game.scene.getScenes(true).map(s => s.scene.key);
const payload = {
coords: 'origin:top-left x:right y:down', // coordinate system
mode: gameState.gameOver ? 'game_over' : 'playing',
scene: activeScenes[0] || null,
score: gameState.score,
bestScore: gameState.bestScore,
};
// Add player info when in gameplay
const gameScene = game.scene.getScene('GameScene');
if (gameState.started && gameScene?.player?.sprite) {
const s = gameScene.player.sprite;
const body = s.body;
payload.player = {
x: Math.round(s.x), y: Math.round(s.y),
vx: Math.round(body.velocity.x), vy: Math.round(body.velocity.y),
onGround: body.blocked.down,
};
}
// Extend with visible entities as you add them:
// payload.entities = obstacles.map(o => ({ x: o.x, y: o.y, type: o.type }));
return JSON.stringify(payload);
};Guidelines for :
render_game_to_text()- Keep the payload succinct — only current, visible, interactive elements
- Include coordinate system note (origin and axis directions)
- Include player position/velocity, active obstacles/enemies, collectibles, timers, score, and mode flags
- Avoid large histories; only include what's currently relevant
- The iterate client and AI agents use this to verify game behavior without screenshots
返回当前游戏状态的简洁JSON字符串,供AI Agent无需解析像素即可推理游戏状态。必须包含坐标系、游戏模式、得分及玩家状态。
js
window.render_game_to_text = () => {
if (!game || !gameState) return JSON.stringify({ error: 'not_ready' });
const activeScenes = game.scene.getScenes(true).map(s => s.scene.key);
const payload = {
coords: 'origin:top-left x:right y:down', // coordinate system
mode: gameState.gameOver ? 'game_over' : 'playing',
scene: activeScenes[0] || null,
score: gameState.score,
bestScore: gameState.bestScore,
};
// Add player info when in gameplay
const gameScene = game.scene.getScene('GameScene');
if (gameState.started && gameScene?.player?.sprite) {
const s = gameScene.player.sprite;
const body = s.body;
payload.player = {
x: Math.round(s.x), y: Math.round(s.y),
vx: Math.round(body.velocity.x), vy: Math.round(body.velocity.y),
onGround: body.blocked.down,
};
}
// Extend with visible entities as you add them:
// payload.entities = obstacles.map(o => ({ x: o.x, y: o.y, type: o.type }));
return JSON.stringify(payload);
};render_game_to_text()- 保持负载简洁——仅包含当前可见的交互元素
- 包含坐标系说明(原点及坐标轴方向)
- 包含玩家位置/速度、活跃障碍物/敌人、可收集物品、计时器、得分及模式标记
- 避免大量历史数据;仅包含当前相关的信息
- 迭代客户端和AI Agent会使用此函数无需截图即可验证游戏行为
3. advanceTime(ms)
(required)
advanceTime(ms)3. advanceTime(ms)
(必需)
advanceTime(ms)Lets test scripts advance the game by a precise duration. The game loop runs normally via RAF; this waits for real time to elapse.
js
window.advanceTime = (ms) => {
return new Promise((resolve) => {
const start = performance.now();
function step() {
if (performance.now() - start >= ms) return resolve();
requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
};For frame-precise control in , prefer + . The hook is primarily used by the standalone iterate client ().
@playwright/testpage.clock.install()page.clock.runFor()advanceTimescripts/iterate-client.jsFor Three.js games, expose the orchestrator instance similarly.
Game允许测试脚本精确推进游戏指定时长。游戏循环通过RAF正常运行;此函数会等待真实时间流逝。
js
window.advanceTime = (ms) => {
return new Promise((resolve) => {
const start = performance.now();
function step() {
if (performance.now() - start >= ms) return resolve();
requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
};在中进行帧精度控制时,优先使用 + 。钩子主要用于独立迭代客户端()。
@playwright/testpage.clock.install()page.clock.runFor()advanceTimescripts/iterate-client.js对于Three.js游戏,需类似地暴露编排器实例。
GameCustom Test Fixture
自定义测试夹具
Create a reusable fixture with game-specific helpers:
js
import { test as base, expect } from '@playwright/test';
export const test = base.extend({
gamePage: async ({ page }, use) => {
await page.goto('/');
// Wait for Phaser to boot and canvas to render
await page.waitForFunction(() => {
const g = window.__GAME__;
return g && g.isBooted && g.canvas;
}, null, { timeout: 10000 });
await use(page);
},
});
export { expect };创建带有游戏特定辅助函数的可复用夹具:
js
import { test as base, expect } from '@playwright/test';
export const test = base.extend({
gamePage: async ({ page }, use) => {
await page.goto('/');
// Wait for Phaser to boot and canvas to render
await page.waitForFunction(() => {
const g = window.__GAME__;
return g && g.isBooted && g.canvas;
}, null, { timeout: 10000 });
await use(page);
},
});
export { expect };Core Testing Patterns
核心测试模式
1. Game Boot & Scene Flow
1. 游戏启动与场景流程
Test that the game initializes and scenes transition correctly.
js
import { test, expect } from '../fixtures/game-test.js';
test('game boots directly to gameplay', async ({ gamePage }) => {
const sceneKey = await gamePage.evaluate(() => {
return window.__GAME__.scene.getScenes(true)[0]?.scene?.key;
});
expect(sceneKey).toBe('GameScene');
});测试游戏是否能正确初始化及场景是否能正确切换。
js
import { test, expect } from '../fixtures/game-test.js';
test('game boots directly to gameplay', async ({ gamePage }) => {
const sceneKey = await gamePage.evaluate(() => {
return window.__GAME__.scene.getScenes(true)[0]?.scene?.key;
});
expect(sceneKey).toBe('GameScene');
});2. Gameplay Verification
2. 玩法验证
Test that game mechanics work — input affects state, scoring works, game over triggers.
js
test('bird flaps on space press', async ({ gamePage }) => {
// Start game
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Record position before flap
const yBefore = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameScene');
return scene.bird.y;
});
// Flap
await gamePage.keyboard.press('Space');
await gamePage.waitForTimeout(100);
// Bird should have moved up (lower y)
const yAfter = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameScene');
return scene.bird.y;
});
expect(yAfter).toBeLessThan(yBefore);
});
test('game over triggers on collision', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Don't flap — let bird fall to ground
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 10000 }
);
expect(await gamePage.evaluate(() => window.__GAME_STATE__.gameOver)).toBe(true);
});测试游戏机制是否正常工作——输入是否影响状态、得分是否正常、游戏结束是否触发。
js
test('bird flaps on space press', async ({ gamePage }) => {
// Start game
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Record position before flap
const yBefore = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameScene');
return scene.bird.y;
});
// Flap
await gamePage.keyboard.press('Space');
await gamePage.waitForTimeout(100);
// Bird should have moved up (lower y)
const yAfter = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameScene');
return scene.bird.y;
});
expect(yAfter).toBeLessThan(yBefore);
});
test('game over triggers on collision', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Don't flap — let bird fall to ground
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 10000 }
);
expect(await gamePage.evaluate(() => window.__GAME_STATE__.gameOver)).toBe(true);
});3. Scoring
3. 得分测试
js
test('score increments when passing pipes', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Keep flapping to survive
const flapInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 300);
// Wait for at least 1 score
await gamePage.waitForFunction(
() => window.__GAME_STATE__.score > 0,
null,
{ timeout: 15000 }
);
clearInterval(flapInterval);
const score = await gamePage.evaluate(() => window.__GAME_STATE__.score);
expect(score).toBeGreaterThan(0);
});js
test('score increments when passing pipes', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Keep flapping to survive
const flapInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 300);
// Wait for at least 1 score
await gamePage.waitForFunction(
() => window.__GAME_STATE__.score > 0,
null,
{ timeout: 15000 }
);
clearInterval(flapInterval);
const score = await gamePage.evaluate(() => window.__GAME_STATE__.score);
expect(score).toBeGreaterThan(0);
});Core Gameplay Invariants
核心玩法不变量
Every game built through the pipeline must pass these minimum gameplay checks. These verify the game is actually playable, not just renders without errors.
通过此流水线构建的每个游戏必须通过以下最低限度的玩法检查。这些检查用于验证游戏实际可玩,而不仅仅是能无错误渲染。
1. Scoring works
1. 得分功能正常
The player must be able to earn at least 1 point through normal gameplay actions:
js
test('player can score at least 1 point', async ({ gamePage }) => {
// Start the game (space/tap)
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
// Perform gameplay actions — keep the player alive
const actionInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 400);
// Wait for score > 0
await gamePage.waitForFunction(
() => window.__GAME_STATE__.score > 0,
null,
{ timeout: 20000 }
);
clearInterval(actionInterval);
const score = await gamePage.evaluate(() => window.__GAME_STATE__.score);
expect(score).toBeGreaterThan(0);
});玩家必须能够通过正常玩法操作获得至少1分:
js
test('player can score at least 1 point', async ({ gamePage }) => {
// Start the game (space/tap)
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
// Perform gameplay actions — keep the player alive
const actionInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 400);
// Wait for score > 0
await gamePage.waitForFunction(
() => window.__GAME_STATE__.score > 0,
null,
{ timeout: 20000 }
);
clearInterval(actionInterval);
const score = await gamePage.evaluate(() => window.__GAME_STATE__.score);
expect(score).toBeGreaterThan(0);
});2. Death/fail condition triggers
2. 死亡/失败条件可触发
The player must be able to die or lose through inaction or collision:
js
test('game over triggers through normal gameplay', async ({ gamePage }) => {
// Start the game
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
// Do nothing — let the fail condition trigger naturally (fall, timer, collision)
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver === true,
null,
{ timeout: 15000 }
);
const isOver = await gamePage.evaluate(() => window.__GAME_STATE__.gameOver);
expect(isOver).toBe(true);
});玩家必须能够通过不作为或碰撞导致死亡或失败:
js
test('game over triggers through normal gameplay', async ({ gamePage }) => {
// Start the game
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
// Do nothing — let the fail condition trigger naturally (fall, timer, collision)
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver === true,
null,
{ timeout: 15000 }
);
const isOver = await gamePage.evaluate(() => window.__GAME_STATE__.gameOver);
expect(isOver).toBe(true);
});3. Game-over buttons have visible text
3. 游戏结束按钮显示可见文本
After game over, restart/play-again buttons must show their text labels:
js
test('game over buttons display text labels', async ({ gamePage }) => {
// Trigger game over
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
await gamePage.waitForFunction(() => window.__GAME_STATE__.gameOver, null, { timeout: 15000 });
// Wait for GameOverScene to render
await gamePage.waitForFunction(() => {
const scenes = window.__GAME__.scene.getScenes(true);
return scenes.some(s => s.scene.key === 'GameOverScene');
}, null, { timeout: 5000 });
await gamePage.waitForTimeout(500);
// Check that text objects exist and are visible in the scene
const hasVisibleText = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameOverScene');
if (!scene) return false;
const textObjects = scene.children.list.filter(
child => child.type === 'Text' && child.visible && child.alpha > 0
);
// Should have at least: title ("GAME OVER"), score, and button label ("PLAY AGAIN")
return textObjects.length >= 3;
});
expect(hasVisibleText).toBe(true);
});游戏结束后,重新开始/再玩一次按钮必须显示文本标签:
js
test('game over buttons display text labels', async ({ gamePage }) => {
// Trigger game over
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
await gamePage.waitForFunction(() => window.__GAME_STATE__.gameOver, null, { timeout: 15000 });
// Wait for GameOverScene to render
await gamePage.waitForFunction(() => {
const scenes = window.__GAME__.scene.getScenes(true);
return scenes.some(s => s.scene.key === 'GameOverScene');
}, null, { timeout: 5000 });
await gamePage.waitForTimeout(500);
// Check that text objects exist and are visible in the scene
const hasVisibleText = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameOverScene');
if (!scene) return false;
const textObjects = scene.children.list.filter(
child => child.type === 'Text' && child.visible && child.alpha > 0
);
// Should have at least: title ("GAME OVER"), score, and button label ("PLAY AGAIN")
return textObjects.length >= 3;
});
expect(hasVisibleText).toBe(true);
});4. render_game_to_text()
returns valid state
render_game_to_text()4. render_game_to_text()
返回有效状态
render_game_to_text()The AI-readable state function must return parseable JSON with required fields:
js
test('render_game_to_text returns valid game state', async ({ gamePage }) => {
const stateStr = await gamePage.evaluate(() => window.render_game_to_text());
const state = JSON.parse(stateStr);
expect(state).toHaveProperty('mode');
expect(state).toHaveProperty('score');
expect(['playing', 'game_over']).toContain(state.mode);
expect(typeof state.score).toBe('number');
});供AI读取的状态函数必须返回可解析的JSON及必需字段:
js
test('render_game_to_text returns valid game state', async ({ gamePage }) => {
const stateStr = await gamePage.evaluate(() => window.render_game_to_text());
const state = JSON.parse(stateStr);
expect(state).toHaveProperty('mode');
expect(state).toHaveProperty('score');
expect(['playing', 'game_over']).toContain(state.mode);
expect(typeof state.score).toBe('number');
});5. Design Intent
5. 设计意图验证
Tests that catch mechanics which technically exist but are too weak to affect gameplay. These use values from to set meaningful thresholds instead of trivial checks.
Constants.js> 0Detecting win/lose state: Read for , , or similar boolean/enum fields. Check in for distinct outcome modes ( vs ). If either exists, the game has a lose state — write lose-condition tests.
GameState.jswonresultrender_game_to_text()main.js'win''game_over'Using design-brief.md: If exists in the project root, read it for expected magnitudes, rates, and win/lose reachability. Use these values to set test thresholds instead of deriving from Constants.js alone.
design-brief.mdNon-negotiable assertion: The no-input lose test must assert the losing outcome. Never write a passing test for a no-input win — if the player wins by doing nothing, that is a bug, and the test exists to catch it.
Lose condition — verify the player can actually lose:
js
test('player loses when providing no input', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 45000 }
);
const result = await gamePage.evaluate(() => window.__GAME_STATE__.result);
expect(result).toBe('lose');
});Opponent/AI pressure — verify AI mechanics produce substantial state changes:
js
test('opponent reaches 25% within half the round duration', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
const { halfDuration, maxValue } = await gamePage.evaluate(() => {
return {
halfDuration: window.Constants?.ROUND_DURATION_MS / 2 || 15000,
maxValue: window.Constants?.MAX_VALUATION || 100,
};
});
await gamePage.waitForTimeout(halfDuration);
const opponentValue = await gamePage.evaluate(() => {
return window.__GAME_STATE__.opponentScore;
});
expect(opponentValue).toBeGreaterThanOrEqual(maxValue * 0.25);
});Win condition — verify active input leads to a win:
js
test('player wins with active input', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
const inputInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 100);
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 45000 }
);
clearInterval(inputInterval);
const result = await gamePage.evaluate(() => window.__GAME_STATE__.result);
expect(result).toBe('win');
});Adapt field names (, , constant names) to match the specific game's GameState and Constants. The patterns above are templates — read the actual game code to determine the correct fields and thresholds.
resultopponentScore此类测试用于捕获技术上存在但对玩法影响过弱的机制。这些测试使用中的值设置有意义的阈值,而非简单的检查。
Constants.js> 0检测胜负状态:读取中的、或类似的布尔值/枚举字段。检查中的是否有不同的结果模式( vs )。如果存在其中任意一个,说明游戏有失败状态——请编写失败条件测试。
GameState.jswonresultmain.jsrender_game_to_text()'win''game_over'使用design-brief.md:如果项目根目录下存在,请阅读其中的预期量级、速率及胜负可达性。使用这些值设置测试阈值,而非仅从Constants.js中获取。
design-brief.md不可协商的断言:无输入失败测试必须断言失败结果。永远不要为无输入胜利编写通过测试——如果玩家无需操作即可获胜,这是一个bug,测试的存在就是为了捕获此类问题。
失败条件——验证玩家确实可能失败:
js
test('player loses when providing no input', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 45000 }
);
const result = await gamePage.evaluate(() => window.__GAME_STATE__.result);
expect(result).toBe('lose');
});对手/AI压力——验证AI机制会产生显著的状态变化:
js
test('opponent reaches 25% within half the round duration', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
const { halfDuration, maxValue } = await gamePage.evaluate(() => {
return {
halfDuration: window.Constants?.ROUND_DURATION_MS / 2 || 15000,
maxValue: window.Constants?.MAX_VALUATION || 100,
};
});
await gamePage.waitForTimeout(halfDuration);
const opponentValue = await gamePage.evaluate(() => {
return window.__GAME_STATE__.opponentScore;
});
expect(opponentValue).toBeGreaterThanOrEqual(maxValue * 0.25);
});胜利条件——验证主动输入可导致胜利:
js
test('player wins with active input', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
const inputInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 100);
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 45000 }
);
clearInterval(inputInterval);
const result = await gamePage.evaluate(() => window.__GAME_STATE__.result);
expect(result).toBe('win');
});根据具体游戏的GameState和Constants适配字段名称(、、常量名称)。以上模式为模板——请阅读实际游戏代码以确定正确的字段和阈值。
resultopponentScore6. Entity Interaction Audit
6. 实体交互审计
Audit collision and interaction logic for asymmetries. A first-time player
expects consistent rules: if visible objects interact with some entities, they
expect them to interact with all relevant entities.
What to check: Read all collision handlers in GameScene.js. Map
entity→entity interactions. Flag any visible moving entity that interacts with
one side but not the other.
Using design-brief.md: If an "Entity Interactions" section exists, verify
each documented interaction matches the code. Flag any entity documented as
"no player interaction" that isn't clearly background/decoration.
Output: Add comments in game.spec.js
for any flagged entity. This is informational — the flag surfaces the issue
for human review, it doesn't fail the test suite.
// QA FLAG: asymmetric interaction审计碰撞和交互逻辑的不对称性。首次游玩的玩家期望规则一致:如果可见对象与某些实体交互,他们会期望其与所有相关实体交互。
检查内容:读取GameScene.js中的所有碰撞处理函数。映射实体→实体的交互关系。标记任何可见的移动实体,如果它与一方交互但不与另一方交互。
使用design-brief.md:如果存在“实体交互”部分,请验证每个文档化的交互是否与代码一致。标记任何被记录为“无玩家交互”但并非明显背景/装饰的实体。
输出:在game.spec.js中为任何标记的实体添加注释。这仅为信息性标记——该标记用于将问题暴露给人工审查,不会导致测试套件失败。
// QA FLAG: asymmetric interaction7. Mute Button Exists and Toggles
7. 静音按钮存在且可切换
Every game with audio must have a mute toggle. Test that exists on GameState and responds to the M key shortcut:
isMutedjs
test('mute button exists and toggles audio state', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
const hasMuteState = await gamePage.evaluate(() => {
return typeof window.__GAME_STATE__.isMuted === 'boolean';
});
expect(hasMuteState).toBe(true);
const before = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
await gamePage.keyboard.press('m');
await gamePage.waitForTimeout(100);
const after = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
expect(after).toBe(!before);
await gamePage.keyboard.press('m');
await gamePage.waitForTimeout(100);
const restored = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
expect(restored).toBe(before);
});The M key is a testable proxy for the mute button — if the event wiring exists, the visual button does too. Playwright cannot inspect Phaser Graphics objects directly.
所有带有音频的游戏必须有静音切换按钮。测试中是否存在,以及是否响应M键快捷键:
GameStateisMutedjs
test('mute button exists and toggles audio state', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
const hasMuteState = await gamePage.evaluate(() => {
return typeof window.__GAME_STATE__.isMuted === 'boolean';
});
expect(hasMuteState).toBe(true);
const before = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
await gamePage.keyboard.press('m');
await gamePage.waitForTimeout(100);
const after = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
expect(after).toBe(!before);
await gamePage.keyboard.press('m');
await gamePage.waitForTimeout(100);
const restored = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
expect(restored).toBe(before);
});M键是静音按钮的可测试代理——如果事件绑定存在,说明视觉按钮也存在。Playwright无法直接检查Phaser图形对象。
When Adding QA to a Game
为游戏添加QA测试的步骤
- Install Playwright:
npm install -D @playwright/test @axe-core/playwright && npx playwright install chromium - Create with the game's dev server port
playwright.config.js - Expose ,
window.__GAME__,window.__GAME_STATE__inwindow.__EVENT_BUS__main.js - Create with the
tests/fixtures/game-test.jsfixturegamePage - Create for deterministic behavior
tests/helpers/seed-random.js - Write tests in :
tests/e2e/- — boot, scene flow, input, scoring, game over
game.spec.js - — screenshot regression for each scene
visual.spec.js - — load time, FPS budget
perf.spec.js
- Add npm scripts: ,
test,test:ui,test:headedtest:update-snapshots - Generate initial baselines:
npm run test:update-snapshots
- 安装Playwright:
npm install -D @playwright/test @axe-core/playwright && npx playwright install chromium - 创建,配置游戏的开发服务器端口
playwright.config.js - 在中暴露
main.js、window.__GAME__、window.__GAME_STATE__window.__EVENT_BUS__ - 创建,添加
tests/fixtures/game-test.js夹具gamePage - 创建,用于确定性行为
tests/helpers/seed-random.js - 在中编写测试:
tests/e2e/- — 启动、场景流程、输入、得分、游戏结束
game.spec.js - — 每个场景的截图回归测试
visual.spec.js - — 加载时间、FPS预算
perf.spec.js
- 添加npm脚本:、
test、test:ui、test:headedtest:update-snapshots - 生成初始基准:
npm run test:update-snapshots
What NOT to Test (Automated)
无需自动化测试的内容
- Exact pixel positions of animated objects (non-deterministic without clock control)
- Active gameplay screenshots — moving objects make stable screenshots impossible; use MCP instead
- Audio playback (Playwright has no audio inspection; test that audio objects exist via evaluate)
- External API calls unless mocked (e.g., Play.fun SDK — mock with )
page.route() - Subjective visual quality — use MCP for "does this look good?" evaluations
- 动画对象的精确像素位置(无时钟控制时具有不确定性)
- 活跃玩法截图——移动对象无法生成稳定截图;请使用MCP替代
- 音频播放(Playwright无法检查音频;通过evaluate测试音频对象是否存在)
- 外部API调用(除非已模拟,例如Play.fun SDK——使用模拟)
page.route() - 主观视觉质量——使用MCP进行“看起来是否良好?”的评估