game-qa

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Game 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:
  • visual-regression.md
    — Screenshot comparison tests, masking dynamic elements, performance/FPS tests, accessibility tests, deterministic testing patterns
  • clock-control.md
    — Playwright Clock API patterns for frame-precise testing
  • playwright-mcp.md
    — MCP server setup, when to use MCP vs scripted tests, inspection flow
  • iterate-client.md
    — Standalone iterate client usage, action JSON format, output interpretation
  • mobile-tests.md
    — Mobile input simulation and responsive layout test patterns
如需详细参考,请查看此目录中的配套文件:
  • visual-regression.md
    — 截图对比测试、动态元素遮罩、性能/FPS测试、可访问性测试、确定性测试模式
  • clock-control.md
    — 用于帧精度测试的Playwright Clock API模式
  • playwright-mcp.md
    — MCP服务器设置、MCP与脚本化测试的适用场景、检查流程
  • iterate-client.md
    — 独立迭代客户端的使用、动作JSON格式、输出解读
  • 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
    webServer
    config
  • Language: JavaScript ES modules
  • 测试运行器: Playwright Test (
    @playwright/test
    )
  • 视觉回归: Playwright内置的
    toHaveScreenshot()
  • 可访问性:
    @axe-core/playwright
  • 构建工具集成: 通过
    webServer
    配置集成Vite开发服务器
  • 语言: JavaScript ES模块

Project Setup

项目设置

When adding Playwright to a game project:
bash
npm install -D @playwright/test @axe-core/playwright
npx playwright install chromium
Add to
package.json
scripts:
json
{
  "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
package.json
的scripts中添加以下内容:
json
{
  "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.js
tests/
├── e2e/
│   ├── game.spec.js       # 核心游戏测试(启动、场景、输入、得分)
│   ├── visual.spec.js     # 视觉回归截图测试
│   └── perf.spec.js       # 性能与FPS测试
├── fixtures/
│   ├── game-test.js       # 带有游戏辅助函数的自定义测试夹具
│   └── screenshot.css     # 用于视觉测试的动态元素遮罩CSS
├── helpers/
│   └── seed-random.js     # 用于确定性游戏行为的种子式伪随机数生成器
playwright.config.js

Playwright 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:
  • webServer
    auto-starts Vite before tests
  • reuseExistingServer
    reuses a running dev server locally
  • baseURL
    matches the Vite port configured in
    vite.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,
  },
});
关键要点:
  • webServer
    会在测试前自动启动Vite
  • reuseExistingServer
    在本地环境会复用已运行的开发服务器
  • baseURL
    vite.config.js
    中配置的Vite端口一致
  • 截图容错设置较为宽松(游戏存在轻微的渲染差异)

Testability Requirements

可测试性要求

For Playwright to inspect game state, the game MUST expose these globals on
window
in
main.js
:
为了让Playwright能够检查游戏状态,游戏必须在
main.js
window
对象上暴露以下全局变量:

1. 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)

2.
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)

3.
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
@playwright/test
, prefer
page.clock.install()
+
page.clock.runFor()
. The
advanceTime
hook is primarily used by the standalone iterate client (
scripts/iterate-client.js
).
For Three.js games, expose the
Game
orchestrator instance similarly.
允许测试脚本精确推进游戏指定时长。游戏循环通过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/test
中进行帧精度控制时,优先使用
page.clock.install()
+
page.clock.runFor()
advanceTime
钩子主要用于独立迭代客户端(
scripts/iterate-client.js
)。
对于Three.js游戏,需类似地暴露
Game
编排器实例。

Custom 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

