说实话做UI自动化的朋友应该都有这种经历几十条用例点击、输入、断言全部绿了一到“上传附件”或者“下载报表”这种环节就直接卡死。我最早用Selenium处理文件下载时还要在Windows底层模拟键盘弹窗一套脚本写得又长又脆。后来切到Playwright发现Python版Playwright把文件上传和下载抽象得特别顺手几乎是我现在最省心的模块之一。这篇文章就只聊一个主题Playwright Python环境下的文件上传、下载与文件操作自动化。我会把上传控件的识别方式、download事件的监听、无头模式下的差异、并发下载和常见坑位一次讲完代码都是能直接抄走的。1. 文件上传自动化先学会识别控件类型1.1 原生input、遮罩层与自定义控件三种形态一眼看穿很多人一上来就调用set_input_files发现怎么都不生效为什么因为你没搞清楚页面上的上传控件真实形态。HTML里的上传源头只有一个就是input[typefile]这是浏览器唯一允许选择本地文件的入口。但是前端为了实现好看的样式会在原生input外面盖一层div、button甚至一个canvas真正被点击的其实是那个可见元素而不是原生input。这就是“遮罩层”的情况。还有一类自定义控件它可能是一个div绑定click事件然后用JS动态创建一个隐藏的input[typefile]甚至是用第三方上传组件包了一层。判断方法很简单打开DevTools在Elements面板里搜file或者直接在Console里执行document.querySelectorAll(input[typefile])数一数有几个。你也可以在Playwright里快速验证from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://example.com/upload-demo) file_inputs page.locator(input[typefile]) print(file_inputs.count()) for i in range(file_inputs.count()): el file_inputs.nth(i) print(el.get_attribute(name), el.is_visible())这里注意一个细节is_visible()返回False不代表不能用set_input_files赋值很多原生input被隐藏后依然能接收文件路径。关键在于你必须先确认这个input确实存在。如果整个页面连input[typefile]都没有那就是纯自定义控件需要走后面我讲的file_chooser事件或者让开发在内部暴露一个可接受文件输入的字段。1.2 最稳定的set_input_files用法与避开浏览器的隐藏限制set_input_files是Playwright对文件上传最直接的API它绕过系统文件选择弹窗直接把本地文件路径“塞”给input。和Selenium时代最大的区别是你完全不需要处理Windows/macOS的弹窗识别也不用装什么第三方桌面自动化工具浏览器层面的文件选择逻辑被Playwright接管了。先看最简单的单文件上传from pathlib import Path upload_path Path(C:/data/订单数据.xlsx) with sync_playwright() as p: browser p.chromium.launch(headlessFalse) page browser.new_page() page.goto(https://example.com/internal/upload) page.set_input_files(input[namefile], upload_path) page.click(button#submit) page.wait_for_selector(#upload-success)注意几点。第一文件路径建议统一用pathlib.Path处理不要在代码里手写反斜杠字符串拼接Windows和Linux的路径分隔符不同这一步能省掉后续大量跨环境问题。第二set_input_files可以传多个文件路径只要input本身允许多选你传一个列表即可page.set_input_files(input[typefile], [ C:/data/合同A.pdf, C:/data/合同B.pdf, C:/data/合同C.pdf ])第三这个API只对真实的input[typefile]有效。如果你的上传控件压根不是input调用时会直接报错或者没有任何反应。先做一步locator.count()确认元素存在比盲试效率高得多。我在实操中还发现一个容易踩的坑部分页面上传后会触发图片预览、文件大小校验或者进度条动画。这时候不要光看上传框有没有反应而是要用wait_for_selector去等待真正的反馈元素出现比如“上传成功”文案、图片img标签的src变化或者按钮从可用变成禁用再恢复。否则脚本很容易在上传后紧接着点击提交时因为文件尚未处理完而丢请求。1.3 file_chooser事件连动态生成的上传框也能hook住有些框架在点击上传按钮的瞬间才动态生成input如果你先定位再赋值就会扑个空。Playwright为这种场景提供了file_chooser事件思路是先注册一个对文件选择框的等待然后触发点击最后拿到文件选择框对象并设置文件。with page.expect_file_chooser() as fc_info: page.click(button#upload-trigger) # 这个按钮可能在点击后创建input file_chooser fc_info.value file_chooser.set_files(C:/data/项目验收文档.pdf)这里要解释一下expect_file_chooser()的工作原理它会在事件循环里等待浏览器出现“文件选择器”请求而点击操作会触发这个请求无论这个input是原本就在页面里还是点击后动态创建的都能捕获到。所以遇到自定义上传控件这是一个比硬定位input更稳的兜底方案。还有一种情况点击上传按钮后前端弹出了一个自己的对话框用div模拟的里面又嵌套了一个上传区域。这时你看到FileChooser事件没有触发是因为点击源头被前端JS拦截根本还没走到浏览器文件选择阶段。排查方法是先看按钮绑定的事件如果它调用了input.click()那下一步必须先点那个隐藏input或者直接调用file_chooser相关事件。实战里我通常直接用expect_file_chooser包住“最终引发文件选择的那一次点击”而不是包住外层按钮。2. 文件下载自动化download事件才是正解2.1 先配置contextaccept_downloads与自定义下载目录上传说完了再来看下载。Playwright处理下载的思路和上传一样紧扣浏览器事件模型它不弹系统窗口而是触发一个download事件让你在脚本里接管。新建浏览器上下文时可以显式设置accept_downloadsTrue虽然新版本里这个参数默认已经是True但显式写出来能让排查问题的人一眼看懂。with sync_playwright() as p: browser p.chromium.launch() context browser.new_context( accept_downloadsTrue, base_urlhttps://example.com ) page context.new_page()还需要理解“下载文件最终去了哪”。如果不在代码里做任何处理Playwright会把下载文件存在一个临时目录随后可能被清理。你需要在下载事件触发后通过download.path()拿到这个临时文件路径并用save_as()把它保存到你的目标位置。千万不要直接拿download.path()当最终产物去给用户用它只是临时文件。有些文章会建议在new_context里设置downloads_path参数让下载文件固定落在某个目录。我只能说这个参数在部分版本的Python Playwright里存在但我实际使用中调用save_as()的机会更多因为后者可以同时改文件名更适合自动化的归档需求。你自己跑一遍就知道哪个顺手脚。2.2 用expect_download捕获下载事件并save_as捕获下载的标准姿势是先把expect_download()和触发下载的点击放进同一个with块点击之后Playwright会等待下载事件再让脚本做后续处理。import os from pathlib import Path download_dir Path(C:/tmp/downloads) download_dir.mkdir(parentsTrue, exist_okTrue) with sync_playwright() as p: browser p.chromium.launch() page browser.new_page() with page.expect_download() as download_info: page.click(button#export-report) download download_info.value # 等待文件下载完成 download_path download.path() if download_path is None: raise RuntimeError(f下载失败: {download.failure}) final_path download_dir / download.suggested_filename download.save_as(str(final_path)) print(f已保存到: {final_path})这段代码有几个关键点值得展开。第一expect_download()必须在点击之前进入否则点击已经触发了下载事件你再注册监听就晚了。第二download.path()在下载完成前可能返回None严格说这是在等待之后才调用的如果下载失败download.failure会返回异常信息可以用来定位服务端是否返回了错误页面。第三save_as()相当于把临时文件复制到目标位置它会阻塞到文件写完毕所以不需要额外sleep。我还习惯在save_as()之前打印一下suggested_filename因为有些服务端会用时间戳或随机串当文件名如果脚本里要按业务规则改名这一步就必须做映射。建议的逻辑是保留suggested_filename方便人工核对再拼接业务前缀。2.3 download对象的常用属性suggested_filename、path、failuredownload对象虽然不像page那样有那么多上层方法但属性信息足够日常排障。这里分享一张速查表是我平时排查下载问题时一定先过一遍的属性/方法类型用途说明suggested_filenamestr服务端建议的文件名浏览器通常从响应头解析path()str/None临时文件路径下载完成后可用failurestr/None下载失败时返回异常信息成功为Noneurlstr触发下载的请求地址save_as(path)none保存下载文件到指定路径cancel()none取消尚未完成的下载用suggested_filename保存文件时注意两点。一是如果文件名包含Windows非法字符:/|?*直接拼接路径会报错需要先用正则清理二是中文文件名在某些系统下可能显示成了URL编码格式保存后名字是%E5%90%88%E5%90%8C.pdf这种出现这种问题多半是服务端没有正确设置Content-Disposition头这种我建议直接按业务规则重命名不要依赖服务端的返回值。3. 无头模式下文件操作照样能跑关键配置差异3.1 headlessTrue下download一样能触发但要注意校验方式很多人一听说无头模式就慌觉得没有界面还能弹下载框吗事实是Playwright的下载机制并不依赖可见窗口里的下载栏它通过浏览器底层的Download事件与脚本通信所以headlessFalse和headlessTrue都能正常触发下载。我做批量报表导出时基本全是在无头模式下跑的。with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context(accept_downloadsTrue) page context.new_page() with page.expect_download(timeout60000) as download_info: page.click(button#export-all) download download_info.value if download.failure: print(下载失败:, download.failure) else: target Path(output/) / download.suggested_filename download.save_as(str(target))无头模式下载真正需要操心的是超时和资源清理。比如报表数据量大后端要生成几十秒expect_download默认30秒超时就可能不够用建议根据业务复杂度设置合理的timeout我一般设为60秒甚至120秒。另外无头模式下出错看不到浏览器界面排查问题主要靠日志和保存的页面快照所以我习惯在download.failure不为空时调用page.screenshot(path...)把触发下载那一刻的页面截下来这能帮你快速区分是按钮点击没生效还是后端返回了错误。3.2 下载目录生命周期管理每轮清空、归档、校验hash自动化跑得越久下载目录里的文件堆积问题越明显。一轮增删改查跑下来几十个文件堆在同一个目录第二次执行时看到同名文件就可能懵。我现在的习惯是给每一轮自动化建一个独立的临时下载目录跑完后做断言和归档最后清空目录绝不留垃圾。import shutil import tempfile from pathlib import Path def create_clean_dir(prefixauto_download_): base Path(tempfile.gettempdir()) / prefix if base.exists(): shutil.rmtree(base) base.mkdir(parentsTrue, exist_okTrue) return base如果你要验证文件内容没被篡改推荐加一步SHA256校验。常见场景是页面上传一个文件到系统再从某个下载入口把同一份文件取回来比对前后两个文件的哈希值。只要哈希一致就证明存储链路没有丢数据。示例我放在下一节。3.3 上传后下载的闭环测试设计一条用例覆盖两个操作业务上最有价值的用例往往是“上传后能正确下载”。比如某内部审批系统要求用户上传合同扫描件流程走完后还能在归档页下载。这时我会把上传、提交、下载、校验四个动作做成一条自动化用例import hashlib SOURCE_FILE Path(C:/data/合同扫描件.pdf) def sha256_of(path): h hashlib.sha256() with open(path, rb) as f: for chunk in iter(lambda: f.read(4096), b): h.update(chunk) return h.hexdigest() with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://example.com/archive/upload) page.set_input_files(input[typefile], str(SOURCE_FILE)) page.click(button#submit) with page.expect_download(timeout60000) as info: page.click(button#download-original) download info.value target Path(output/) / download.suggested_filename download.save_as(str(target)) assert sha256_of(SOURCE_FILE) sha256_of(target), 文件内容不一致这段代码把File uploaded和Downloads两条链路串了起来跑一遍等于回归了存储与读取两个核心环节。有一点要提醒上传的文件路径和下载的文件路径尽量不要在同一个目录否则脚本清理时可能把自己的源文件删掉。源文件单独放assets/下下载文件放临时目录边界很清晰。4. 让文件操作稳定起来并发、超时与等待细节4.1 用expect_download的timeout和URL过滤别在无头模式下等死下载操作最让人头疼的问题不是“怎么触发”而是“怎么等”。如果在点击下载按钮后服务端迟迟不返回文件流expect_download就会一直等到超时默认30秒意味着整个测试可能被这一个等待拖垮。这种情况下你要给expect_download设置一个针对性的超时超时后快速失败并记录现场。更灵活的做法是给下载事件加上过滤条件。当一个页面上有多个不同的下载按钮或者同一次操作可能触发多个下载时你可以在expect_download里传入expected_url或match回调只关注你想等的那个下载with page.expect_download( timeout30000, matchlambda d: monthly-report.xlsx in d.suggested_filename ) as info: page.click(button#download-excel)这样即使页面上同时还有另一个pdf下载事件也不会干扰你的断言。注意match回调是在下载事件触发时调用的所以里面的download.suggested_filename是可靠的。如果你不筛选只是单纯等待“任意下载发生”在多下载场景下很容易取到错误的download对象。4.2 多文件并发下载同步API开线程还是推荐async API有些测试场景需要批量下载几十个文件逐个顺序下载也不是不行但耗时长。这里可以分两条路走。如果主要用sync_playwright同步API最简单的提速方式就是开线程池每个线程独立启动浏览器实例或独立的context分别处理一个下载任务。from concurrent.futures import ThreadPoolExecutor def download_one(item_id): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://example.com/items/ item_id) with page.expect_download(timeout30000) as info: page.click(button#download) info.value.save_as(foutput/{item_id}.pdf) browser.close() with ThreadPoolExecutor(max_workers5) as pool: list(pool.map(download_one, [A001, A002, A003, A004, A005]))需要注意线程模式下每个线程内部都要单独创建和关闭browser实例不要让多个线程共享同一个playwright实例。如果你用的是async_playwright那可以维护多个page并发触发下载本质上逻辑相同但代码结构会复杂一点。多数项目里一个页面最多同时出现三五个下载按钮我反而建议优先串行保证顺序和稳定性除非你有明确的性能压力。4.3 .crdownload残留与临时文件等待“消失”比等待“出现”更可靠Chrome和Chromium在下载文件时会先生成一个以.crdownload结尾的临时文件下载完成后才把它重命名为最终文件名。如果你看到目标目录里出现了目标文件就立刻拿去做断言有可能撞上文件还没写完的窗口期。更稳妥的方式是等待.crdownload伴随文件消失或者用轮询判断目标文件大小不再变化。import time from pathlib import Path def wait_for_download_complete(directory, filename, timeout60): target Path(directory) / filename tmp Path(directory) / f{filename}.crdownload deadline time.time() timeout while time.time() deadline: if tmp.exists(): time.sleep(0.5) continue if target.exists() and target.stat().st_size 0: return target time.sleep(0.5) raise TimeoutError(f下载未完成: {target})为什么“等待消失”比“等待出现”更可靠因为最终文件只要出现大概率说明下载管理器已经完成了重命名而.crdownload文件只要还存在就说明写入还没结束。用这个思路做轮询几乎不会遇到文件半截的尴尬。若是不存在于.crdownload后缀的下载器可以直接检查目标文件大小连续两次读取不变。这个函数我在好几个项目里复用尤其在批量导出场景下避免了大量偶发失败。5. 文件操作避坑清单我从真实自动化项目里捡回来的教训5.1 点击下载后页面立刻跳转download被中断怎么办这种情况常发生在“点击下载后跳转到详情页”或者“下载按钮本身是一个导航链接”的设计里。如果expect_download包住了点击但页面同时开始导航Playwright的download事件可能会因为context被关闭而丢失。我踩过之后总结出一个处理方案把expect_download放在导航发生前使用new_context里的base_url理顺页面关系或先拦截导航用page.expect_navigation配合等待确认download事件触发后再允许跳转。更实用的做法是如果后端直接返回文件流但你发现页面是window.open方式打开下载链接那download事件会落在新开的page上而不是当前page。这时要改用context.expect_page()捕获新页面再去新页面里处理download。代码大致长这样with context.expect_page() as page_info: page.click(button#download-in-new-tab) new_page page_info.value with new_page.expect_download() as download_info: new_page.wait_for_load_state() download download_info.value download.save_as(output/新标签页下载文件.pdf)遇到这个问题别慌先在新页面里等待network idle状态再处理download。大多数崩溃场景不是事件没触发而是你等错了页面。5.2 中文文件名与URL编码显示的%XX怎么处理很多被测系统的后端没有正确设置响应头导致浏览器拿到的suggested_filename是URL编码后的字符串比如%E8%AE%A2%E5%8D%95.xlsx保存出来的文件完全是乱码。遇到这种问题最简单的处理是不要依赖服务端文件名而是自己在脚本里定义映射规则比如“下载订单导出”按钮固定保存为order_{当前日期}.xlsx。from datetime import datetime timestamp datetime.now().strftime(%Y%m%d_%H%M%S) local_name forder_{timestamp}.xlsx download.save_as(foutput/{local_name})如果你还是想还原中文名可以先从suggested_filename里做一次URL decode。Python里直接用urllib.parse.unquote即可但前提是确认文件名确实经过了URL编码而不是本来就包含%字符。这个判断我没法给你一条万能公式最快的验证方法是在调试时打印suggested_filename肉眼确认后再决定处理方式。5.3 不要在代码里手搓路径pathlib才是文件操作的好朋友文件自动化最隐蔽的Bug来自Windows的路径分隔符。我在初期写脚本时习惯用双反斜杠拼接地址比如C:\\files\\new\\report.pdf。一旦脚本挪到Linux服务器上双反斜杠直接失效。现在所有文件路径统一用pathlib.Path处理它会根据当前操作系统自动选择正确分隔符而且在拼接文件名时比字符串格式化安全很多。base_dir Path(output) base_dir.mkdir(parentsTrue, exist_okTrue) target base_dir / download.suggested_filename注意Path对象和字符串不是完全等价的调用save_as()时有些版本要求传入字符串所以我通常写str(target)。还有一个小习惯判断文件是否存在时用Path.exists()而不是os.path.exists()代码意图更清晰也方便链式调用。5.4 环境相关坑playwright install与系统依赖在干净服务器上用Playwright跑文件下载第一件要做的就是安装对应浏览器内核。只有安装完headless模式下才能真正触发下载事件。经常有人发现本机能跑服务器上报错提示找不到可执行路径十有八九是漏了这步python -m playwright install chromium playwright install-depsinstall-deps会安装Linux环境下运行Chromium所需的系统依赖库比如libnss3、libatk等。如果你用的是Docker镜像建议直接基于官方Playwright镜像构建省掉一堆系统包安装步骤。文件下载如果触发后没有任何反应先去命令行执行一次playwright install chromium --with-deps跑通之后再看代码逻辑。写在最后的几句实在话这套文件上传下载的自动化方案我前前后后在好几个项目里跑过从最开始的Excel报表导入导出到后来批量合同归档Playwright的稳定性确实比早期Selenium加系统弹窗方案高了一个量级。如果你们团队正准备给UI自动化增加文件操作场景我强烈建议从一条最简单的“上传单文件后立即下载”用例开始跑通它再扩展并发或者复杂控件场景。最后再分享一个小技巧下载目录的清理要放在用例的teardown阶段而不是收集阶段之前。因为断言失败时你往往需要保留现场文件去分析清得太早反而丢了证据。文件自动化没有太多高深理论把等待机制、事件模型、目录生命周期这三件事想清楚后面都是体力活。