《Cocos 游戏开发实战》16.2 自动化冒烟与截图比对

类型对、单测过,不代表跑起来不白屏。本节把「冷启动 → 主菜单 → 跳一下」这条主路径写成 Playwright 冒烟用例,用 Cocos CLI 构建 H5 产物、用截图比对守住首屏外观,并给出 CI 里的构建命令、产物归档与失败判定——让每次提交都自动「打开游戏看一眼」。

16.2 自动化冒烟与截图比对

16.1 的类型门禁能挡住「API 写错」,单测能挡住「逻辑算错」。但还有一类问题它们都看不见:代码全对、测试全绿,构建出来的包却是白屏——可能是资源路径错、可能是首场景名写错、可能是某个组件在真机上抛异常。这类问题只有「真的把游戏跑起来看一眼」才能发现。

与站内 game/client/ 的测试文章的关系:那些文章多讲单元测试;本节讲的是端到端冒烟 + 视觉回归在 Cocos 工程里的落地方式——站内很少有人写「怎么在 CI 里自动打开一个小游戏并截图」。

冒烟(smoke test)的原则是「少而关键」:不追求覆盖所有玩法,只保证主路径能走通、首屏长得对。一局跑酷的主路径是「冷启动 → 主菜单 → 点开始 → 进关卡 → 跳一下」。跑通它,就等于确认了「这个包至少能用」。

16.2.1 冒烟要测的那一条主路径

1.1 的用例表里,UC-01「首次进入」就是冒烟该覆盖的主路径。把它拆成可断言的步骤:

步骤断言失败表现
打开页面画布可见白屏
引擎启动完成__BLOCK_DASH_READY__ 为 true卡在加载
首屏外观截图与基线像素差 < 2%UI 错位、贴图丢失
点一下屏幕无异常抛出输入无响应

第一步与第三步是视觉断言,第二步是状态断言,第四步是交互断言。四步都过,才叫「冒烟通过」。

冒烟和 16.1 的单测分工不同,别指望一个替代另一个:

维度单元测试(16.1)冒烟(本节)
测什么纯逻辑的输入输出整包能否跑起来
跑在哪Node浏览器 / 真机
速度毫秒级十秒级
覆盖分支与边界只覆盖主路径
挡什么算错白屏、崩、UI 错位

一句话:单测证明「逻辑对」,冒烟证明「包能用」。 两者都不能省,但优先级不同——冒烟先做,因为它挡的是「根本打不开」这种最严重的问题。

16.2.2 让构建产物能被冒烟识别

截图比对需要一个稳定的「准备好了」信号。下面这个组件在首帧绘制后,往 window 上打一个标记,Playwright 就能等它:

import { _decorator, Component, director, Director } from 'cc';

const { ccclass } = _decorator;

declare global {
  interface Window {
    __BLOCK_DASH_READY__?: boolean;
  }
}

@ccclass('SmokeProbe')
export class SmokeProbe extends Component {
  onLoad(): void {
    director.once(Director.EVENT_AFTER_DRAW, this.markReady, this);
  }

  private markReady(): void {
    if (typeof window !== 'undefined') {
      window.__BLOCK_DASH_READY__ = true;
    }
  }
}

director.once(Director.EVENT_AFTER_DRAW, ...) 只触发一次——首帧画完就置位。这个标记必须挂在启动场景的常驻节点上,否则场景切换时组件被销毁,标记也没了。typeof window !== 'undefined' 的判断是为了让这段代码在原生端(没有 window)也不报错——同一份脚本要跑三端。

16.2.3 用 CLI 构建 H5 产物

冒烟测的是构建产物,不是编辑器预览,所以 CI 里要先跑一次构建。Cocos Creator 提供了命令行构建:

# 需本机已安装 Cocos Creator 3.8.8 编辑器(CI 上要自建 runner 或预装编辑器镜像)
/Applications/Cocos/Creator/3.8.8/CocosCreator.app/Contents/MacOS/CocosCreator \
  --project "$PWD" \
  --build "platform=web-mobile;debug=false;md5Cache=true"

# 产物落在 build/web-mobile/
ls build/web-mobile/index.html

