模組六|測試、效能與上線(Day 26–30)
昨天講完存檔:九條通往「當作全新存檔」的路徑,每一條都有測試。今天把鏡頭拉遠,看整個專案的測試長什麼樣。
先攤開事實。這個專案的 tests/ 底下有三個目錄,vitest.config.js 與 playwright.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 建立之後從來沒變動過,裡面自始至終只有一個 .gitkeep。find tests/integration -type f 回 1,find tests/integration -name '*.test.js' 回 0——同一個目錄、兩個指令、兩個數字。我把口徑差讀成了事件。
之所以要花一段講這件事,是因為它示範了統計口徑沒有寫死的下場:你會從自己的資料裡讀出一個根本沒發生過的故事。這個系列後來為此定了一條規則——每個數字都要登記口徑與指令,不登記的不准引用。
而查證完的真相比誤讀更值得寫:vitest.config.js:6 的 include 確實把 tests/integration/**/*.test.js 配進去了,這個 glob 到今天零命中。三層測試裡,中間那層目錄建了、設定配了、一個檔案都沒寫。誠實的講法是「我規劃了三層,實際只有兩層」,不是「三層各司其職」。
vitest.config.js:5 只有一行是關鍵:environment: 'node'。
不是 jsdom。313 個測試全部在 Node 環境跑,沒有瀏覽器、沒有 DOM、沒有 localStorage,專案根本沒安裝 jsdom 這個依賴。整份跑完 3.72 秒。
這件事不是設定選出來的,是架構的後果。物理層與系統層全部收 injectable 的依賴:ProgressStore 的 constructor 收 { storage },測試餵一個假物件進去;GameLoop 收 now,測試餵一個假時鐘。沒有任何一個測試需要建 Pixi 應用。
Day 5 講過 src/utils/ 那兩個檔案零 import。這裡是它的回收處,但回收的形狀跟我原本想的不一樣。我原本以為「大多數測試都打在 utils 上」——實查完全不是:
| 類別 | 檔數 | 檔名 |
|---|---|---|
專職測 src/utils/ |
2 | geometry.test.js、seededRandom.test.js |
順帶 import src/utils/ |
6 | determinism、levelOutcome、lineStability、levelSolvability、sceneRouting、leakCycles |
完全不碰 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 一定得開一個測試專用模式,直接注入一條「保證成功」的防線——否則要用程式模擬滑鼠拖出一條剛好擋得住的線,太脆弱了。
實測結果:這種機制不存在。 全庫搜 inject、addInitScript、?test、force、cheat 之類的字,零命中。實際做法是兩層接力:
第一層,在便宜的地方證明「什麼樣的輸入會贏」。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 有微小差異),所以只擋最嚴重的那一種失敗——整片空白。這比討論「像素容忍度該設多少」誠實,也比較有交付性。

.github/workflows/deploy.yml 的 verify job 只跑四個命令:
assets:validate → lint → test:unit → build
沒有 Playwright。那 75 個 E2E 測試實例從來沒有在 CI 執行過一次。playwright.config.js:15 的 reuseExistingServer: !process.env.CI 明明為 CI 留了位置,但沒有人叫它。
這件事的後果不是「E2E 白寫了」,是**「有測試」跟「有東西在擋」是兩回事**。這個系列後來把「覆蓋」定義成一句很窄的話:有東西會讓 verify job 失敗。按這個定義,E2E 不算覆蓋。
順帶埋一個明天之後要收的伏筆。AGENTS.md 有一條工程規則寫「遊戲邏輯不准出現 Date.now() / performance.now()」。沒有任何 lint 規則在擋它——擋它的是三個單元測試(BeeSystem.test.js:587-588、CountdownSystem.test.js:106、CollisionSystem.test.js:138),做法是用 helper 直接把原始碼讀成字串,斷言 not.toMatch(WALL_CLOCK)。
一條規則可以被降維成好幾種執行方式,而每一種的覆蓋範圍都不一樣:lint 選擇器管整個 codebase,讀原始碼的測試只管它點名的那幾個檔案。Day 29 會把這件事完整攤開。
觸控延遲、iOS 的音訊解鎖政策、瀏覽器的手勢仲裁、記憶體壓力下的表現——這些只能靠實機。
我要誠實:這個專案到現在還沒做過任何一次實機驗收。 跨幀率一致性、iOS 的 pointercancel、記憶體快照,三項全部掛在待辦上。所以這一段不是「我做了實機驗收,建議你也做」,是「我知道這些非做不可,而我還沒排」。
測試是這個專案裡我最放心交出去的部分,理由很單純:測試有一個機器可判定的驗收條件——它自己要能跑過,而且要能因為正確的原因失敗。
實際的循環是:我指定要測的性質(「同一個 seed 兩次結果要相同」「防線靜止後不准自己抖開」),AI 寫測試與實作,然後 npm run test:unit -- --run 跑一次。這一步的產出可以直接被驗證,不需要我逐行讀。
但有一段是我自己決定的,而且不能委外:哪些東西不寫測試。 「蜜蜂彈到哪裡不寫斷言」「視覺回歸不做,只做白畫面偵測」——這兩個決定的依據是我願意為它付多少維護成本,不是任何模型讀得出來的東西。丟給 AI 的話,它會很樂意幫我把六個視覺回歸基準圖全部建起來,然後我每次調素材都要重新核准一批截圖。
測試要押在「改壞了不會被發現」的地方,而不是押在「最重要」的地方。
三件今天就能做的事:
明天 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
如果你卡在語法
深入原理