iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
JavaScript

一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲系列 第 26

Day 26|測什麼、不測什麼:我規劃了三層測試,中間那層一個檔案都沒有

  • 分享至 

  • xImage
  •  

模組六|測試、效能與上線(Day 26–30)

昨天講完存檔:九條通往「當作全新存檔」的路徑,每一條都有測試。今天把鏡頭拉遠,看整個專案的測試長什麼樣。

先攤開事實。這個專案的 tests/ 底下有三個目錄,vitest.config.jsplaywright.config.js 都各自指向了它們。實際跑起來是這樣(2026-08-07 12:35 快照,commit 5aa3705):

層級 目錄 內容 誰執行
單元 tests/unit 36 個測試檔、313 個測試,另有 helpers/ 2 檔不是測試 Vitest
整合 tests/integration 0 個測試檔,目錄裡只有一個 .gitkeep Vitest(glob 配了,0 命中)
端對端 tests/e2e 4 個 spec、15 個 test(),跨 5 個 project = 75 個測試實例 Playwright

結論先講:測試的分佈不是規劃出來的,是「哪一層便宜」決定的。 我規劃了三層,實際只有兩層有內容;而真正決定測試押在哪裡的,不是我對品質的想像,是每一層跑一次要花多少秒。


先講一個我自己讀錯的數字

tests/integration 這個目錄,我在整理素材時記成「從 1 個檔案變成 0 個,測試被重新歸位了」。那是錯的,而且錯得很低級。

正確的答案:這個目錄從 866d153 建立之後從來沒變動過,裡面自始至終只有一個 .gitkeepfind tests/integration -type f 回 1,find tests/integration -name '*.test.js' 回 0——同一個目錄、兩個指令、兩個數字。我把口徑差讀成了事件。

之所以要花一段講這件事,是因為它示範了統計口徑沒有寫死的下場:你會從自己的資料裡讀出一個根本沒發生過的故事。這個系列後來為此定了一條規則——每個數字都要登記口徑與指令,不登記的不准引用。

而查證完的真相比誤讀更值得寫:vitest.config.js:6include 確實把 tests/integration/**/*.test.js 配進去了,這個 glob 到今天零命中。三層測試裡,中間那層目錄建了、設定配了、一個檔案都沒寫。誠實的講法是「我規劃了三層,實際只有兩層」,不是「三層各司其職」。


單元測試為什麼便宜到可以有 313 個

vitest.config.js:5 只有一行是關鍵:environment: 'node'

不是 jsdom。313 個測試全部在 Node 環境跑,沒有瀏覽器、沒有 DOM、沒有 localStorage專案根本沒安裝 jsdom 這個依賴。整份跑完 3.72 秒。

這件事不是設定選出來的,是架構的後果。物理層與系統層全部收 injectable 的依賴:ProgressStore 的 constructor 收 { storage },測試餵一個假物件進去;GameLoopnow,測試餵一個假時鐘。沒有任何一個測試需要建 Pixi 應用。

Day 5 講過 src/utils/ 那兩個檔案零 import。這裡是它的回收處,但回收的形狀跟我原本想的不一樣。我原本以為「大多數測試都打在 utils 上」——實查完全不是:

類別 檔數 檔名
專職測 src/utils/ 2 geometry.test.jsseededRandom.test.js
順帶 import src/utils/ 6 determinismlevelOutcomelineStabilitylevelSolvabilitysceneRoutingleakCycles
完全不碰 src/utils/ 28 目標是 core/systems/levels/config/entities/scripts/

測試主體是系統層的行為測試,不是工具函式。但那 2 個檔案是一對一的:兩個工具檔,各有一個專屬測試檔,再加 6 個測試檔間接依賴它們。

一個零 import 的模組才可能被這麼多測試共用而不用 mock 任何東西——這比「多數測試打在這裡」更能說明那條規則的價值。

座標轉換就是最典型的例子。它的正確性沒有任何手感可以驗,只能靠斷言:

// tests/unit/geometry.test.js
it('maps browser coordinates to world coordinates', () => {
  const canvas = {
    getBoundingClientRect: () => ({ left: 10, top: 20, width: 375, height: 667 })
  }

  expect(
    clientToGamePoint({
      clientX: 197.5,
      clientY: 353.5,
      canvas,
      gameWidth: 750,
      gameHeight: 1334
    })
  ).toEqual({ x: 375, y: 667 })
})