4.
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
Constants.js
to set meaningful thresholds instead of trivial
> 0
checks.
Detecting win/lose state: Read
GameState.js
for
won
,
result
, or similar boolean/enum fields. Check
render_game_to_text()
in
main.js
for distinct outcome modes (
'win'
vs
'game_over'
). If either exists, the game has a lose state — write lose-condition tests.
Using design-brief.md: If
design-brief.md
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.
Non-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 (
result
,
opponentScore
, 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.
此类测试用于捕获技术上存在但对玩法影响过弱的机制。这些测试使用
Constants.js
中的值设置有意义的阈值,而非简单的
> 0
检查。
检测胜负状态:读取
GameState.js
中的
won
result
或类似的布尔值/枚举字段。检查
main.js
中的
render_game_to_text()
是否有不同的结果模式(
'win'
vs
'game_over'
)。如果存在其中任意一个,说明游戏有失败状态——请编写失败条件测试。
使用design-brief.md:如果项目根目录下存在
design-brief.md
,请阅读其中的预期量级、速率及胜负可达性。使用这些值设置测试阈值,而非仅从Constants.js中获取。
不可协商的断言:无输入失败测试必须断言失败结果。永远不要为无输入胜利编写通过测试——如果玩家无需操作即可获胜,这是一个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适配字段名称(
result
opponentScore
、常量名称)。以上模式为模板——请阅读实际游戏代码以确定正确的字段和阈值。

6. 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
// QA FLAG: asymmetric interaction
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.
审计碰撞和交互逻辑的不对称性。首次游玩的玩家期望规则一致:如果可见对象与某些实体交互,他们会期望其与所有相关实体交互。
检查内容:读取GameScene.js中的所有碰撞处理函数。映射实体→实体的交互关系。标记任何可见的移动实体,如果它与一方交互但不与另一方交互。
使用design-brief.md:如果存在“实体交互”部分,请验证每个文档化的交互是否与代码一致。标记任何被记录为“无玩家交互”但并非明显背景/装饰的实体。
输出:在game.spec.js中为任何标记的实体添加
// QA FLAG: asymmetric interaction
注释。这仅为信息性标记——该标记用于将问题暴露给人工审查,不会导致测试套件失败。

7. Mute Button Exists and Toggles

7. 静音按钮存在且可切换

Every game with audio must have a mute toggle. Test that
isMuted
exists on GameState and responds to the M key shortcut:
js
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.
所有带有音频的游戏必须有静音切换按钮。测试
GameState
中是否存在
isMuted
,以及是否响应M键快捷键:
js
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测试的步骤

  1. Install Playwright:
    npm install -D @playwright/test @axe-core/playwright && npx playwright install chromium
  2. Create
    playwright.config.js
    with the game's dev server port
  3. Expose
    window.__GAME__
    ,
    window.__GAME_STATE__
    ,
    window.__EVENT_BUS__
    in
    main.js
  4. Create
    tests/fixtures/game-test.js
    with the
    gamePage
    fixture
  5. Create
    tests/helpers/seed-random.js
    for deterministic behavior
  6. Write tests in
    tests/e2e/
    :
    • game.spec.js
      — boot, scene flow, input, scoring, game over
    • visual.spec.js
      — screenshot regression for each scene
    • perf.spec.js
      — load time, FPS budget
  7. Add npm scripts:
    test
    ,
    test:ui
    ,
    test:headed
    ,
    test:update-snapshots
  8. Generate initial baselines:
    npm run test:update-snapshots
  1. 安装Playwright:
    npm install -D @playwright/test @axe-core/playwright && npx playwright install chromium
  2. 创建
    playwright.config.js
    ,配置游戏的开发服务器端口
  3. main.js
    中暴露
    window.__GAME__
    window.__GAME_STATE__
    window.__EVENT_BUS__
  4. 创建
    tests/fixtures/game-test.js
    ,添加
    gamePage
    夹具
  5. 创建
    tests/helpers/seed-random.js
    ,用于确定性行为
  6. tests/e2e/
    中编写测试:
    • game.spec.js
      — 启动、场景流程、输入、得分、游戏结束
    • visual.spec.js
      — 每个场景的截图回归测试
    • perf.spec.js
      — 加载时间、FPS预算
  7. 添加npm脚本:
    test
    test:ui
    test:headed
    test:update-snapshots
  8. 生成初始基准:
    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进行“看起来是否良好?”的评估