今天我們會來處理檔案上傳跟下載功能的測試,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 資料夾裡的檔案。
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 按鈕。它匯出目前清單資料為 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來協助建立測試程式碼。