做Web自动化测试,只要业务里带了文件上传下载,你迟早会撞上Playwright。很多团队从Selenium迁移过来,第一反应是“上传不是得用AutoIt/Robot类去操作系统弹窗吗”,但Playwright的思路完全不同:它把文件上传直接收敛成set_input_files这类DOM级API,下载则通过事件监听和自动保存机制完成。这篇文章我会从上传控件的不同形态讲起,把文件上传、下载、iframe内上传、拖拽上传、下载鉴权、CI集成这些场景一次讲透,适合刚接触Playwright、或者已经被上传下载搞到头秃的测试开发同学直接“抄作业”。
1. Playwright处理文件上传:set_input_files为什么是首选
1.1 先看上传控件的四种形态
很多人在Playwright里卡住,不是不会写set_input_files,而是没分清页面上的上传控件到底属于哪种形态。我按实际项目中遇到的频率排个序:
- 原生
<input type="file">,这种最老实,Playwright可以直接喂文件。 - 按钮或div触发后动态创建隐藏的
<input type="file">,很多组件库都是这么干的,比如Ant Design Upload、ElementUI的Upload。 - iframe里嵌套的上传框,常见于老后台系统、富文本编辑器、第三方供应商页面。
- 拖拽上传区域,比如直接把文件拖进一个框里,底层可能是隐藏input,也可能完全不是input,而是监听drop事件后读取
DataTransfer。
为什么说要先分清形态?因为Playwright的set_input_files本质上是在DOM层面直接给input[type=file]赋值文件路径,它不依赖操作系统弹窗,也不需要扫描屏幕坐标。只要页面上存在这样一个input元素,不管它是隐藏还是可见,都可以用。但如果页面根本没有input,而是自定义拖拽组件,那就要走另外的路子。这也是我见过最多人“卡死”的地方——他们把API用错了对象。
1.2 set_input_files的核心用法与参数细节
先看最标准的用法:
// 给单个原生input传文件 await page.locator('input[type="file"]').setInputFiles('tests/data/report.csv'); // 上传后断言页面出现了文件名 await expect(page.locator('.file-name')).toHaveText('report.csv');如果上传控件在iframe里,先定位frame,再在frame内定位input:
const frame = page.frameLocator('iframe[name="uploadFrame"]'); await frame.locator('input[type="file"]').setInputFiles('tests/data/report.csv');这里有个细节:setInputFiles不但支持字符串路径,也支持文件路径数组,还支持直接在内存中构造文件内容。日常测试中,我经常用内存文件代替真实文件,特别是做接口异常场景时,根本不需要准备一堆垃圾文件放在仓库里:
await page.locator('input[type="file"]').setInputFiles({ name: 'test.csv', mimeType: 'text/csv', buffer: Buffer.from('id,name\n1,zhangsan\n2,lisi') });这个用法很多教程没仔细讲。它其实对应了后端收到的Multipart文件流,name、mimeType、buffer三个字段最终会被组装成一次完整的上传请求。测试上传接口的文件类型限制时,把mimeType改成application/pdf再传一段文本内容,就能验证后端到底是不是只校验了MIME类型而不校验真实内容。注意,这不是让你去钻漏洞,而是测试服务端对异常文件的防护是否到位,是正当的测试场景。
1.3 多文件上传与内存文件的坑
多文件上传同样简单:
await page.locator('input[type="file"]').setInputFiles([ 'tests/data/a.txt', 'tests/data/b.txt', 'tests/data/c.txt' ]);但我实际踩过一个坑:某些组件支持多文件,但要求按顺序上传,或者第一个文件上传后组件内部会重置input。这种情况下,如果一次性setInputFiles传多个文件,组件可能只取第一个。解决办法是逐个上传,每次上传后等页面出现对应的文件条目,再操作下一次。
另一个容易忽略的坑是setInputFiles传文件路径时,Playwright对路径的处理是相对于当前工作目录的,而不是相对于测试文件。如果路径写错,会直接报 “File does not exist”。我建议所有测试数据文件统一放在项目根目录的test-data或fixtures目录下,然后用path.join(__dirname, '../../test-data/...')拼绝对路径。这样不管从哪个目录执行,都不会因为路径问题在CI上突然挂掉。
内存文件方式还要注意编码。用Buffer.from构造内容时,默认UTF-8,如果后端是GBK编码的老系统,中文文件名或内容可能会乱码。遇到这种情况,老老实实放一个真正的GBK编码文件到测试数据目录,别在内存里硬凑。
2. 文件下载:从下载事件到保存落盘的全链路实现
2.1 expect_download到底监听了什么
下载和上传不一样,下载无法靠定位一个DOM元素直接赋值。浏览器触发下载时,会走独立的下载流程,Playwright通过download事件来捕获。标准写法是同时启动“等待事件”和“点击下载”两个动作:
const [download] = await Promise.all([ page.waitForEvent('download'), page.locator('button#downloadBtn').click() ]); const fileName = download.suggestedFilename(); const savePath = 'downloads/' + fileName; await download.saveAs(savePath);很多新人会先click再waitForEvent,结果大概率超时。原因是下载事件可能在点击后瞬间就触发了,等你开始监听时事件已经过去。用Promise.all并行启动,才能保证事件不会漏掉。
老版本Playwright还需要在创建context时显式开启下载支持:
const context = await browser.newContext({ acceptDownloads: true });新版本虽然默认接受下载,但我建议仍然显式声明,省得团队里有人升级了Playwright版本之后行为不一致。显式声明不是坏习惯,反而是可维护性的体现。
2.2 下载文件的保存、重命名与路径管理
download.suggestedFilename()是浏览器根据响应头Content-Disposition给出的文件名。但实际项目中这个文件名可能乱码、可能缺失,也可能带一些路径字符,直接用它落盘有风险。我一般会先做一次安全化处理,只保留文件名,去掉路径分隔符,避免saveAs时被系统拒绝。
const rawName = download.suggestedFilename(); const safeName = rawName.replace(/[\\\/:*?"<>|]/g, '_'); const savePath = path.join('downloads', safeName); await download.saveAs(savePath);这里还涉及一个关键点:下载目录不要用工作目录下的相对路径。因为CI并行执行测试时,多个worker可能同时写同一个目录,文件名一旦重复就是互相覆盖。我习惯在测试开始时为每个用例创建独立目录:
const dir = path.join('downloads', `run-${Date.now()}-${Math.random().toString(36).slice(2)}`); fs.mkdirSync(dir, { recursive: true });最后在afterEach或finally里清理整个目录。
2.3 下载进度的等待策略:别再写死sleep
文件下载完成不等于下载事件触发,事件触发的时机是浏览器收到响应并开始下载,而文件落盘可能还在进行。download.saveAs()本身会等待文件流写入完成,所以大多数场景下,调用完saveAs后文件就是可用的。
但如果下载的是大文件,或者你需要对下载结果做二次处理,靠setTimeout等待是下策。我推荐用Playwright的expect.poll做轮询断言,等文件真正出现在磁盘上再继续:
await expect.poll(() => { return fs.existsSync(savePath) ? fs.statSync(savePath).size : 0; }, { timeout: 30000 }).toBeGreaterThan(0);这种方式比固定sleep稳定得多。比如下载一个1GB的安装包,固定sleep3秒可能不够,sleep10秒又白白浪费大量时间。轮询策略能保证“文件没落盘就继续等,落盘了立刻继续”。
另外,下载场景中page.close()的时机也要注意。有些人在下载完成后立刻关闭页面或浏览器,如果下载还没完全写入到本地,再调用download.path()或saveAs()可能会直接抛异常,常见报错之一就是“target closed”。稳妥的顺序永远是:先拿Download对象,先保存到本地,再关闭页面。
3. 绕不开的实战场景:iframe上传、拖拽上传、非input控件的处理
3.1 iframe里的上传控件怎么定位
后台系统和低代码平台里,iframe嵌套是最常见的坑。PlayTouch处理iframe不需要切换上下文,它用frameLocator就能直接穿透进去,这个设计比Selenium的switch_to.frame顺手很多。
const frame = page.frameLocator('#uploadIframe'); await frame.locator('input[type="file"]').setInputFiles('tests/data/avatar.png');如果上传控件不在input里,而是点击按钮弹出了文件选择框,可以用filechooser事件。Playwright把所有上传控件的“打开文件对话框”行为统一抽象成了filechooser,触发对话框后,直接setFiles即可:
const [chooser] = await Promise.all([ page.waitForEvent('filechooser'), frame.locator('button.upload').click() ]); await chooser.setFiles('tests/data/avatar.png');这个方案对自定义控件特别有用,因为它不关心元素是不是input,只要最终用户操作会触发浏览器文件选择框,就一定能拦截到。
3.2 拖拽上传的模拟方式
拖拽上传是最让人纠结的场景。Playwright没有提供类似dragAndDropFile的现成API,因为浏览器安全机制不允许脚本直接构造一个带本地文件路径的拖拽对象。但是,如果拖拽组件底层最终会转换成File对象,那我们可以在页面上下文里构造DataTransfer,派发drop事件来模拟。
await page.locator('.drop-zone').evaluate((el) => { const dataTransfer = new DataTransfer(); const file = new File(['模拟文件内容'], 'mock.txt', { type: 'text/plain' }); dataTransfer.items.add(file); const dropEvent = new DragEvent('drop', { dataTransfer, bubbles: true, cancelable: true }); el.dispatchEvent(dropEvent); });这里有个前提:页面代码在接收drop事件时会读取DataTransfer中的File对象。如果组件比较老,用的是dataTransfer.files,这个方案可行;如果组件只接受拖拽过程中产生的dragenter/dragover事件,则还需要补发前置事件。
不过我的真实建议是,如果这个拖拽区域内部其实有一个隐藏的<input type="file">,那就直接找这个input,用setInputFiles,不要为了“看起来更像真实拖拽”而选择更脆弱的方案。自动化测试的价值是验证业务逻辑,不是像素级还原用户操作。只有当你确实需要覆盖拖拽交互本身时,才用DataTransfer模拟。
3.3 剪贴板上传和完全非input的上传
剪贴板上传是另一个麻烦。比如富文本编辑器里支持 Ctrl+V 粘贴截图。Playwright没有官方API直接往系统剪贴板塞文件,如果你用真实剪贴板操作,又很容易因为系统权限、焦点问题变得不稳定。
我目前的处理策略是:底层有input就用setInputFiles;如果确实没有input,且不能通过DataTransfer模拟粘贴事件,就把这部分场景下沉到手动测试或组件级单元测试,而不是硬塞进E2E里。一个E2E用例如果每次跑都不稳定,它造成的噪音远大于收益。
另外,有些上传组件是点击后拉出一个弹窗,里面嵌了文件选择区域,但这个弹窗里的input可能是动态渲染的,需要等待。这时候用waitFor等待input出现再赋值即可:
const input = page.locator('.upload-modal input[type="file"]'); await input.waitFor({ state: 'attached', timeout: 10000 }); await input.setInputFiles('tests/data/file.doc');4. 下载场景的进阶处理:鉴权、大文件、并发下载
4.1 带Cookie或Token的下载如何做
很多下载接口需要登录态。用Playwright点击页面里的下载按钮时,浏览器会自动带上当前context里的Cookie,这点不需要额外处理。但如果下载不是一个普通链接,而是前端通过fetch带Authorization头去请求二进制文件,那情况就不同了。
我的做法是:优先让页面自己完成下载,而不是绕过UI用手工接口。因为你做的是E2E测试,要验证的是完整链路。如果页面里下载按钮调用了带token的接口,这个token来自应用状态,Playwright拦截不到也不该拦截。
但如果有场景需要单独验证下载接口在特定鉴权条件下是否正常,可以用context.request发起带header的请求,然后把响应体保存到本地:
const response = await context.request.get('https://example.com/api/download/file', { headers: { Authorization: 'Bearer token' } }); const buffer = await response.body(); fs.writeFileSync('downloads/file.bin', buffer);这种方式适合做接口层的下载验证,不适合替代UI层测试。两者各司其职。
4.2 大文件下载与超时设置
大文件下载给自动化带来的最大问题是等待时间不好预估。下载事件可能在点击后几秒内触发,但文件完全落盘可能要几分钟。download.saveAs()会等待文件流结束,所以只要给它足够的时间,理论上不会有问题。但测试框架本身有全局超时,比如Playwright Test默认的test timeout是30秒,下载大文件时妥妥超时。
解决办法是在用例级别把超时调大:
test('下载大文件', async ({ page }) => { test.setTimeout(180000); // 具体下载逻辑 });同时,下载完成后要立刻校验文件大小,避免只生成了一个0字节的空文件:
const stat = fs.statSync(savePath); expect(stat.size).toBeGreaterThan(1024 * 1024);如果下载过程中网络抖动导致中断,saveAs会抛异常,用例自然失败。这里不建议自己在测试里加无限重试逻辑,下载失败往往意味着环境或接口有问题,重试反而掩盖了真实故障。
4.3 并发下载多个文件时的隔离
有些页面会一次触发多个文件下载,比如勾选多个附件后点击“批量下载”。Playwright的page.on('download')可以监听多个事件,但你需要为每个事件准备独立的保存路径。
const saveDir = path.join('downloads', `batch-${Date.now()}`); fs.mkdirSync(saveDir, { recursive: true }); page.on('download', async (download) => { const savePath = path.join(saveDir, download.suggestedFilename().replace(/[\\\/]/g, '_')); await download.saveAs(savePath); }); await page.locator('button.batch-download').click(); await expect.poll(() => { const files = fs.readdirSync(saveDir); return files.length; }, { timeout: 30000 }).toBe(3);这里我特别强调目录隔离,因为多个文件如果叫同一个名字,很容易互相覆盖。如果你监听的是页面级download事件,并且想并发处理多个下载,建议用Promise.all或计数器来等待所有保存操作完成,不要在回调里直接做断言,回调里的断言失败不会被测试框架正确捕获。
5. 这些坑我边用边踩:浏览器行为、文件类型、稳定性
5.1 浏览器下载行为差异
Playwright支持Chromium、Firefox、WebKit三套浏览器,但下载行为并不完全一致。Chromium对Content-Disposition的处理最宽松,Firefox对部分文件类型可能直接展示而不是下载,WebKit在Linux环境下下载行为还可能受系统限制。
所以我建议,下载相关用例至少在Chromium上跑主流程,在Firefox和WebKit上跑冒烟。如果团队资源有限,优先保证Chromium稳定,但要在用例注释里标注“仅支持Chromium”。否则别人在另一个浏览器里跑挂了,排查半天才发现是浏览器差异,很浪费时间。
另外,headless模式下下载通常没有问题,但如果你在Docker里跑,需要保证容器内有足够的/tmp空间。下载文件先落到系统临时目录,再默认复制到saveAs指定位置,如果磁盘满了,报错会是磁盘写入失败而不是下载失败,那时候查起来得有经验。
5.2 文件类型与MIME的坑
setInputFiles传入内存文件时,mimeType字段很关键。Playwright会按照你填写的mimeType构造上传请求。如果后端校验了文件扩展名和MIME的一致性,而你填了text/plain但文件名是.png,可能会被后端拒绝。
反过来,真实文件上传时,浏览器根据文件扩展名自动判断MIME。如果你的测试数据文件扩展名和实际内容不一致,比如把.txt内容命名为.pdf,浏览器仍然会按.pdf的MIME去传,但后端一解析发现不是合法PDF,可能返回500错误。所以测试数据文件尽量用真实合理的文件,不要随意改后缀。
下载场景也有类似问题。如果接口返回的Content-Type是application/octet-stream,浏览器通常会把suggestedFilename按URL末尾文件名处理。如果URL是/download?id=123这种,文件名可能是一串随机数,甚至没有扩展名。这时候断言不要写死文件名,要先用suggestedFilename()获取,再断言它包含期望的关键字,而不是完全相等。
5.3 上传下载测试的稳定性保障
上传下载相关的E2E用例,天然比普通点击用例更容易不稳定。我总结了几条稳定性保障措施:
- 上传之后不要立刻断言上传结果,先用
expect轮询等待文件列表出现。 - 下载之后不要立刻断言文件存在,用轮询等待文件大小大于0。
- 不要在用例结束时立即删除下载目录,先等
afterEach里其他断言完成。 - 不要在测试过程中关闭浏览器context,容易触发
target closed。 - 使用独立临时目录,避免并行执行相互干扰。
其中target closed是很多人问过我的报错。这个错误出现的原因很直接:你在下载还没结束时就关闭了页面或context。比如下载事件触发后,你以为已经拿到了文件,直接browser.close(),但Playwright内部可能还在把文件流写入本地。解决方式就是严格先saveAs再关闭,或者关闭前对下载Promise做await。
6. 把上传下载集成到CI流水线时要注意的事
6.1 临时目录与并发隔离
CI上并行执行测试时,最典型的故障就是不同worker写同一个下载目录或同一个上传临时文件导致互相覆盖。我见过最崩溃的一次是,三个并行任务同时下载同一个文件,最后断言文件内容时全挂,因为保存路径互相覆盖。
解决办法很简单,每个用例创建专属目录,目录名带时间戳和随机后缀。上传测试如果使用内存文件,不存在这个问题;如果使用真实文件,只要文件是只读的,多个worker同时读取同一个路径没问题。
下载则必须每个用例一个目录:
const downloadDir = path.join('downloads', `test-${testInfo.workerIndex}-${Date.now()}`);workerIndex是Playwright Test给每个worker的编号,用它区分目录,基本不会冲突。
6.2 失败重试与磁盘清理
CI上的磁盘空间要格外小心。一次下载测试可能产生几百MB文件,跑完不清理,几十次执行就能撑爆一块小容量磁盘。我在项目里做的清理策略是:
- 测试结束后,在
finally里删除当前用例的下载目录。 - CI流水线在每次构建前,清理
downloads目录下超过一天的文件夹。 - 对超大文件下载,单独设置一个
large-files目录,并配置更长的保留时间,方便排查历史问题。
重试策略也不能无脑设置。我的建议是普通上传下载用例可以设置retries: 1,涉及大文件下载的用例不要自动重试,否则一次失败会跑两次,磁盘和时间成本都翻倍。
6.3 测试报告里如何保留上传下载证据
测试跑完,光有“通过/失败”是不够的,尤其当用例在CI上失败,你人不在本地时,没法复现。我习惯在报告中保留三类证据:
- 上传成功后的页面截图,证明文件确实进了页面列表。
- 下载完成后,把落盘文件的大小、文件名记录到日志。
- 对下载的关键文件,把文件路径通过
testInfo.attach附到报告里。
Playwright Test支持附件:
testInfo.attach('download-file', { path: savePath, contentType: 'application/octet-stream' });这样在HTML报告里可以直接查看或下载这个附件。如果下载文件很大,不建议把整个文件塞进报告,可以只附一个文件信息摘要,比如大小和MD5值,判断下载是否完整:
const hash = crypto.createHash('md5').update(fs.readFileSync(savePath)).digest('hex'); testInfo.attach('download-info', { body: `size=${stat.size}, md5=${hash}` });这个技巧在排查“文件下载了但内容不对”的问题时特别有用。有一次我们在CI上发现下载的PDF打开报错,用MD5对比后才发现是响应经过了网关压缩,内容被转成了gzip格式,后来针对这个场景单独做了处理。没有证据,这种问题排查起来就是盲人摸象。
把上传下载测试稳定跑起来之后,你会发现它不只是一堆API调用,更多时候是对页面行为和浏览器机制的理解。我现在的习惯是把上传控件形态、下载事件时序、文件清理策略这些细节都沉淀成项目里的公共工具方法,团队成员写用例时直接调,不需要每个人重新踩一遍我踩过的坑。如果你刚接手一个上传下载相关的项目,不妨先从最小的setInputFiles和waitForEvent('download')跑通一条主链路,再逐步覆盖各种控件形态,这个路径是最稳的。