摘要: 凌晨 2 点的救火现场,该结束了!测试工程师老王盯着满屏飘红的 Selenium 脚本陷入沉思——元素定位失效、异步加载超时、跨域页面阻塞……这已是本周第三次为 UI 自动化熬夜救火。当 UI 自动化测试成为刚需,传统工具却让团队陷入脚本脆弱、环境依赖、维护成本高的泥潭。而微软开源的 Playwright 正以革命性设计横扫测试圈,成为新一代自动化测试的事实标准。 一、为什么全球大厂都在抛弃 S……
标签:Playwright, 自动化测试, LLM, 自愈测试, AI 编程
分类:软件测试 → 自动化测试
当 UI 自动化测试成为刚需,传统工具却让团队陷入脚本脆弱、环境依赖、维护成本高的泥潭。而微软开源的Playwright正以革命性设计横扫测试圈,成为新一代自动化测试的事实标准。
一、为什么全球大厂都在抛弃 Selenium?
先看两组真实对比数据:
| 痛点 | Selenium 方案 | Playwright 方案 |
|---|---|---|
| 执行速度 | 100 用例 / 8 分钟 | 100 用例 / 2 分钟 |
| 元素定位器维护 | 平均每周 3 小时 | 智能定位,接近零维护 |
| 跨浏览器支持 | 需独立驱动配置 | 开箱即用 |
| 移动端测试 | 依赖 Appium | 原生模拟 |
| 网络拦截 | 复杂代理配置 | 一行代码搞定 |
更致命的是:当页面出现动态元素时,传统方案需要这样的补救:
# Selenium 的经典重试逻辑element=Nonefor_inrange(10):try:element=driver.find_element(By.XPATH,"//div[contains(@class,'loading')]")breakexceptNoSuchElementException:time.sleep(1)二、Playwright 的四大杀手锏
1. 智能等待:告别 sleep 噩梦
自动感知页面加载状态,无需手动等待:
# 等元素出现再操作(传统方案需显式等待)page.click("text=立即购买")# 等导航完成再继续(告别随机超时)withpage.expect_navigation():page.click("#submit-btn")2. 跨域页面无缝操作
原生支持多 Tab 页 / iframe 交互,无需切换上下文:
# 跨域 Tab 页操作withpage.context.expect_page()asnew_tab:page.click("a[target='_blank']")new_tab.value.fill("#email","test@demo.com")# iframe 内直接定位frame=page.frame_locator(".payment-iframe")frame.locator("#card-number").fill("12345678")3. 移动端真机模拟
精确还原移动端交互,支持传感器模拟:
# 切换手机模式iphone=playwright.devices["iPhone 13 Pro"]context=browser.new_context(**iphone)# 模拟横屏 / 地理定位 / 陀螺仪context.set_geolocation({"latitude":39.9,"longitude":116.4})context.set_orientation("landscape")4. 网络精准拦截
控制请求与响应,实现自动化测试的终极武器:
# 拦截 API 请求page.route("**/api/userinfo",lambdaroute:route.fulfill(status=200,body=json.dumps({"name":"测试用户"})))# 捕获网络响应withpage.expect_response("**/api/checkout")asresponse:page.click("#pay-button")print(response.value.json())# 获取接口返回数据三、实战:用 LLM 给 Playwright 加自愈能力
Playwright 虽强,但定位器还是会因为前端重构而失效。怎么办?让 LLM 来帮你修。
核心思路:定位器失效 → 抓 DOM 快照 → 调 LLM 重写定位器 → 缓存结果。
3.1 Healer 核心:src/healer.js
constfs=require('fs');constpath=require('path');constOpenAI=require('openai');constCACHE_FILE='./healing-cache.json';constCACHE_TTL_MS=60*60*1000;// 1 小时constCONFIDENCE_THRESHOLD=0.75;// 持久化 cachefunctionloadCache(){if(!fs.existsSync(CACHE_FILE))return{};returnJSON.parse(fs.readFileSync(CACHE_FILE,'utf-8'));}functionsaveCache(cache){fs.writeFileSync(CACHE_FILE,JSON.stringify(cache,null,2));}// 让 LLM 重新生成定位器asyncfunctionaskLLMForLocator(brokenLocator,domSnapshot,errorMsg){constclient=newOpenAI({apiKey:process.env.GROQ_API_KEY});constprompt=`定位器 "${brokenLocator}" 报错:${errorMsg}当前页面 DOM 片段:${domSnapshot}请返回一个可用的 Playwright 定位器表达式(JavaScript 语法), 以及你对新定位器的置信度(0-1)。请用 JSON 格式返回: {"locator": "...", "confidence": 0.9, "strategy": "..."}`;constcompletion=awaitclient.chat.completions.create({model:'llama-3.1-70b-versatile',messages:[{role:'user',content:prompt}],temperature:0.1,max_tokens:200,response_format:{type:'json_object'},});returnJSON.parse(completion.choices[0]?.message?.content??'{}');}// 主函数asyncfunctionhealLocator(page,originalLocator,error){constcache=loadCache();constcached=cache[originalLocator];// 命中缓存直接返回if(cached&&(Date.now()-cached.timestamp)<CACHE_TTL_MS){console.log(`[self-heal] ✅ Cache hit: "${originalLocator}" → "${cached.newLocator}"`);return{success:true,newLocator:cached.newLocator,confidence:cached.confidence,strategy:'cache'};}// 抓 DOM 快照(只保留交互元素)constdomSnapshot=awaitpage.evaluate(()=>{returnArray.from(document.querySelectorAll('button, input, a, [role="button"]')).slice(0,50).map(el=>el.outerHTML.slice(0,200)).join('\n');});constsuggestion=awaitaskLLMForLocator(originalLocator,domSnapshot,error.message);// 置信度门控:低置信度不静默通过if(!suggestion.locator||suggestion.confidence<CONFIDENCE_THRESHOLD){console.warn(`[self-heal] ⚠️ Low confidence (${suggestion.confidence}). Skipping.`);return{success:false,newLocator:null,confidence:suggestion.confidence,strategy:suggestion.strategy};}// 写缓存 + 审计日志cache[originalLocator]={newLocator:suggestion.locator,confidence:suggestion.confidence,timestamp:Date.now(),};saveCache(cache);fs.appendFileSync('./healing-report.log',`[${newDate().toISOString()}] HEALED: "${originalLocator}" → "${suggestion.locator}" (${suggestion.confidence})\n`);return{success:true,newLocator:suggestion.locator,confidence:suggestion.confidence,strategy:suggestion.strategy};}module.exports={healLocator};3.2 Fixture:把每个动作都包上自愈
const{test:base}=require('@playwright/test');const{healLocator}=require('./healer');constFAST_TIMEOUT=3_000;asyncfunctionwithHeal(page,originalSelector,action){try{awaitpage.locator(originalSelector).waitFor({state:'attached',timeout:FAST_TIMEOUT});awaitaction(page.locator(originalSelector));}catch(err){constresult=awaithealLocator(page,originalSelector,err);if(!result.success||!result.newLocator)throwerr;// 把 LLM 返回的字符串 evaluate 成真正的 LocatorconsthealedLocator=newFunction('page',`return${result.newLocator}`)(page);awaitaction(healedLocator);}}consttest=base.extend({healPage:async({page},use)=>{awaituse({click:(selector)=>withHeal(page,selector,(loc)=>loc.click()),fill:(selector,value)=>withHeal(page,selector,(loc)=>loc.fill(value)),selectOption:(selector,value)=>withHeal(page,selector,async(loc)=>{awaitloc.selectOption(value);}),check:(selector)=>withHeal(page,selector,(loc)=>loc.check()),});},});module.exports={test};3.3 测试用例
const{test,expect}=require('../src/fixtures');constBASE_URL='https://the-internet.herokuapp.com/login';// TC-01:正常定位器test('TC-01 | Login with correct locators (baseline)',async({page,healPage})=>{awaitpage.goto(BASE_URL);awaithealPage.fill('#username','tomsmith');awaithealPage.fill('#password','SuperSecretPassword!');awaithealPage.click('button[type="submit"]');awaitexpect(page.getByText('You logged into a secure area!')).toBeVisible();});// TC-02:故意写错定位器,触发 LLM 自愈test('TC-02 | Login with BROKEN locators (self-heal triggered)',async({page,healPage})=>{awaitpage.goto(BASE_URL);awaithealPage.fill('#user-name-input','tomsmith');// 错的awaithealPage.fill('#pass-word-field','SuperSecretPassword!');// 错的awaithealPage.click('#login-submit-btn');// 错的awaitexpect(page.getByText('You logged into a secure area!')).toBeVisible();});// TC-03:同样的错误,这次走缓存test('TC-03 | Second run — healer reads from cache',async({page,healPage})=>{awaitpage.goto(BASE_URL);awaithealPage.fill('#user-name-input','tomsmith');awaithealPage.fill('#pass-word-field','SuperSecretPassword!');awaithealPage.click('#login-submit-btn');awaitexpect(page.getByText('You logged into a secure area!')).toBeVisible();});3.4 Playwright 配置
// playwright.config.jsmodule.exports=defineConfig({testDir:'./tests',timeout:90_000,// 30s 不够:3 个坏定位器 × LLM 延迟retries:0,// 重试交给 healer,不是 Playwrightworkers:1,// 文件 cache 不能并发写reporter:[['list'],['html',{outputFolder:'playwright-report',open:'never',port:9324}],],use:{headless:true,screenshot:'only-on-failure',video:'retain-on-failure',},});四、跑起来看效果
# 装依赖npminstall@playwright/test openai# 配 API key(也可以用 OpenAI / Ollama / Gemini)exportGROQ_API_KEY=gsk_xxxxxxxxxxxxxxxxxx# 跑测试npx playwrighttest首次运行的预期输出:
[chromium] › TC-01 | Login with correct locators 1.2s [chromium] › TC-02 | Login with BROKEN locators [self-heal] 📞 Locator failed: "#user-name-input". Calling LLM... [self-heal] ✅ Healed → page.getByLabel('Username') (confidence: 0.94) [self-heal] 📞 Locator failed: "#pass-word-field". Calling LLM... [self-heal] ✅ Healed → page.getByLabel('Password') (confidence: 0.96) [self-heal] 📞 Locator failed: "#login-submit-btn". Calling LLM... [self-heal] ✅ Healed → page.getByRole('button', { name: 'Login' }) (confidence: 0.91) 7.4s [chromium] › TC-03 | Second run — cache hit 1.8s [chromium] › TC-04 | Login fails with wrong password 1.1s 4 passed (11.5s)五、几条踩过的坑
- 别静默放过低置信度的 heal。0.75 是经验值,低于它 LLM 基本在猜。让测试失败比硬过更安全。
- workers 设为 1。文件 cache 多 worker 并发写会写坏,要并行就换 SQLite 或 Redis。
- healing-cache.json 加进 .gitignore。缓存的 locator 字符串和当前 DOM 强相关,跨环境没价值,提交 healing-report.log 就行。
- TypeScript 项目记得加 DOM lib。
page.evaluate内部document不识别,需要在 tsconfig.json 里加"lib": ["ES2020", "DOM"]。 - 慢机器 timeout 调到 90s。3 个坏定位器连续调 LLM,30s 可能不够。
六、总结
自愈测试不能替代写得好的定位器,但它解决的是:当 UI 变更慢慢扩散到系统各处的时候,让你的测试套件保持绿色。
并通过审计日志,把"曾经坏过的 selector"沉淀下来,反过来指导团队规范定位器写法。
LLM 也可以替换,Ollama / Gemini / DeepSeek 都行,选你顺手的。
作者:智测开发手记
【智测开发手记】专注测试开发、AI 时代质量保障、Playwright 实战