物理手感改壞了,你玩一次就知道;座標轉換偏三像素,你玩一百次也不會發現。 這句話就是這個專案分配測試的唯一原則。


不測什麼:物理模擬的「結果」

蜜蜂撞到防線後應該彈到哪裡——這種東西不寫斷言。寫了,只會在每次調參時全部紅掉,然後你會為了讓測試綠回來而放棄一個更好的手感。

但「不測數值」不等於「不測物理」。這個專案測的是性質

測什麼 檔案 性質
同一個 seed 跑兩次結果相同 determinism.test.js 決定性
防線在若干步之後靜止,不會自己抖開 lineStability.test.js 穩定性
蜜蜂不會疊成一柱 swarmStacking.test.js 分佈
五個關卡都有解 levelSolvability.test.js 可解性

性質不會因為你把彈性從 0.2 調成 0.25 就變成假的,數值會。


沒有測試後門:兩層接力

形狀我先用薄紙剪過幾張,確定了才把它壓在這塊厚料上劃線

我原本以為 E2E 一定得開一個測試專用模式,直接注入一條「保證成功」的防線——否則要用程式模擬滑鼠拖出一條剛好擋得住的線,太脆弱了。

實測結果:這種機制不存在。 全庫搜 injectaddInitScript?testforcecheat 之類的字,零命中。實際做法是兩層接力:

第一層,在便宜的地方證明「什麼樣的輸入會贏」。tests/unit/levelOutcome.test.js:123 拿同一條五點折線跑 12 個不同的蜂群 seed,斷言 12 次全勝。測試自己的註解寫明了為什麼要 12 次:一條高懸的拱形在多數 seed 下也會贏,但蜜蜂可以繞過它兩端,結果是邊緣的——而 Math.sin / atan2 在不同 JS 引擎不是逐位元相同,邊緣的線可能在 Chromium 贏、在 WebKit 輸。所以選一條贏有餘裕的。

第二層,在貴的地方用真的滑鼠把同一條線畫出來:

// tests/e2e/level-outcome.spec.js
async function dragWorldLine(page, canvas, points) {
  const box = await canvas.boundingBox()
  const toClient = (point) => ({
    x: box.x + (point.x / 750) * box.width,
    y: box.y + (point.y / 1334) * box.height
  })
  const [start, ...rest] = points.map(toClient)

  await page.mouse.move(start.x, start.y)
  await page.mouse.down()

  for (const point of rest) {
    await page.mouse.move(point.x, point.y, { steps: 8 })
  }

  await page.mouse.up()
}

750×1334 的世界座標換算回 client 座標,再一段一段拖。同一組座標點在單元層與 E2E 層各出現一次,spec 的檔頭註解明寫它們是同一條。

而且 E2E 那一側還多了一條加嚴條件::51 斷言 spawnedBeeCount === 0,確保這條線是在蜂巢開啟之前畫完的。這不是放水,是把「玩家真的來得及畫」也一起測了。

唯一存在的出口是 window.__SAVE_THE_DOG_DEBUG__,四個 spec 全靠 page.waitForFunction 輪詢它。它只寫出、不接受寫入,沒有任何 setter 能改變遊戲狀態。那是可觀測性,不是後門。

與其做一個能贏的後門,不如先在便宜的那一層把「什麼樣的輸入會贏」證明出來,再讓貴的那一層照著做。 後門會讓 E2E 測到一條玩家永遠不會走的路徑。


視覺回歸:規劃六處,實作零處

PRD 有一整節在講視覺回歸,六個地方提到 toHaveScreenshot()、基準圖、四個截圖標的。實作是零。

實測:grep -rniE "toHaveScreenshot|toMatchSnapshot|percy" tests/ playwright.config.js package.json .github/0 命中,也沒有任何基準圖目錄。

實際做的是一個便宜很多的替代品(tests/e2e/smoke.spec.js:109-117):截 canvas 中間一小塊(最多 240×240),用自寫的 PNG 解析器算相異 RGB 數,斷言大於 3。同一支測試還斷言 console error 為空、404 回應清單為空。

註解寫了理由:解整張 1500×2668 的 PNG 會在慢的 mobile profile 上 timeout。

這是白畫面偵測,不是像素比對回歸。 我付不起視覺回歸的成本(基準圖要管、CI 映像要固定、跨 GPU 的 WebGL 有微小差異),所以只擋最嚴重的那一種失敗——整片空白。這比討論「像素容忍度該設多少」誠實,也比較有交付性。


