iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
自我挑戰組

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

Day 19 - 處理瀏覽器新分頁:監聽 popup 事件與操作新分頁

  • 分享至 

  • xImage
  •  

在 Day 17 我們處理了下拉選單和 Dialog/Modal的測試:它們會蓋住畫面,但元素仍在原本的網頁裡。Day 18 的 iframe 則是在同一個分頁裡進入另一份文件。

今天要處理的情境是使用者點擊連結後,瀏覽器會開出另一個新的分頁。在 Playwright 裡,這代表我們需要取得一個新的 Page 物件,之後的操作與斷言也必須從這個新物件開始。

小提醒:Playwright 將由目前頁面開出的新分頁稱為 popup,而不是 Day 17 那種 UI 彈窗。

先建立可控制的多分頁練習頁

目前的 react-admin demo 沒有 target="_blank" 或 window.open() 的現成功能;Dashboard 上的外部連結也會在原分頁導覽。因此,這次和 Day 18 一樣,新增一個很小的本地 fixture 來練習瀏覽器行為。

練習情境如下:

已登入的 react-admin 後台(原分頁)
└── 多分頁練習頁
    └──「開新分頁查看訂單摘要」
        └── 訂單摘要(新分頁)

1. 建立多分頁練習頁

新增 demo/src/multi-page/MultiPageDemo.tsx:

import { Button, Card, CardContent, Typography } from '@mui/material';

const MultiPageDemo = () => (
  <Card>
    <CardContent>
      <Typography variant="h5" component="h1" gutterBottom>
        多分頁練習:訂單摘要
      </Typography>
      <Typography paragraph>
        點擊連結後,會在瀏覽器的新分頁開啟訂單摘要。
      </Typography>
      <Button
        component="a"
        href="/order-preview.html"
        target="_blank"
        rel="noopener noreferrer"
      >
        開新分頁查看訂單摘要
      </Button>
    </CardContent>
  </Card>
);

export default MultiPageDemo;

target="_blank" 是讓瀏覽器建立一張新分頁。rel="noopener noreferrer" 是外開頁面常見的安全設定,避免新頁透過 window.opener 操作原頁面。

2. 建立新分頁顯示的內容

新增 demo/public/order-preview.html:

<!doctype html>
<html lang="zh-Hant">
  <head>
    <meta charset="UTF-8" />
    <title>訂單摘要</title>
  </head>
  <body>
    <main>
      <h1>訂單摘要</h1>
      <p>訂單編號:RA-2026-001</p>
      <p>金額:NT$ 1,280</p>

      <button id="confirm" type="button">確認已閱讀</button>
      <p id="result" role="status"></p>
    </main>

    <script>
      const confirmButton = document.querySelector('#confirm');
      const result = document.querySelector('#result');

      confirmButton.addEventListener('click', () => {
        result.textContent = '訂單摘要已確認';
      });
    </script>
  </body>
</html>

3. 註冊路由

在 demo/src/App.tsx 先加入 import:

import MultiPageDemo from './multi-page/MultiPageDemo';

再在既有的 CustomRoutes 區塊加入:

<Route path="/multi-page-demo" element={<MultiPageDemo />} />

啟動 demo、登入後開啟 http://localhost:8000/#/multi-page-demo。先手動點擊連結,確認瀏覽器真的多出一張分頁,並確認「確認已閱讀」按鈕會顯示結果文字,接著再開始寫測試。

新分頁和 URL 改變是兩回事

當連結使用 target="_blank" 時,瀏覽器 context 會同時有兩張分頁:

BrowserContext
├── page     原本的多分頁練習頁
└── newPage  新開的訂單摘要頁

在這次的範例中,原頁面是 /#/multi-page-demo,新頁面是 /order-preview.html,所以 URL 的確不同。不過,URL 不同並不是判斷 Page 不同的決定性依據,因為兩個瀏覽器分頁也可以載入相同 URL;同一個分頁也可以導覽到不同 URL。

對 Playwright 來說,當開出新分頁的時候,原本的 page 與新分頁的 DOM 彼此獨立,所以不能用原本的 page 去尋找新分頁裡的元素。

例如下面這段會失敗:

await page.getByRole('link', { name: '開新分頁查看訂單摘要' }).click();

await expect(
  page.getByRole('heading', { name: '訂單摘要' })
).toBeVisible();

點擊後,原本的 page 仍在多分頁練習頁;但 訂單摘要 標題屬於另一張分頁。

先監聽 popup,再點擊連結

建立 playwright-tests/tests/day19-multi-page.spec.ts:

import { test, expect } from '../fixtures/fixtures';

test('可以在新分頁確認訂單摘要', async ({ loggedInPage: page }) => {
  await page.goto('/#/multi-page-demo');

  // 先建立等待,暫時不要 await。
  const newPagePromise = page.waitForEvent('popup');

  await page
    .getByRole('link', { name: '開新分頁查看訂單摘要' })
    .click();

  const newPage = await newPagePromise;
  await newPage.waitForLoadState();

  // newPage 與原本的 page 是不同的 Page 物件。
  expect(newPage).not.toBe(page);

  await expect(newPage).toHaveURL(/order-preview\.html/);
  await expect(
    newPage.getByRole('heading', { name: '訂單摘要' })
  ).toBeVisible();

  await newPage.getByRole('button', { name: '確認已閱讀' }).click();

  await expect(newPage.getByRole('status')).toHaveText('訂單摘要已確認');
});