三点提醒。第一,这条命令依赖编辑器二进制,GitHub 官方的 ubuntu runner 上没有它——要么用自托管 runner,要么在别处构建好、把 build/web-mobile/ 作为 artifact 传进冒烟 job。第二,platform=web-mobile 是 H5 移动端构建,与真机接近,比 web-desktop 更能暴露适配问题。第三,debug=false 关掉调试信息,产物更接近发布包。

构建完起一个静态服务,让 Playwright 能访问:

npx http-server build/web-mobile -p 8080 -c-1
# -c-1 关闭缓存,避免冒烟读到上一轮的旧文件

16.2.4 用 Playwright 驱动一局

Playwright 负责「打开页面、等信号、点一下、截图」。下面是一条完整的冒烟用例:

import { test, expect } from '@playwright/test';

test('冷启动到第一屏冒烟', async ({ page }) => {
  await page.goto('http://127.0.0.1:8080/');

  const canvas = page.locator('#GameCanvas');
  await expect(canvas).toBeVisible({ timeout: 15000 });

  await page.waitForFunction(
    () => (window as unknown as { __BLOCK_DASH_READY__?: boolean }).__BLOCK_DASH_READY__ === true,
    null,
    { timeout: 15000 },
  );

  await expect(page).toHaveScreenshot('first-screen.png', { maxDiffPixelRatio: 0.02 });

  await page.mouse.click(480, 270);
  await page.waitForTimeout(300);
  await expect(canvas).toBeVisible();
});

#GameCanvas 是 Cocos H5 构建默认的画布 id。waitForFunction 等的是 16.2.2 打的标记——不要用固定 waitForTimeout 代替它,那会让冒烟在慢机器上随机失败。toHaveScreenshot 是 Playwright 的截图比对断言:首次运行生成基线图,之后每次运行拿新截图和基线比,像素差超过 maxDiffPixelRatio(这里 2%)就失败。

光有首屏还不够,主路径里「点一下能跳」也该被断言。给同一条用例追加一段,用状态变化而不是截图来验证交互:

import { test, expect } from '@playwright/test';

test('点击后角色进入跳跃状态', async ({ page }) => {
  await page.goto('http://127.0.0.1:8080/');
  await page.waitForFunction(
    () => (window as unknown as { __BLOCK_DASH_READY__?: boolean }).__BLOCK_DASH_READY__ === true,
    null,
    { timeout: 15000 },
  );

  const before = await page.evaluate(
    () => (window as unknown as { __BLOCK_DASH_STATE__?: string }).__BLOCK_DASH_STATE__,
  );
  await page.mouse.click(480, 270);
  await page.waitForFunction(
    (prev) => (window as unknown as { __BLOCK_DASH_STATE__?: string }).__BLOCK_DASH_STATE__ !== prev,
    before,
    { timeout: 3000 },
  );

  await expect(page.locator('#GameCanvas')).toBeVisible();
});

这里用第二个标记 __BLOCK_DASH_STATE__(由玩法脚本在状态切换时写入)来断言「点击确实产生了状态变化」。用状态断言代替截图断言,是交互测试比外观测试更稳的关键——状态是确定性的,像素不是。两段用例都过,才算「主路径能走通且能交互」。

16.2.5 截图比对的基线怎么管

截图比对的难点不在跑,而在基线图(baseline)怎么维护。基线图必须进版本库,且只能在「确认这次改动是预期的」时更新:

# 第一次运行:生成基线(把生成的图提交进仓库)
npx playwright test --update-snapshots

# 之后每次运行:与基线比对,不一致则失败
npx playwright test

# 确认某个改动是预期的(比如 UI 改版),手动更新基线并单独提交
npx playwright test --update-snapshots

这里有个纪律:更新基线必须是一次「有意的、单独的」提交,提交信息里写清「为什么改」。如果随手 --update-snapshots 就把基线覆盖了,视觉回归就形同虚设——因为它永远和当前状态一致。

16.2.6 在 CI 里怎么落

把构建、起服务、冒烟、归档串成一个 job:

name: smoke
on:
  pull_request:
    paths: ["assets/**", "settings/**"]
