iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
自我挑戰組

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

Day 20 - 檔案上傳與下載:把檔案交給網頁,再把匯出結果帶回來

  • 分享至 

  • xImage
  •  

今天我們會來處理檔案上傳跟下載功能的測試,Orders 清單右上角有 react-admin 的 ExportButton,按下後會匯出 CSV;這是一個可以直接拿來測試的真實下載流程。然而,目前這個 demo 網站並沒有上傳的功能,因此這篇我們也會針對上傳功能補一個最小的 fixture,下載則直接使用 Orders 的 Export。上傳時要讓測試把檔案交給網頁;下載時則要先接住瀏覽器發出的下載事件,並在雲端環境把結果保存到測試可存取的位置。

先準備一個只做上傳的練習頁

這個 fixture 不模擬後端儲存檔案。它只負責接收使用者選的檔案,並把檔名顯示在畫面上;這樣我們可以把注意力放在 Playwright 如何操作檔案 input,而串接後端檔案服務的部分不是我們這篇的重點。

真實網站常會把 <input type="file"> 隱藏,只留下樣式較完整的「選擇附件」按鈕。按鈕被點擊時,再由程式觸發隱藏 input 的 click()。這個 fixture 也刻意採用相同結構,讓我們能比較直接操作 input 的 setInputFiles(),以及從使用者看得到的按鈕開始操作時會用到的 filechooser。

新增 demo/src/file-upload/FileUploadDemo.tsx:

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

const FileUploadDemo = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [fileName, setFileName] = useState('');

  return (
    <Card>
      <CardContent>
        <Typography variant="h5" component="h1" gutterBottom>
          檔案上傳練習
        </Typography>

        <input
          ref={inputRef}
          id="attachment"
          type="file"
          hidden
          onChange={event => {
            setFileName(event.target.files?.[0]?.name ?? '');
          }}
        />
        <Button
          type="button"
          variant="contained"
          onClick={() => inputRef.current?.click()}
        >
          選擇附件
        </Button>

        <p role="status">
          {fileName ? `已選擇:${fileName}` : '尚未選擇檔案'}
        </p>
      </CardContent>
    </Card>
  );
};

export default FileUploadDemo;

接著在 demo/src/App.tsx 的 CustomRoutes 加入 /file-upload-demo 路由。另在測試專案內放一個小型、可提交到版本控制的檔案,例如:

playwright-tests/test-data/attachment.txt

內容可以只是 Playwright upload demo。測試資料跟程式碼一起管理,才能避免在 CI 或其他人的電腦上找不到你本機 Downloads 資料夾裡的檔案。

上傳:直接對 file input 使用 setInputFiles()

建立 playwright-tests/tests/day20-file.spec.ts:

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

test('可以選擇要上傳的附件', async ({ loggedInPage: page }) => {
  await page.goto('/#/file-upload-demo');

  const filePath = path.resolve(__dirname, '../test-data/attachment.txt');

  await page.locator('#attachment').setInputFiles(filePath);

  await expect(page.getByRole('status')).toHaveText(
    '已選擇:attachment.txt'
  );
});

這邊使用css selector的方式來定位檔案上傳的元素,主要是因為 <input type="file"> 在這個範例中被設定成 hidden,在Accessibility Tree 中不存在被隱藏的元素,所以透過 Accessibility Tree 來定位的 getByRole 無法選取到,所以這邊我們用 id attribute 來做定位。

setInputFiles() 直接把指定路徑的檔案交給 <input type="file">。因此測試不是在驗證作業系統的檔案視窗,而是驗證網頁收到檔案後的行為,這個例子是確認畫面正確顯示被選取的檔名。

如果頁面有清除附件的功能,可以傳入空陣列清空目前選擇:

await page.locator('#attachment').setInputFiles([]);

從「選擇附件」按鈕開始:filechooser

目前畫面上看不到真正的 file input;使用者點擊的是「選擇附件」按鈕。按鈕內的 inputRef.current?.click() 會觸發隱藏 input 的選檔流程,瀏覽器在這個時間點發出 filechooser event。

如果測試要重現使用者從按鈕開始的流程,就先等待這個 event,再點擊按鈕:

const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: '選擇附件' }).click();

