前兩篇介紹了用 setup project 在所有測試開始前先完成登入,並把登入狀態存起來,讓測試不必特別處理登入。
不過測試跑完之後,這些檔案還留在那裡;如果測試過程中建立了暫存檔、或在後端新增了資料,需要有清理機制還原測試資料,避免後續的測試因為資料被污染而失敗。今天就要說明 playwright 的測試資料清理機制。
setup(登入、存 storageState)
│
├──> chromium ─┐
├──> firefox ─┼──> teardown(收尾清理)
└──> webkit ─┘
teardown 是在所有測試都跑完之後,執行一次的收尾步驟。它跟 setup 剛好相反:setup 在所有測試開始前把環境準備好,teardown 則在所有測試結束後把環境恢復乾淨,例如刪掉 setup 產生的 .auth 檔、清掉測試留下的暫存檔或資料。
Playwright 提供兩種做法來設定 teardown:
globalTeardown 設定:在 config 指定一支收尾用的檔案官方文件推薦第一種,因為它跟 test runner 整合得比較好,在報告裡可以看到執行過程、會錄 trace,也可以使用 fixture。前面我們的 setup 就是用 project 寫的,所以 teardown 我們也用同樣的方式。至於globalTeardown 的寫法這裡不介紹,有興趣可以參考官方文件 Global setup and teardown。
teardown project 跟一般的 project 差別在執行的時間點,它會在 setup 和所有依賴 setup 的 project 都跑完之後才執行,而且只會執行一次。
設定方式是在 setup project 上加一個 teardown 屬性,指定要由哪個 project 負責收尾。以下沿用官方文件範例的命名,收尾的 project 就叫 teardown:
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/, teardown: 'teardown' },
{ name: 'teardown', testMatch: /global\.teardown\.ts/ },
// chromium / firefox / webkit 維持 dependencies: ['setup']
],
第一行的 teardown: 'teardown' 的左邊是屬性名稱,右邊的是負責收尾的 project 名稱,對應到第二行的 name: 'teardown'。
我們稍微比較一下 dependencies 和 teardown:
| 屬性 | 寫在哪個 project | 意思 |
|---|---|---|
dependencies |
測試的 project(chromium 等) | 「我要等 setup 跑完才開始」 |
teardown |
setup project | 「依賴 setup 的 project 都跑完後,由 teardown project 收尾」 |
而 teardown project 本身不需要寫 dependencies,它什麼時候執行,完全由 setup 上的 teardown: 'teardown' 決定。
在 tests/ 底下新增 global.teardown.ts:
import { test as teardown } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const authDir = path.join(__dirname, '../playwright/.auth');
teardown('清除登入狀態檔', async () => {
console.log('>>> teardown:清除 .auth');
fs.rmSync(authDir, { recursive: true, force: true });
});
跟 Day26 的 test as setup 一樣,這裡的 teardown 就是平常的 test 改了名字,讓人一眼看出它是收尾用的。所以它也是一支普通的測試檔,執行過程同樣會出現在報告裡。
force: true 的作用是當資料夾不存在時不要報錯,避免 setup 沒產生檔案時 teardown 反而失敗。
在 setup project 加上 teardown,並新增 teardown project:
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/, teardown: 'teardown' },
{ name: 'teardown', testMatch: /global\.teardown\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: authFile },
dependencies: ['setup'],
},
// firefox、webkit 不變
],
檔名要注意兩件事:
testMatch 要對得到 global.teardown.ts
.spec,否則 chromium 這些 project 會把它當成一般測試,跟其他測試一起跑拿上一篇的多角色測試來跑:
npx playwright test day27-multi-role --project=chromium
Running 6 tests using 3 workers
[setup] › tests/auth.setup.ts:41:6 › 登入一般使用者
>>> setup:demo 登入
[setup] › tests/auth.setup.ts:46:6 › 登入管理員
>>> setup:admin 登入
[teardown] › tests/global.teardown.ts:7:9 › 清除登入狀態檔
>>> teardown:清除 .auth
6 passed (3.9s)
可以看到:
>>> teardown:清除 .auth
ls playwright/.auth,資料夾已經不存在;下次執行時 setup 會重新產生收尾的程式碼最怕的情況是當測試一失敗,清理就被跳過。
我們故意讓一支測試失敗,把 day27-multi-role.spec.ts 裡的 'Jane Doe' 暫時改成 'Jane Doe X',再跑一次:
Running 6 tests using 3 workers
[setup] › tests/auth.setup.ts:46:6 › 登入管理員
>>> setup:admin 登入
[setup] › tests/auth.setup.ts:41:6 › 登入一般使用者
>>> setup:demo 登入
1) [chromium] › tests/day27-multi-role.spec.ts:4:9 › 預設身分:一般使用者 › 右上角顯示 Jane Doe ───────────
Error: expect(locator).toContainText(expected) failed
Locator: getByRole('button', { name: 'Profile' })
# skipped
[teardown] › tests/global.teardown.ts:7:9 › 清除登入狀態檔
>>> teardown:清除 .auth
1 failed
[chromium] › tests/day27-multi-role.spec.ts:4:9 › 預設身分:一般使用者 › 右上角顯示 Jane Doe ────────────
5 passed (10.9s)
從結果我們可以看到雖然有 1 支測試失敗,最後仍然印出了 >>> teardown:清除 .auth,所以我們可以確定即使有測試失敗也不影響 teardown 執行。
清理也能寫在 hooks 裡,差別在於執行的範圍:
| 寫法 | 執行時機 |
|---|---|
afterEach / fixture 的 use() 之後 |
每支測試結束後 |
afterAll |
一個測試檔(或 describe)的測試跑完後 |
| teardown project | 所有測試都跑完後,全部測試專案只會執行一次 |
teardown project 不會一個測試檔跑完就清一次。若要在某些測試結束後馬上清理跟該測試有關的東西,用 afterAll 或 afterEach;若是要清理 .auth 這種整場測試共用的檔案,就交給 teardown project。
除了 .auth,測試過程中也可能產生暫存檔,例如匯出的報表。用一個小例子示範,順便帶出平行執行時的命名問題:
// tests/day28-temp-data.spec.ts
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const tmpDir = path.join(__dirname, '../test-data/tmp');
for (const n of [1, 2, 3, 4]) {
test(`產生暫存匯出檔 ${n}`, async ({}, testInfo) => {
fs.mkdirSync(tmpDir, { recursive: true });
const file = path.join(
tmpDir,
`export-w${testInfo.parallelIndex}-${Date.now()}-${n}.csv`
);
fs.writeFileSync(file, 'id,name\n1,poster');
expect(fs.existsSync(file)).toBe(true);
});
}
在 global.teardown.ts 裡多清一個資料夾:
const tmpDir = path.join(__dirname, '../test-data/tmp');
teardown('清除暫存檔', async () => {
console.log('>>> teardown:清除 test-data/tmp');
fs.rmSync(tmpDir, { recursive: true, force: true });
});
先把清除暫存檔那支 teardown 註解掉跑一次,ls test-data/tmp 可以看到 4 個檔名都不一樣,而且分別帶著 w0、w1;接著把剛剛的註解取消掉再跑一次,會發現test-data/tmp資料夾被清掉了。
補充:上一篇 worker 專屬帳號的 storageState 存在
test-results/.auth/底下。test-results是 Playwright 的輸出資料夾,每次執行前會自動清空,所以這類放在輸出資料夾裡的檔案不需要自己清。
上面的檔名刻意帶了 parallelIndex 和時間戳記。如果兩個 worker 同時寫入同一個檔名,就會互相覆蓋,資料也是一樣,兩個 worker 同時建立名稱相同的資料,斷言時可能抓到別人的那筆,清理時也可能刪錯。
常見做法是組合這幾個元素:
e2e-,讓人(和清理程式)一眼認出是測試資料testInfo.parallelIndex,分辨是哪個 workerreact-admin demo 的資料層是 FakeRest,資料存在瀏覽器的記憶體裡,重新載入頁面就會重置,每個新的 context 也都拿到一份全新的資料,所以在這個 demo 裡,測試新增的商品本來就不會殘留在資料庫中。
但在串接真實後端的專案中,測試新增的資料會留在資料庫裡,所以也需要清理資料庫裡的測試資料。以下程式碼是示意寫法,假設後端有 /api/products 這組 API。
teardown project 要等所有測試都跑完才會執行。如果後端資料都留到最後才清,執行過程中這些資料一直留在資料庫裡,其他測試可能會看到它,例如斷言列表筆數或搜尋結果時被干擾。所以單支測試自己建立的資料,比較適合在這支測試結束時就清掉。
做法是把「建立資料」和「清理資料」包在同一個 fixture 裡,用 Playwright 內建的 request fixture 直接呼叫 API:
// fixtures/api-data.ts(示意:假設後端有 /api/products)
import { test as base, expect } from '@playwright/test';
type Product = { id: number; reference: string };
export const test = base.extend<{ product: Product }>({
product: async ({ request }, use, testInfo) => {
// 準備:用 API 建立一筆這支測試專用的商品
const res = await request.post('/api/products', {
data: {
reference: `e2e-w${testInfo.parallelIndex}-${Date.now()}`,
price: 10,
},
});
expect(res.ok()).toBeTruthy();
const product: Product = await res.json();
await use(product);
// 清理:測試結束後刪掉這筆資料
const del = await request.delete(`/api/products/${product.id}`);
expect(del.ok()).toBeTruthy();
},
});
export { expect };
測試裡直接拿 product 來用:
import { test, expect } from '../fixtures/api-data';
test('編輯頁顯示商品名稱', async ({ page, product }) => {
await page.goto(`/#/products/${product.id}`);
await expect(page.getByLabel('Reference')).toHaveValue(product.reference);
});
這個寫法有幾個好處:
use() 之後的程式碼一定會執行:這是前面介紹 fixture 時提過的特性,就算測試失敗,fixture 的收尾仍然會跑,資料不會留下來parallelIndex 和時間戳記,平行執行時不會刪到別人的資料request 的 baseURL 會沿用 config 的設定;真實專案的 API 通常還需要驗證,可以在 config 的 use.extraHTTPHeaders 帶上 token,但這部分不是今天的重點,有興趣可以再去官方文件查看。
fixture 只清自己這支測試建立的資料。如果測試執行到一半整個程序被中斷(例如在終端機按了 Ctrl+C),fixture 的清理可能來不及跑,資料就會殘留下來;另外也可能有不屬於任何單支測試、需要在整場測試結束後統一清理的資料。
這類資料就適合交給 teardown project,我們在所有測試結束後,把帶有 e2e- 前綴的資料一次清掉:
// tests/global.teardown.ts(示意:加在同一個檔案裡)
import { test as teardown, expect } from '@playwright/test';
teardown('清除殘留的測試資料', async ({ request }) => {
const res = await request.get('/api/products', {
params: { reference_like: 'e2e-' },
});
expect(res.ok()).toBeTruthy();
const leftovers: { id: number }[] = await res.json();
for (const item of leftovers) {
await request.delete(`/api/products/${item.id}`);
}
console.log(`>>> teardown:清除 ${leftovers.length} 筆殘留資料`);
});
查詢參數 reference_like 只是示意,實際要依後端 API 的設計調整。這也是為什麼前面強調資料名稱要有固定前綴,有了前綴,清理程式可以明確知道哪些是測試過程中產生的資料,而不會誤刪。
這邊簡單彙整下fixture 和 teardown project 在資料清理上的差異:
| fixture 收尾 | teardown project | |
|---|---|---|
| 執行時機 | 每支測試結束後 | 所有測試結束後,只跑一次 |
| 清理範圍 | 這支測試自己建的資料 | 所有殘留的測試資料、共用檔案 |
| 適合的情境 | 避免執行過程中資料互相干擾 | 整場測試結束後的統一收尾 |
teardown: 'teardown',teardown project 會在所有依賴 setup 的 project 跑完後執行一次;只想清單一檔案的東西,用 afterAll 或 afterEach 就好。parallelIndex + 時間戳記」命名,斷言和清理才不會抓到別人的資料。request 自己建、自己清,避免執行過程中互相干擾;整場結束後的統一收尾、清理殘留資料,則交給 teardown project。到這裡,登入與資料流程的工具已經都介紹完了:setup、storageState、多角色、worker 專屬帳號、參數化、teardown。下一篇是最後一個緩衝日,我們會把這些概念串起來,在後台做一輪完整的巡檢。