
做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 typefile这种最老实Playwright可以直接喂文件。按钮或div触发后动态创建隐藏的input typefile很多组件库都是这么干的比如Ant Design Upload、ElementUI的Upload。iframe里嵌套的上传框常见于老后台系统、富文本编辑器、第三方供应商页面。拖拽上传区域比如直接把文件拖进一个框里底层可能是隐藏input也可能完全不是input而是监听drop事件后读取DataTransfer。为什么说要先分清形态因为Playwright的set_input_files本质上是在DOM层面直接给input[typefile]赋值文件路径它不依赖操作系统弹窗也不需要扫描屏幕坐标。只要页面上存在这样一个input元素不管它是隐藏还是可见都可以用。但如果页面根本没有input而是自定义拖拽组件那就要走另外的路子。这也是我见过最多人“卡死”的地方——他们把API用错了对象。1.2 set_input_files的核心用法与参数细节先看最标准的用法// 给单个原生input传文件 await page.locator(input[typefile]).setInputFiles(tests/data/report.csv); // 上传后断言页面出现了文件名 await expect(page.locator(.file-name)).toHaveText(report.csv);如果上传控件在iframe里先定位frame再在frame内定位inputconst frame page.frameLocator(iframe[nameuploadFrame]); await frame.locator(input[typefile]).setInputFiles(tests/data/report.csv);这里有个细节setInputFiles不但支持字符串路径也支持文件路径数组还支持直接在内存中构造文件内容。日常测试中我经常用内存文件代替真实文件特别是做接口异常场景时根本不需要准备一堆垃圾文件放在仓库里await page.locator(input[typefile]).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[typefile]).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[typefile]).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 typefile那就直接找这个input用setInputFiles不要为了“看起来更像真实拖拽”而选择更脆弱的方案。自动化测试的价值是验证业务逻辑不是像素级还原用户操作。只有当你确实需要覆盖拖拽交互本身时才用DataTransfer模拟。3.3 剪贴板上传和完全非input的上传剪贴板上传是另一个麻烦。比如富文本编辑器里支持 CtrlV 粘贴截图。Playwright没有官方API直接往系统剪贴板塞文件如果你用真实剪贴板操作又很容易因为系统权限、焦点问题变得不稳定。我目前的处理策略是底层有input就用setInputFiles如果确实没有input且不能通过DataTransfer模拟粘贴事件就把这部分场景下沉到手动测试或组件级单元测试而不是硬塞进E2E里。一个E2E用例如果每次跑都不稳定它造成的噪音远大于收益。另外有些上传组件是点击后拉出一个弹窗里面嵌了文件选择区域但这个弹窗里的input可能是动态渲染的需要等待。这时候用waitFor等待input出现再赋值即可const input page.locator(.upload-modal input[typefile]); 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?id123这种文件名可能是一串随机数甚至没有扩展名。这时候断言不要写死文件名要先用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)跑通一条主链路再逐步覆盖各种控件形态这个路径是最稳的。