執行測試:

npx playwright test day19-multi-page --project=chromium

這個測試有四個重點:

  1. page.waitForEvent('popup') 先建立一個 Promise,等待「由目前這張頁面開出的新分頁」的事件。
  2. 監聽器必須在 click() 之前建立;如果先點擊,新分頁可能已經開出,之後才開始等待就會因為等不到這個event而 timeout。
  3. newPage 是訂單摘要所在的頁面,所以標題、按鈕與 status 都要從 newPage 開始定位。
  4. expect(newPage).not.toBe(page) 直接確認兩者不是同一個 Page 物件;URL 斷言只是在確認連結導向正確內容,不是用來定義分頁身分。

另外我們用 waitForLoadState() 等待新頁面進入可操作狀態,而不是用 waitForTimeout() 去猜測需要等待幾秒,因為頁面載入速度不固定,用固定時間等待會不穩定。

popup event 和 page event 的範圍

看到 popup 這個名稱,很容易誤以為它和「彈窗 UI」有關。實際上它描述的是目前這張 page 開出新分頁的這個事件。

target="_blank" 動作實際上會觸發兩件事:

目前的 page 開出新分頁
├── 目前的 page 發出 popup event
└── BrowserContext 發出 page event

兩種等待寫法都會取得新 Page,差別在監聽範圍:

寫法 監聽範圍 適合情境
page.waitForEvent('popup') 目前這張頁面開出的新分頁 已知點擊來源就是目前頁面;本篇主案例
context.waitForEvent('page') 這個 BrowserContext 裡任何新開的分頁 不知道哪一張頁面會開新分頁

我們的例子是明確知道是多分頁練習頁上的連結開出訂單摘要,所以選擇範圍較小、語意也更清楚的 popup event就足夠了。

另外,waitForEvent() 不論等待哪一種 event,都是一次性等待:它收到第一個符合條件的事件就會結束。若要持續記錄每一次事件,才會使用 page.on() 或 context.on();這屬於長期監聽,不在今天我們討論的範疇。

延伸觀察:從訂單摘要再開一張訂單明細頁

我們再為訂單摘要頁加入一條連結,來驗證 context.waitForEvent() 的監聽範圍。

<a href="/order-detail.html" target="_blank" rel="noopener noreferrer">
  開新分頁查看訂單明細
</a>

並建立一個只含 <h1>訂單明細</h1> 的 demo/public/order-detail.html。

把下面的測試程式碼加入剛剛使用的playwright-tests/tests/day19-multi-page.spec.ts,然後跑跑看這個測試:

test('context 可以取得不同頁面開出的新分頁', async ({ loggedInPage: page }) => {
  await page.goto('/#/multi-page-demo');

  const context = page.context();

  // A 開 B:兩個 event 都會取得同一個訂單摘要頁。
  const popupFromA = page.waitForEvent('popup');
  const pageFromContext = context.waitForEvent('page');

  await page.getByRole('link', { name: '開新分頁查看訂單摘要' }).click();

  const [orderPreview, sameOrderPreview] = await Promise.all([
    popupFromA,
    pageFromContext,
  ]);

  expect(orderPreview).toBe(sameOrderPreview);

  // B 開 C:由 context 繼續等待,不依賴是哪一張 page 開出的。
  const detailFromContext = context.waitForEvent('page');

  await orderPreview
    .getByRole('link', { name: '開新分頁查看訂單明細' })
    .click();

  const orderDetail = await detailFromContext;

  await expect(
    orderDetail.getByRole('heading', { name: '訂單明細' })
  ).toBeVisible();

  // C 的 DOM 不屬於 A;從原本的 page 找不到訂單明細標題。
  await expect(
    page.getByRole('heading', { name: '訂單明細' })
  ).not.toBeVisible();
});

最後的負向斷言驗證了 Page 的邊界:orderDetail(C)能找到 訂單明細,但原本的 page(A)不能。若改成下面這種正向斷言,測試會因為 A 的 DOM 裡根本沒有該標題而 timeout:

// 不要放進正式測試:這是刻意會失敗的寫法。
await expect(
  page.getByRole('heading', { name: '訂單明細' })
).toBeVisible();

當 A 開 B 時,A 的 popup event 與 context 的 page event 都能取得 B;但由 B 開 C 時,context 仍能取得 C,而 A 無法直接操作 C 的 DOM。

小結

今天處理的是瀏覽器新增分頁的情境:

  1. Dialog/Modal 屬於原本 page 的 UI;target="_blank" 才會建立新的 Page。
  2. 對已知由目前頁面觸發的新分頁,先用 page.waitForEvent('popup'),再點擊連結。
  3. 取得 newPage 後,所有 locator、操作和斷言都要從 newPage 開始。
  4. URL 可以協助驗證連結導向,但不能用來定義兩個頁面是否為不同 Page。
  5. context.waitForEvent('page') 的監聽範圍更大,適合來源不確定的新分頁。

下一篇會處理檔案上傳與下載,它們同樣需要在正確的時機等待瀏覽器事件。


上一篇
Day 18 - 處理 iframe:使用 frameLocator 定位內層元素
下一篇
Day 20 - 檔案上傳與下載:把檔案交給網頁,再把匯出結果帶回來
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言