iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
自我挑戰組

Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記系列 第 28 篇

Day 28 - Global Teardown:測試結束後的收尾

  • 分享至 

  • xImage
  •  

前兩篇介紹了用 setup project 在所有測試開始前先完成登入,並把登入狀態存起來,讓測試不必特別處理登入。

不過測試跑完之後,這些檔案還留在那裡;如果測試過程中建立了暫存檔、或在後端新增了資料,需要有清理機制還原測試資料,避免後續的測試因為資料被污染而失敗。今天就要說明 playwright 的測試資料清理機制。

teardown 是什麼?

setup(登入、存 storageState)
   │
   ├──> chromium ─┐
   ├──> firefox  ─┼──> teardown(收尾清理)
   └──> webkit   ─┘

teardown 是在所有測試都跑完之後,執行一次的收尾步驟。它跟 setup 剛好相反:setup 在所有測試開始前把環境準備好,teardown 則在所有測試結束後把環境恢復乾淨,例如刪掉 setup 產生的 .auth 檔、清掉測試留下的暫存檔或資料。

Playwright 提供兩種做法來設定 teardown:

  1. teardown project:跟 Day26 的 setup 一樣,把收尾寫成一個 project
  2. 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' 決定。

實作:清掉 .auth 檔

步驟一:寫 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 反而失敗。

步驟二:修改 config

在 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 不變
],

檔名要注意兩件事:

  • teardown project 的 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)

可以看到:

  1. 執行順序:先印出 setup 的兩行登入訊息,最後才印出 >>> teardown:清除 .auth
  2. 只指定 chromium 也會收尾:因為 chromium 的 dependency 裡有 setup,setup 的 teardown 又指定了 teardown project,所以即使只下 chromium,也會一起跑 teardown
  3. 檔案被清掉了:跑完後執行 ls playwright/.auth,資料夾已經不存在;下次執行時 setup 會重新產生

測試失敗時,teardown 還會跑嗎?

收尾的程式碼最怕的情況是當測試一失敗,清理就被跳過。

我們故意讓一支測試失敗,把 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 執行。

跟 afterAll、afterEach 差在哪?

清理也能寫在 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,分辨是哪個 worker
  • 時間戳記或隨機字串,分辨是哪一次執行

真實專案:用 fixture 搭配 API 清後端資料

react-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);
});

這個寫法有幾個好處:

  1. 用 API 準備資料比走 UI 快很多:測試的重點是「編輯頁」,沒必要每次都透過新增頁面點一輪來建資料
  2. use() 之後的程式碼一定會執行:這是前面介紹 fixture 時提過的特性,就算測試失敗,fixture 的收尾仍然會跑,資料不會留下來
  3. 每支測試只清自己的那筆:資料名稱帶著 parallelIndex 和時間戳記,平行執行時不會刪到別人的資料

request 的 baseURL 會沿用 config 的設定;真實專案的 API 通常還需要驗證,可以在 config 的 use.extraHTTPHeaders 帶上 token,但這部分不是今天的重點,有興趣可以再去官方文件查看。

用 teardown 清掉殘留的資料

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
執行時機 每支測試結束後 所有測試結束後,只跑一次
清理範圍 這支測試自己建的資料 所有殘留的測試資料、共用檔案
適合的情境 避免執行過程中資料互相干擾 整場測試結束後的統一收尾

今日小結

  1. teardown project:在 setup project 加上 teardown: 'teardown',teardown project 會在所有依賴 setup 的 project 跑完後執行一次;只想清單一檔案的東西,用 afterAll 或 afterEach 就好。
  2. 平行執行下產生的檔案名稱不能重複:測試產生的資料用「前綴 + parallelIndex + 時間戳記」命名,斷言和清理才不會抓到別人的資料。
  3. 清後端資料:單支測試的資料用 fixture 搭配 request 自己建、自己清,避免執行過程中互相干擾;整場結束後的統一收尾、清理殘留資料,則交給 teardown project。

到這裡,登入與資料流程的工具已經都介紹完了:setup、storageState、多角色、worker 專屬帳號、參數化、teardown。下一篇是最後一個緩衝日,我們會把這些概念串起來,在後台做一輪完整的巡檢。


上一篇
Day 27 - storageState 進階:多角色與 worker 專屬帳號
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言