const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(filePath);

這個寫法和 Day 19 等待 popup 相同:先建立等待,再觸發動作。如果先點擊按鈕,瀏覽器可能已經送出 event,後面的 waitForEvent() 就只能一直等到 timeout。

setInputFiles() 與 fileChooser.setFiles() 最後都是把同一個檔案交給 input,差別在測試從哪裡開始:可以直接定位 input 時,setInputFiles() 最短;input 被隱藏、而你想從使用者實際會點的控制項開始時,使用 filechooser 才能接住這個中間事件。

下載:使用 Orders 的 Export 按鈕

切到 Orders 清單頁,右上方工具列已有 Export 按鈕。它匯出目前清單資料為 CSV,正好是一個真實的下載流程。

下載不是在 click 後用固定秒數等待,而是等待 download event。要特別注意 Promise 建立的順序:如果先 click 才開始等待,下載速度很快時可能已經錯過事件。

test('可以匯出 Orders 清單', async ({ loggedInPage: page }, testInfo) => {
  await page.goto('/#/orders');

  await expect(page.getByRole('heading', { name: 'Orders' })).toBeVisible();

  // 先監聽,暫時不要 await。
  const downloadPromise = page.waitForEvent('download');

  await page.getByRole('button', { name: 'Export' }).click();

  const download = await downloadPromise;

  expect(download.suggestedFilename()).toMatch(/\.csv$/);

  const savedFile = testInfo.outputPath(download.suggestedFilename());
  await download.saveAs(savedFile);
});

這裡的 suggestedFilename() 是瀏覽器建議使用的檔名;實際匯出資料筆數、瀏覽器與網站設定都可能讓完整名稱不同。因此範例只斷言穩定的副檔名,而不是把整個檔名寫死。

為什麼雲端環境要用 saveAs()?

在本機直接跑測試時,可能會想到這樣寫:

const downloadedPath = await download.path();

但在雲端或遠端瀏覽器環境,download.path() 會失敗。因為它想取得的是瀏覽器執行環境中的暫存檔案路徑,而遠端機器的路徑不能直接交給目前執行測試的程式使用。

此時應使用 download.saveAs():

const savedFile = testInfo.outputPath(download.suggestedFilename());
await download.saveAs(savedFile);

saveAs() 會等待下載完成,並把檔案複製到你指定的路徑。testInfo.outputPath() 則讓每個測試有各自的輸出資料夾,避免平行執行時互相覆蓋檔案。

另一個容易忽略的細節是:BrowserContext 關閉時,尚未另存的下載檔也會被清除。換句話說,suggestedFilename() 適合驗證下載是否被觸發;若後面還要讀取內容或保留檔案作為測試產物,就呼叫 saveAs()。

下載後要驗證到什麼程度?

測試範圍要跟需求相稱。若需求只是「使用者可以匯出訂單」,確認下載 event、CSV 副檔名與成功儲存已足夠。

如果需求還包含「匯出的欄位與資料正確」,可以在 saveAs() 後讀取 CSV,例如驗證標題列中有 reference:

import { readFile } from 'fs/promises';

const csv = await readFile(savedFile, 'utf8');
expect(csv).toContain('reference');

一般來說不需要驗證所有訂單、所有欄位和完整 CSV 排序,因為那會讓失敗原因變得難以判讀。挑選具代表性的欄位或一筆可預期的資料做驗證即可。

小結

今天兩個案例的共同點,是讓 Playwright 等待真正的瀏覽器事件,而不是猜測時間:

情境 主要 API 驗證重點
上傳 locator.setInputFiles() 網頁是否接收到正確檔案
下載 page.waitForEvent('download') 是否真的觸發下載
雲端儲存下載檔 download.saveAs() 將遠端下載複製到可存取的測試輸出路徑

上傳用 setInputFiles() 把測試資料送進網頁;下載則先等待 download,再以 saveAs() 保留結果。

下一篇我們會分享如何透過AI來協助建立測試程式碼。


上一篇
Day 19 - 處理瀏覽器新分頁:監聽 popup 事件與操作新分頁
下一篇
Day 21 - AI 輔助生成測試:如何與AI協作產生高品質測試
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言