jobs:
  smoke:
    runs-on: [self-hosted, cocos]   # 需要装有 Cocos Creator 3.8.8 的 runner
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - name: build web
        run: ./scripts/build-web.sh   # 封装 16.2.3 的 CLI 命令
      - name: serve
        run: npx http-server build/web-mobile -p 8080 -c-1 &
      - name: smoke
        run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: smoke-screenshots
          path: test-results/

失败判定分两层:Playwright 用例失败(画布不可见、标记超时、截图差异超阈值)时进程退出码非 0,job 红;同时 if: failure() 把 test-results/ 目录(含实际截图、期望截图、差异图)上传成 artifact,reviewer 下载就能看到「到底是哪里不一样」。产物就是这个 artifact——没有它,光知道「截图对不上」没法排查。

--update-snapshots 这个开关绝不能出现在 CI 脚本里。一旦 CI 自动更新基线,视觉回归就退化成「每次都和上一轮比」,任何改动都会被它自己接受——门禁形同虚设。基线更新必须是人手动触发、单独提交的动作(16.2.5)。

16.2.7 冒烟覆盖不到的地方

冒烟在 H5 上跑通,不代表三端都通。三端各有盲区:

平台冒烟怎么跑盲区
H5Playwright(本节这套)不覆盖小游戏宿主 API
微信小游戏微信开发者工具的自动化接口真机性能仍需复测
原生真机 / 模拟器CI 难自动化,多靠手动

所以本节这套只覆盖 H5 端。小游戏端可以用微信开发者工具的自动化跑一遍同样的主路径,原生端则往往只能保留「发布前手动过一遍 18.1 检查清单」这一步。不要因为 H5 冒烟绿了,就以为三端都安全——宿主环境的差异(wx.* API、原生 JSB)恰恰是冒烟在浏览器里碰不到的。

16.2.8 让冒烟稳定的三个技巧

冒烟最怕「随机红」,三次里红一次,团队就会开始忽略它。三条反波动措施:

  1. 等信号,不等时间:用 waitForFunction 等 __BLOCK_DASH_READY__,不用固定 waitForTimeout。
  2. 关掉非确定性动画:截图前把粒子、飘字、背景滚动暂停或固定随机种子,否则每次截图都不同。
  3. 固定视口与设备倍率:Playwright 配置里写死 viewport: { width: 960, height: 540 } 与 deviceScaleFactor: 1,避免不同分辨率下布局漂移。

deviceScaleFactor 尤其重要:它一变,截图像素尺寸就变,比对必然失败。把它写进配置,而不是依赖运行环境默认值。

还有一个容易被忽略的波动源:字体渲染。不同操作系统、不同浏览器对同一字体的抗锯齿不同,同一段文字截出来的像素会有细微差异。对策是冒烟环境用同一套容器镜像(CI 里用固定的 Playwright Docker 镜像),本地与 CI 保持一致。如果实在无法统一,就把含文字的区域用 mask 参数遮掉,只比对图形区域。

这四条合起来是一句话:让每次运行的外部条件尽可能一致,把「不确定」挤到最小。 冒烟的价值不在于测得全,而在于测得稳——一个三天两头误报的冒烟,比没有冒烟更糟,因为它会训练团队忽略红灯。

16.2.9 工程视角的收口

这一节做完,工程里多了:assets/scripts/platform/SmokeProbe.ts(往 window 打就绪标记)、一份 playwright.config.ts、一组 tests/smoke.spec.ts 与基线截图目录、scripts/build-web.sh(封装 CLI 构建),以及一个 CI 的 smoke job。质量保障从「类型 + 逻辑」扩到了「真实运行」。

冒烟保证了「包能跑、首屏对」,但它只在 CI 里跑——真实用户那边出问题时,你不在场。用户遇到崩溃、卡死、某个机型黑屏时,如果没有上报,你只能靠「用户反馈」猜。下一节把崩溃上报与埋点接进来,让线上问题看得见。

阅读导航:上一节:16.1 逻辑单测与类型校验 · 下一节:16.3 崩溃上报与埋点 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画