本篇最重要的誠實:CI 不跑 E2E

盤上這四支我推得動,腳邊那幾支的握把到現在還是新的

.github/workflows/deploy.yml 的 verify job 只跑四個命令:

assets:validate → lint → test:unit → build

沒有 Playwright。那 75 個 E2E 測試實例從來沒有在 CI 執行過一次playwright.config.js:15reuseExistingServer: !process.env.CI 明明為 CI 留了位置,但沒有人叫它。

這件事的後果不是「E2E 白寫了」,是**「有測試」跟「有東西在擋」是兩回事**。這個系列後來把「覆蓋」定義成一句很窄的話:有東西會讓 verify job 失敗。按這個定義,E2E 不算覆蓋。

順帶埋一個明天之後要收的伏筆。AGENTS.md 有一條工程規則寫「遊戲邏輯不准出現 Date.now() / performance.now()」。沒有任何 lint 規則在擋它——擋它的是三個單元測試(BeeSystem.test.js:587-588CountdownSystem.test.js:106CollisionSystem.test.js:138),做法是用 helper 直接把原始碼讀成字串,斷言 not.toMatch(WALL_CLOCK)

一條規則可以被降維成好幾種執行方式,而每一種的覆蓋範圍都不一樣:lint 選擇器管整個 codebase,讀原始碼的測試只管它點名的那幾個檔案。Day 29 會把這件事完整攤開。


測不到的那一塊

觸控延遲、iOS 的音訊解鎖政策、瀏覽器的手勢仲裁、記憶體壓力下的表現——這些只能靠實機。

我要誠實:這個專案到現在還沒做過任何一次實機驗收。 跨幀率一致性、iOS 的 pointercancel、記憶體快照,三項全部掛在待辦上。所以這一段不是「我做了實機驗收,建議你也做」,是「我知道這些非做不可,而我還沒排」。


交給 AI

測試是這個專案裡我最放心交出去的部分,理由很單純:測試有一個機器可判定的驗收條件——它自己要能跑過,而且要能因為正確的原因失敗。

實際的循環是:我指定要測的性質(「同一個 seed 兩次結果要相同」「防線靜止後不准自己抖開」),AI 寫測試與實作,然後 npm run test:unit -- --run 跑一次。這一步的產出可以直接被驗證,不需要我逐行讀。

但有一段是我自己決定的,而且不能委外:哪些東西不寫測試。 「蜜蜂彈到哪裡不寫斷言」「視覺回歸不做,只做白畫面偵測」——這兩個決定的依據是我願意為它付多少維護成本,不是任何模型讀得出來的東西。丟給 AI 的話,它會很樂意幫我把六個視覺回歸基準圖全部建起來,然後我每次調素材都要重新核准一批截圖。


帶走什麼

測試要押在「改壞了不會被發現」的地方,而不是押在「最重要」的地方。

三件今天就能做的事:

  1. 先確定每一層跑一次要幾秒。 這個專案的單元測試 3.72 秒,所以它有 313 個;E2E 一關要跑滿十秒真實時間,所以它只有 15 個。時間預算決定分佈,不是重要性決定分佈。
  2. 不要為了 E2E 開後門。 改成兩層接力:便宜的那層證明「什麼輸入會贏」,貴的那層照著做一次。
  3. 查一次你的 CI 到底跑了哪幾個命令。 沒被 CI 叫到的測試,作用是「你想起來的時候跑一次」,不是閘門。

明天 Day 27 講效能。這個專案在寫任何一行程式之前就訂了一張效能預算表,十二列,每列都有目標值與警戒線;那張表確實反過來決定了架構——防線節點上限、蜜蜂數量、以及 Day 15 為什麼不用柔性繩索,答案全在那張表上。然後我會講一件更難堪的事:到今天為止,那十二列裡有實際量測數字的是零列,而量測工具本身第一版還說了謊。


本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit 5aa3705。專案仍在開發中,量體數字會變動;測試數字取自本文提到的指令當下重跑的輸出。
可玩網址https://save-the-dog-web.vercel.app/原始碼https://github.com/HarryFan/save-the-dog-web

參考資料

如果你卡在語法

深入原理


上一篇
Day 25|版本號我第一天就加了,它只買到偵測,沒買到遷移
下一篇
Day 27|效能預算:我訂了十二列,一列都沒量過
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言