主题切换
游戏测试专题
预计阅读 25 分钟
版本信息
最后更新:2026-07-09 · 适用基础库:≥ 3.9.0(最低兼容 ≥ 2.30.0)
游戏测试往往被独立开发者忽视,但它是保证产品质量和用户体验的关键环节。本章讲解如何将前端测试经验迁移到游戏开发中。
前端测试 vs 游戏测试
前端对比:Web 测试 vs 游戏测试
| 维度 | Web 前端测试 | 游戏测试 |
|---|---|---|
| 单元测试对象 | 组件逻辑、工具函数 | 游戏逻辑(碰撞检测、计分、状态转换) |
| 组件/实体测试 | React Testing Library、Vue Test Utils | 引擎组件测试(Cocos/Unity) |
| E2E 测试 | Playwright、Cypress | minium(微信官方)、AutoTest |
| 视觉回归 | Percy、Chromatic 截图对比 | Canvas/WebGL 截图像素级对比 |
| 性能测试 | Lighthouse CI | 帧率稳定性、内存泄漏检测、首帧耗时 |
| 测试难点 | 异步状态、DOM 渲染 | 帧依赖逻辑、物理引擎随机性、真机兼容性 |
| 覆盖率目标 | 80%+(行覆盖) | 核心玩法 90%+,UI/渲染层选择性覆盖 |
关键差异: Web 应用测试的核心是"给定 Props,验证 DOM 输出";游戏测试的核心是"给定输入序列和初始状态,验证多帧后的状态和渲染结果"。游戏的时序依赖和物理随机性使得传统的快照测试难以直接套用。
单元测试:核心游戏逻辑
测试环境搭建
typescript
// 使用 Jest + ts-jest 测试游戏核心逻辑
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node', // 游戏逻辑不依赖 DOM
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
moduleNameMapper: {
// 对引擎 API 做 mock
'^cc$': '<rootDir>/tests/__mocks__/cc.ts',
},
}碰撞检测测试
typescript
// tests/collision.test.ts
import { rectCollision, circleCollision } from '../src/utils'
describe('碰撞检测', () => {
describe('矩形碰撞 (AABB)', () => {
test('两个矩形完全重叠时返回 true', () => {
expect(rectCollision(0, 0, 10, 10, 5, 5, 10, 10)).toBe(true)
})
test('两个矩形相邻但不重叠时返回 false', () => {
// rect1: (0,0,10,10), rect2: (10,0,10,10) — 边界相邻
expect(rectCollision(0, 0, 10, 10, 10, 0, 10, 10)).toBe(false)
})
test('一个矩形完全在另一个内部时返回 true', () => {
expect(rectCollision(0, 0, 20, 20, 5, 5, 5, 5)).toBe(true)
})
test('两个矩形距离很远时返回 false', () => {
expect(rectCollision(0, 0, 10, 10, 100, 100, 10, 10)).toBe(false)
})
test('同一条边对齐且有部分重叠时返回 true', () => {
expect(rectCollision(0, 0, 10, 10, 5, 0, 15, 10)).toBe(true)
})
})
describe('圆形碰撞', () => {
test('两个圆重叠时返回 true', () => {
expect(circleCollision(0, 0, 10, 15, 0, 10)).toBe(true) // 圆心距 15 < 半径和 20
})
test('两个圆相切时返回 true', () => {
expect(circleCollision(0, 0, 10, 20, 0, 10)).toBe(true) // 圆心距 20 == 半径和 20
})
test('两个圆不重叠时返回 false', () => {
expect(circleCollision(0, 0, 10, 30, 0, 10)).toBe(false) // 圆心距 30 > 半径和 20
})
})
})游戏状态机测试
typescript
// tests/game-state.test.ts
enum GameState {
Ready,
Playing,
Paused,
GameOver,
}
class GameStateMachine {
private state = GameState.Ready
// 允许的状态转换表
private readonly transitions: Record<GameState, GameState[]> = {
[GameState.Ready]: [GameState.Playing],
[GameState.Playing]: [GameState.Paused, GameState.GameOver],
[GameState.Paused]: [GameState.Playing, GameState.Ready],
[GameState.GameOver]: [GameState.Ready],
}
get current() {
return this.state
}
transition(to: GameState): boolean {
if (this.transitions[this.state].includes(to)) {
this.state = to
return true
}
return false
}
}
describe('游戏状态机', () => {
let fsm: GameStateMachine
beforeEach(() => {
fsm = new GameStateMachine()
})
test('初始状态为 Ready', () => {
expect(fsm.current).toBe(GameState.Ready)
})
test('Ready → Playing 是合法转换', () => {
expect(fsm.transition(GameState.Playing)).toBe(true)
expect(fsm.current).toBe(GameState.Playing)
})
test('Playing → GameOver 是合法转换', () => {
fsm.transition(GameState.Playing)
expect(fsm.transition(GameState.GameOver)).toBe(true)
expect(fsm.current).toBe(GameState.GameOver)
})
test('GameOver → Ready 是合法转换(重新开始)', () => {
fsm.transition(GameState.Playing)
fsm.transition(GameState.GameOver)
expect(fsm.transition(GameState.Ready)).toBe(true)
})
test('Ready → GameOver 是非法转换', () => {
expect(fsm.transition(GameState.GameOver)).toBe(false)
expect(fsm.current).toBe(GameState.Ready) // 状态未变
})
test('Playing → Ready 是非法转换(必须经过 GameOver)', () => {
fsm.transition(GameState.Playing)
expect(fsm.transition(GameState.Ready)).toBe(false)
expect(fsm.current).toBe(GameState.Playing)
})
})计分逻辑测试
typescript
// tests/scoring.test.ts
describe('计分系统', () => {
// 测试 Flappy Bird 的通过管道计分逻辑
test('小鸟通过管道中心线时加 1 分', () => {
const birdX = 120
const pipes = [
{ x: 100, width: 60, passed: false },
{ x: 200, width: 60, passed: false },
]
let score = 0
for (const pipe of pipes) {
if (!pipe.passed && birdX > pipe.x + pipe.width) {
pipe.passed = true
score++
}
}
expect(score).toBe(1) // 只通过了第一根管道
expect(pipes[0].passed).toBe(true)
expect(pipes[1].passed).toBe(false)
})
test('同一根管道不会重复计分', () => {
const birdX = 120
const pipe = { x: 50, width: 60, passed: true } // 已通过
let score = 0
if (!pipe.passed && birdX > pipe.x + pipe.width) {
pipe.passed = true
score++
}
expect(score).toBe(0) // 不应重复计分
})
})用 AI 辅助生成测试用例
对于边界条件复杂的函数,可以让 AI 先生成测试骨架,再人工补充遗漏场景:
markdown
请为以下 TypeScript 函数生成 Jest 单元测试,覆盖正常输入、边界值和异常输入:
```typescript
function calculateScore(combo: number, timeBonus: number): number {
if (combo < 0 || timeBonus < 0) return 0
return Math.floor(combo * 100 * (1 + timeBonus / 10))
}
```
AI 通常能覆盖:
- 正常输入(combo=5, timeBonus=10)
- 边界值(combo=0, timeBonus=0)
- 异常输入(负数、极大值)
你需要人工补充的往往是:
- 业务特有的边界(如 combo 上限、timeBonus 精度)
- 与微信小游戏环境相关的测试(如弱网、异常回调)
- 针对历史 Bug 的回归测试
<WmgCalloutBox type="best-practice" title="AI 辅助测试的原则">
1. **让 AI 写骨架,人写业务边界**:AI 不擅长发现你的业务漏洞
2. **核心循环优先覆盖**:得分、碰撞、状态转换比 UI 动画更重要
3. **保留人工审查**:AI 生成的测试可能包含过时的 API 或错误的断言
4. **与 CI 集成**:每次提交自动运行核心逻辑单元测试
</WmgCalloutBox>
---
## 组件测试:Cocos Creator
Cocos Creator 的组件测试需要在引擎运行时环境中执行。推荐使用 Cocos Creator 的测试框架或通过场景快照进行验证。
```typescript
// tests/BirdController.test.ts
// 注意:此测试需要在 Cocos Creator 编辑器环境中运行
import { BirdController } from '../assets/scripts/BirdController'
import { Node, RigidBody2D } from 'cc'
describe('BirdController', () => {
let birdNode: Node
let controller: BirdController
beforeEach(() => {
// 创建测试节点
birdNode = new Node('Bird')
birdNode.addComponent(RigidBody2D)
controller = birdNode.addComponent(BirdController)
})
test('点击屏幕时小鸟向上施加力', () => {
const rigid = birdNode.getComponent(RigidBody2D)!
const initialVelY = rigid.linearVelocity.y
// 模拟触摸事件
controller.onTouch()
// 验证向上的力被施加
expect(rigid.linearVelocity.y).toBeGreaterThan(initialVelY)
})
test('小鸟死亡状态下点击不生效', () => {
// 设置死亡状态
;(controller as any).isDead = true
const rigid = birdNode.getComponent(RigidBody2D)!
const initialVelY = rigid.linearVelocity.y
controller.onTouch()
// 死亡后不应响应点击
expect(rigid.linearVelocity.y).toBe(initialVelY)
})
})E2E 测试:微信小游戏自动化
微信小游戏的 E2E 测试主要在真机上进行,使用微信官方或第三方自动化框架。
框架选择
- minium(Python):微信早期官方自动化框架,功能完善但维护频率降低。
- miniprogram-automator(Node.js):微信官方更新的自动化方案,对 JS/TS 开发者更友好,推荐新项目优先考虑。
两者原理相似,本章以 minium 为例讲解核心概念,Node.js 版本使用方法可参考 miniprogram-automator 文档。
minium(微信官方)集成
python
# tests/e2e/test_game_basic.py
import minium
class TestGameBasic(minium.MiniTest):
def test_game_launch(self):
"""测试游戏正常启动"""
# 等待游戏加载完成
self.app.wait_for(5)
# 获取当前页面信息
page = self.app.get_current_page()
self.assertIsNotNone(page)
# 验证 Canvas 元素存在(小游戏的核心渲染层)
canvas = page.get_element('canvas')
self.assertIsNotNone(canvas)
def test_tap_to_start(self):
"""测试点击开始游戏"""
# 模拟触摸事件
self.app.tap(200, 400)
# 等待游戏状态切换
self.app.wait_for(2)
# 验证计分 UI 出现(游戏已开始)
# 通过截图或元素检测
def test_game_over_scenario(self):
"""测试完整游戏流程:开始 → 碰撞 → 结束"""
self.app.tap(200, 400) # 开始游戏
# 等待管道出现并进行碰撞
self.app.wait_for(10)
# 验证 Game Over 界面出现
# 通过截图像素检测或性能数据变化
def test_ad_revival(self):
"""测试广告复活流程"""
self.app.tap(200, 400) # 开始
self.app.wait_for(10) # 等待碰撞
# 检查复活按钮是否出现
# 模拟点击复活按钮
# 验证激励视频广告触发自动化测试流水线
yaml
# .github/workflows/e2e-test.yml
name: E2E Tests
on:
pull_request:
branches: [main]
jobs:
e2e:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install minium
run: pip install minium
- name: Run E2E tests
run: |
minitest -m tests/e2e -c config.json视觉回归测试
游戏的视觉回归测试通过截图对比确保渲染输出正确:
typescript
// tests/visual/visual-regression.test.ts
import { compareScreenshots } from './helpers/pixelmatch-helper'
describe('视觉回归测试', () => {
test('游戏主界面渲染一致性', async () => {
// 1. 截取当前版本的屏幕截图
const currentScreenshot = await captureGameScreen()
// 2. 加载基准截图(baseline)
const baselinePath = 'tests/visual/baselines/game-main-scene.png'
// 3. 像素级对比
const diff = await compareScreenshots(baselinePath, currentScreenshot, {
threshold: 0.01, // 允许 1% 的像素差异(抗锯齿导致)
})
// 4. 如果差异过大,生成 diff 图片供人工审查
if (diff.mismatchedPercentage > 0.01) {
await saveDiffImage(diff, 'tests/visual/diffs/game-main-scene-diff.png')
}
expect(diff.mismatchedPercentage).toBeLessThanOrEqual(0.01)
})
test('UI 元素在不同分辨率下的渲染', async () => {
const resolutions = [
{ width: 375, height: 667 }, // iPhone 6/7/8
{ width: 414, height: 896 }, // iPhone 11
{ width: 390, height: 844 }, // iPhone 14/15
{ width: 360, height: 800 }, // 常见安卓
]
for (const res of resolutions) {
setCanvasSize(res.width, res.height)
const screenshot = await captureGameScreen()
const baseline = `tests/visual/baselines/game-${res.width}x${res.height}.png`
const diff = await compareScreenshots(baseline, screenshot, {
threshold: 0.02, // 不同分辨率允许略微更大的差异
})
expect(diff.mismatchedPercentage).toBeLessThanOrEqual(0.02)
}
})
})性能基准测试
typescript
// tests/perf/performance.test.ts
describe('性能基准测试', () => {
test('游戏循环在 16ms 内完成(60fps)', () => {
const iterations = 100
const frameTimes: number[] = []
for (let i = 0; i < iterations; i++) {
const start = performance.now()
gameManager.update(16.67) // 模拟 16.67ms 帧间隔
gameManager.render()
frameTimes.push(performance.now() - start)
}
// P99 帧时间不超过 16ms
frameTimes.sort((a, b) => a - b)
const p99 = frameTimes[Math.floor(iterations * 0.99)]
expect(p99).toBeLessThanOrEqual(16)
// 平均帧时间不超过 8ms(留出渲染余量)
const avg = frameTimes.reduce((a, b) => a + b, 0) / iterations
expect(avg).toBeLessThanOrEqual(8)
})
test('对象池无内存泄漏', () => {
const pool = new ObjectPool<TestObj>(() => new TestObj())
// 模拟 1000 次获取/回收循环
for (let i = 0; i < 1000; i++) {
const objs = Array.from({ length: 50 }, () => pool.get())
objs.forEach((o) => pool.recycle(o))
}
// 池中对象数应保持稳定
expect(pool.size()).toBeLessThanOrEqual(100)
})
test('100 个实体同屏时帧率不低于 30fps', async () => {
// 创建 100 个移动实体
const entities = Array.from({ length: 100 }, (_, i) => {
return { x: i * 10, y: 200, vx: 2, vy: 0 }
})
const frameCount = 60 // 模拟 60 帧
const frameTimes: number[] = []
for (let f = 0; f < frameCount; f++) {
const start = performance.now()
for (const entity of entities) {
entity.x += entity.vx
entity.y += entity.vy
}
frameTimes.push(performance.now() - start)
}
const maxFrameTime = Math.max(...frameTimes)
expect(maxFrameTime).toBeLessThanOrEqual(33) // 30fps = 33.3ms
})
})CI 集成测试流水线
yaml
# .github/workflows/game-test.yml
name: Game Tests
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: pnpm install
- run: pnpm test -- --coverage
- name: Check coverage
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% is below 80% threshold"
exit 1
fi
perf-benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: pnpm install
- run: pnpm test:perf
- name: Compare with baseline
run: |
node scripts/compare-perf-baseline.js
# 如果性能退化超过 5%,CI 失败
lint-and-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: pnpm install
- run: pnpm lint
- run: pnpm typecheck游戏测试金字塔
╱ E2E ╲ ← 少量(核心用户流程,如启动→玩法→游戏结束)
╱ 集成测试 ╲ ← 适量(场景切换、引擎 API 交互、模块协作)
╱ 组件测试 ╲ ← 适量(引擎组件、实体行为、生命周期钩子)
╱ 单元测试 (大量) ╲ ← 大量(碰撞检测、计分逻辑、状态机、工具函数)游戏测试的核心原则:
- 逻辑与渲染分离 — 核心游戏逻辑不依赖 Canvas/WebGL,便于单元测试
- 输入端可控 — 使用依赖注入替代全局状态(
wx.getSystemInfoSync等) - 帧逻辑可时序化 — 将随机因素(Math.random、物理引擎)通过参数注入控制
- 真机测试不可替代 — 桌面环境和真机在性能、输入、渲染方面差异巨大
📚 相关阅读
- 08 性能优化与包体控制 — 测试中发现的性能问题进行优化
- 异常监控与日志 — 线上监控与测试的互补关系
- CI/CD 构建与部署 — 将测试集成到自动化流水线
📝 课后练习
练习 1:为碰撞检测函数编写完整的 Jest 测试用例
题目: 为以下碰撞检测函数编写完整的 Jest 测试套件,覆盖正常情况、边界条件和边缘场景:
typescript
// 圆形与矩形碰撞检测
function circleRectCollision(
cx: number,
cy: number,
radius: number,
rx: number,
ry: number,
rw: number,
rh: number
): boolean {
// 找到矩形上离圆心最近的点
const closestX = Math.max(rx, Math.min(cx, rx + rw))
const closestY = Math.max(ry, Math.min(cy, ry + rh))
const distX = cx - closestX
const distY = cy - closestY
return distX * distX + distY * distY < radius * radius
}要求覆盖以下场景:
- 圆完全在矩形内部
- 圆与矩形边缘重叠
- 圆与矩形完全分离
- 圆与矩形顶点接触(接触点不在边上)
- 矩形在圆内部
- 半径为 0 的退化圆
- 宽度/高度为 0 的退化矩形
参考答案:
typescript
describe('圆形与矩形碰撞检测 (circleRectCollision)', () => {
// 1. 圆完全在矩形内部
test('圆完全在矩形内部时返回 true', () => {
// 矩形 (0,0,100,100),圆心 (50,50) 半径 20 — 圆完全在矩形内
expect(circleRectCollision(50, 50, 20, 0, 0, 100, 100)).toBe(true)
})
// 2. 圆与矩形边缘重叠
test('圆与矩形右边重叠时返回 true', () => {
// 圆心在矩形右侧边缘外侧但半径覆盖到边缘
expect(circleRectCollision(110, 50, 15, 0, 0, 100, 100)).toBe(true)
})
// 3. 圆与矩形完全分离
test('圆与矩形完全分离时返回 false', () => {
expect(circleRectCollision(200, 200, 10, 0, 0, 100, 100)).toBe(false)
})
// 4. 圆与矩形顶点接触
test('圆与矩形右上角顶点重叠时返回 true', () => {
// 矩形的右上角在 (100, 0),圆心在 (100, -5),半径 10
expect(circleRectCollision(100, -5, 10, 0, 0, 100, 100)).toBe(true)
})
test('圆与矩形顶点刚好错过时返回 false', () => {
// 圆心到右上角距离 > 半径
expect(circleRectCollision(115, -5, 10, 0, 0, 100, 100)).toBe(false)
})
// 5. 矩形在圆内部
test('矩形完全在圆内部时返回 true', () => {
expect(circleRectCollision(50, 50, 100, 30, 30, 40, 40)).toBe(true)
})
// 6. 退化圆 (radius = 0)
test('半径为 0 的圆视为一个点', () => {
// 点在矩形内部
expect(circleRectCollision(50, 50, 0, 0, 0, 100, 100)).toBe(true)
// 点在矩形外部
expect(circleRectCollision(200, 200, 0, 0, 0, 100, 100)).toBe(false)
// 点在矩形边上(边界情况)
expect(circleRectCollision(0, 50, 0, 0, 0, 100, 100)).toBe(true)
})
// 7. 退化矩形 (宽度/高度为 0)
test('宽度为 0 的矩形退化为线段', () => {
// 矩形退化为 x=50 上的竖线段
expect(circleRectCollision(50, 50, 10, 50, 0, 0, 100)).toBe(true)
expect(circleRectCollision(70, 50, 10, 50, 0, 0, 100)).toBe(false)
})
test('高度为 0 的矩形退化为线段', () => {
expect(circleRectCollision(50, 50, 10, 0, 50, 100, 0)).toBe(true)
expect(circleRectCollision(50, 70, 10, 0, 50, 100, 0)).toBe(false)
})
// 边界:刚好接触到边缘
test('圆与矩形边缘刚好接触(无重叠)时返回 false', () => {
// 距离恰好等于半径
expect(circleRectCollision(110, 50, 10, 0, 0, 100, 100)).toBe(false)
})
})练习 2:设计游戏性能基准测试方案
题目: 为你的小游戏设计一套性能基准测试方案,确保关键场景下的帧率不低于 30fps。要求:
- 选取 3 个有代表性的测试场景(如:正常游玩、大量实体、特效爆发)
- 定义每个场景的测试参数和通过标准
- 实现自动化测试脚本
- 设计 CI 中的性能回归检测机制
参考答案:
typescript
// tests/perf/scenario-benchmarks.test.ts
interface BenchmarkScenario {
name: string
setup: () => void
run: () => void
duration: number // 测试持续时间 (ms)
maxP99FrameTime: number // P99 帧时间阈值 (ms)
maxMemoryGrowth: number // 内存增量阈值 (MB)
}
class PerformanceBenchmarkRunner {
private frameTimes: number[] = []
private startMemory = 0
/** 运行单个场景的性能基准测试 */
async runScenario(scenario: BenchmarkScenario): Promise<{
passed: boolean
p99: number
avgFps: number
memoryGrowth: number
}> {
console.log(`\n🏃 运行场景: ${scenario.name}`)
scenario.setup()
this.frameTimes = []
this.startMemory = this.getCurrentMemory()
const startTime = performance.now()
// 运行场景
await new Promise<void>((resolve) => {
const loop = () => {
if (performance.now() - startTime >= scenario.duration) {
resolve()
return
}
const frameStart = performance.now()
scenario.run()
this.frameTimes.push(performance.now() - frameStart)
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
})
// 计算指标
const p99 = this.calculateP99()
const avgFps = 1000 / (this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length)
const endMemory = this.getCurrentMemory()
const memoryGrowth = endMemory - this.startMemory
const passed = p99 <= scenario.maxP99FrameTime && memoryGrowth <= scenario.maxMemoryGrowth
console.log(` P99: ${p99.toFixed(1)}ms (阈值: ${scenario.maxP99FrameTime}ms)`)
console.log(` 平均 FPS: ${avgFps.toFixed(1)}`)
console.log(` 内存增长: ${memoryGrowth.toFixed(1)}MB (阈值: ${scenario.maxMemoryGrowth}MB)`)
console.log(` 结果: ${passed ? '✅ 通过' : '❌ 未通过'}`)
return { passed, p99, avgFps, memoryGrowth }
}
private calculateP99(): number {
const sorted = [...this.frameTimes].sort((a, b) => a - b)
const idx = Math.floor(sorted.length * 0.99)
return sorted[idx]
}
/** 获取当前内存使用量(MB),仅 Chrome/V8 环境可用 */
private getCurrentMemory(): number {
try {
const perf = (performance as any).memory
return perf?.usedJSHeapSize ? perf.usedJSHeapSize / 1024 / 1024 : 0
} catch {
// 非 V8 环境(如 iOS JavaScriptCore)不支持 performance.memory
return 0
}
}
}
// ===== 定义测试场景 =====
const scenarios: BenchmarkScenario[] = [
{
name: '正常游玩 — 60fps 稳定',
setup: () => {
// 初始化标准游戏场景
gameManager.init()
gameManager.start()
},
run: () => {
gameManager.update(16.67)
gameManager.render()
},
duration: 10000, // 测试 10 秒
maxP99FrameTime: 18, // 60fps = 16.67ms,允许 18ms
maxMemoryGrowth: 10, // 允许 10MB 以内的内存增长
},
{
name: '高负载 — 200 个实体同屏',
setup: () => {
gameManager.init()
// 创建 200 个实体
for (let i = 0; i < 200; i++) {
gameManager.spawnEntity({ x: Math.random() * 750, y: Math.random() * 1334 })
}
gameManager.start()
},
run: () => {
gameManager.update(16.67)
gameManager.render()
},
duration: 5000,
maxP99FrameTime: 33, // 30fps = 33.3ms
maxMemoryGrowth: 20,
},
{
name: '特效爆发 — 连续创建粒子',
setup: () => {
gameManager.init()
gameManager.start()
},
run: () => {
// 每帧创建 10 个粒子
for (let i = 0; i < 10; i++) {
gameManager.spawnParticle({ x: 200, y: 400 })
}
gameManager.update(16.67)
gameManager.render()
},
duration: 5000,
maxP99FrameTime: 33,
maxMemoryGrowth: 15,
},
]
// ===== 运行基准测试 =====
describe('性能基准测试', () => {
const runner = new PerformanceBenchmarkRunner()
for (const scenario of scenarios) {
test(
scenario.name,
async () => {
const result = await runner.runScenario(scenario)
expect(result.passed).toBe(true)
},
30000
) // 单场景超时 30 秒
}
})CI 性能回归检测配置 (scripts/compare-perf-baseline.js):
javascript
// scripts/compare-perf-baseline.js
const fs = require('fs')
const path = require('path')
const BASELINE_PATH = path.join(__dirname, '..', 'tests', 'perf', 'baseline.json')
const CURRENT_PATH = path.join(__dirname, '..', 'tests', 'perf', 'current.json')
function compare() {
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'))
const current = JSON.parse(fs.readFileSync(CURRENT_PATH, 'utf-8'))
let hasRegression = false
for (const [scenario, metrics] of Object.entries(current)) {
const base = baseline[scenario]
if (!base) continue
const p99Degradation = ((metrics.p99 - base.p99) / base.p99) * 100
const memDegradation = ((metrics.memoryGrowth - base.memoryGrowth) / base.memoryGrowth) * 100
console.log(`\n📊 ${scenario}:`)
console.log(
` P99: ${base.p99.toFixed(1)}ms → ${metrics.p99.toFixed(1)}ms (${p99Degradation > 0 ? '+' : ''}${p99Degradation.toFixed(1)}%)`
)
console.log(
` 内存: ${base.memoryGrowth.toFixed(1)}MB → ${metrics.memoryGrowth.toFixed(1)}MB (${memDegradation > 0 ? '+' : ''}${memDegradation.toFixed(1)}%)`
)
if (p99Degradation > 10) {
console.error(` ❌ P99 性能退化 ${p99Degradation.toFixed(1)}% 超过 10% 阈值`)
hasRegression = true
}
if (memDegradation > 20) {
console.error(` ❌ 内存增长 ${memDegradation.toFixed(1)}% 超过 20% 阈值`)
hasRegression = true
}
}
if (hasRegression) {
console.error('\n❌ 性能回归检测未通过,请检查代码变更')
process.exit(1)
}
console.log('\n✅ 性能回归检测通过')
}
compare()