1. 为什么我最终选了 LangChain + Playwright 这套组合
网页自动化测试这件事,我做了快六年。早期用 Selenium 写脚本,后来换到 Playwright,再后来发现光有 Playwright 还不够——因为测试用例的维护成本实在太高了。一个电商后台,光是登录、下单、退款这三条链路,就能写出两百多个断言,每次前端改个按钮的 class 名,我就得跟着改一堆选择器。这种活干久了,人会麻木。
真正让我下决心引入 LangChain 的,是一次很典型的场景:产品经理临时要求验证"用户下单后,如果优惠券过期,页面提示文案是否正确"。这种需求用传统脚本写,得先定位优惠券元素、判断状态、再定位提示文案,前后至少半小时。但如果让大模型来理解页面结构,我只需要告诉它"检查过期优惠券的提示",它就能自己找到对应的 DOM 节点。这就是 LangChain 在这套组合里的核心价值——它不替代 Playwright 的执行能力,而是补上了"语义理解"这一环。
这套方案适合谁?如果你已经会写基本的 Playwright 脚本,但被频繁变动的选择器和断言逻辑折磨,那这套组合能帮你省下大量维护时间。如果你是完全的新手,也没关系,我会把每一步拆开讲,包括环境配置、代码结构、以及我踩过的那些坑。
提示:LangChain 和 LangGraph 不是替代关系。LangGraph 更适合有状态、多轮循环的 Agent 场景,而网页自动化测试这种"一次性任务链"用 LangChain 的 LCEL 表达式就够了,没必要上 LangGraph 增加复杂度。
2. 环境搭建与核心依赖选型
2.1 Python 版本与虚拟环境
我实测下来,Python 3.10 到 3.12 都能跑通这套组合,但 3.9 会在 LangChain 的某些依赖上出问题。建议直接用 3.11,兼容性最稳。虚拟环境用 venv 就行,没必要上 conda,除非你本地已经有 conda 环境不想折腾。
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate创建完环境后,先升级 pip,这一步很多人会跳过,但老版本 pip 在装 Playwright 的浏览器依赖时容易卡住。
pip install --upgrade pip2.2 核心依赖安装
这套组合需要三个核心包:playwright、langchain、langchain-openai。注意 langchain-openai 是独立包,不要只装 langchain 就以为万事大吉。
pip install playwright langchain langchain-openai python-dotenv playwright install chromium这里有个细节:playwright install chromium只装 Chromium 内核,如果你需要测试 Firefox 或 WebKit,得单独执行playwright install firefox和playwright install webkit。我一般只装 Chromium,因为 90% 的网页测试场景用它就够了,而且能省下几百 MB 磁盘空间。
注意:如果你在公司内网环境,Playwright 下载浏览器二进制文件可能会超时。这时候可以设置环境变量
PLAYWRIGHT_DOWNLOAD_HOST指向内部镜像源,或者手动下载后放到缓存目录。具体路径在 Windows 下是%USERPROFILE%\AppData\Local\ms-playwright,macOS 和 Linux 在~/.cache/ms-playwright。
2.3 大模型接入配置
LangChain 本身不绑定任何模型,你需要自己接一个。我用的是 OpenAI 的接口,但国内也有不少兼容 OpenAI 协议的服务,配置方式基本一致。在项目根目录建一个.env文件:
OPENAI_API_KEY=你的密钥 OPENAI_BASE_URL=https://api.openai.com/v1然后在代码里用python-dotenv加载。这里我要强调一点:不要把密钥硬编码在代码里,哪怕只是本地测试。我见过太多人把带密钥的代码直接传到公开仓库,结果被人扫到盗刷。用.env文件加上.gitignore是最基本的操作。
from dotenv import load_dotenv load_dotenv()2.4 目录结构设计
我习惯把项目分成四个目录:pages放页面对象、chains放 LangChain 链、tests放测试用例、utils放工具函数。这种结构在项目变大后优势很明显——当你要改某个页面的定位逻辑时,只需要动pages目录下的文件,不会影响到测试用例本身。
project/ ├── pages/ │ └── login_page.py ├── chains/ │ └── element_finder.py ├── tests/ │ └── test_login.py ├── utils/ │ └── browser.py └── .env3. 核心原理:LangChain 到底在自动化测试里做什么
3.1 传统 Playwright 脚本的痛点
先看一段典型的 Playwright 代码:
await page.click("button.submit-btn") await page.fill("#username", "testuser") await page.fill("#password", "testpass")这段代码能跑,但问题在于button.submit-btn和#username这些选择器是硬编码的。一旦前端重构,把submit-btn改成submit-button,脚本立刻报错。更麻烦的是,有些页面的按钮文案会变——今天叫"登录",明天叫"立即登录",后天叫"登 录"(中间多个空格),你根本没法用固定的选择器覆盖所有情况。
3.2 LangChain 的语义补位
LangChain 在这里的作用,是把"找元素"这件事从"精确匹配"变成"语义匹配"。具体做法是:先把页面的 DOM 结构简化成一段文本(去掉样式、脚本等无关内容),然后让大模型根据你的自然语言描述,返回最可能匹配的元素选择器。
举个例子,你告诉模型"找到登录按钮",它会在简化后的 DOM 里搜索包含"登录"、"登陆"、"sign in"等语义相关的元素,然后返回对应的 CSS 选择器或 XPath。这样即使按钮文案变了,只要语义没变,脚本依然能跑。
3.3 为什么不用纯大模型操作浏览器
有人会问:既然大模型这么聪明,为什么不直接让它控制浏览器?答案是成本和稳定性。大模型每次调用都有延迟和费用,如果每个点击、每个输入都走一遍模型,一个测试用例跑下来可能要几十秒甚至几分钟。而且大模型的输出有不确定性,同样的输入可能返回不同的选择器,这在测试场景里是致命的。
所以我的方案是:用 LangChain 做"元素定位"这一件事,其余操作全部交给 Playwright 原生 API。这样既享受了语义理解的好处,又保持了执行层的稳定和高效。
3.4 页面 DOM 简化的具体做法
直接把整个 HTML 丢给大模型是不现实的,一个现代网页的 DOM 动辄几千个节点,token 消耗巨大且容易超出上下文限制。我的做法是用 Playwright 的evaluate方法,在浏览器里先做一轮筛选:
simplified_dom = await page.evaluate("""() => { const interactiveTags = ['button', 'a', 'input', 'select', 'textarea']; const elements = document.querySelectorAll(interactiveTags.join(',')); return Array.from(elements).map(el => ({ tag: el.tagName.toLowerCase(), text: el.innerText?.trim().slice(0, 50) || '', id: el.id || '', className: el.className || '', type: el.type || '', placeholder: el.placeholder || '' })); }""")这段代码只提取可交互元素,并且把每个元素的文本截断到 50 个字符,避免 token 浪费。实测下来,一个普通页面的简化 DOM 大概在 500 到 1500 token 之间,完全在可控范围内。
4. 完整代码实现:从零到跑通
4.1 浏览器管理工具类
先写一个浏览器管理的工具类,负责启动、关闭和页面创建。这里我用的是异步 API,因为 Playwright 的同步 API 在某些场景下会和 LangChain 的异步调用冲突。
from playwright.async_api import async_playwright class BrowserManager: def __init__(self, headless=True): self.headless = headless self.playwright = None self.browser = None async def start(self): self.playwright = await async_playwright().start() self.browser = await self.playwright.chromium.launch( headless=self.headless, args=['--disable-blink-features=AutomationControlled'] ) async def new_page(self): context = await self.browser.new_context( viewport={'width': 1280, 'height': 720}, user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' ) return await context.new_page() async def close(self): if self.browser: await self.browser.close() if self.playwright: await self.playwright.stop()--disable-blink-features=AutomationControlled这个参数很关键,它能去掉 navigator.webdriver 标志,避免被一些网站的反自动化机制识别。当然,这不是万能的,有些站点还有更复杂的检测手段,但对于大多数内部测试场景已经够用。
4.2 LangChain 元素定位链
这是整套方案的核心。我用 LCEL 表达式构建了一条链:输入是简化 DOM 和自然语言描述,输出是选择器。
from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import JsonOutputParser llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个网页元素定位专家。根据用户描述和页面元素列表, 返回最匹配的元素选择器。优先使用 id,其次使用稳定的 class, 最后才用文本内容。返回 JSON 格式:{{"selector": "...", "reason": "..."}}"""), ("human", "页面元素:\n{elements}\n\n用户描述:{description}") ]) parser = JsonOutputParser() element_chain = prompt | llm | parsertemperature=0是为了让输出尽可能确定,减少随机性。模型我选的是 gpt-4o-mini,因为元素定位这个任务不需要太强的推理能力,mini 版本足够且便宜。如果你对成本不敏感,换成 gpt-4o 效果会更好,尤其是在处理复杂表单时。
4.3 页面对象封装
把定位逻辑封装成页面对象,这样测试用例里就不用关心具体选择器是怎么来的。
class SmartPage: def __init__(self, page, chain): self.page = page self.chain = chain async def get_elements(self): return await self.page.evaluate("""() => { const tags = ['button', 'a', 'input', 'select', 'textarea']; return Array.from(document.querySelectorAll(tags.join(','))) .map(el => ({ tag: el.tagName.toLowerCase(), text: (el.innerText || '').trim().slice(0, 50), id: el.id || '', className: typeof el.className === 'string' ? el.className : '', type: el.type || '', placeholder: el.placeholder || '' })); }""") async def smart_click(self, description): elements = await self.get_elements() result = await self.chain.ainvoke({ "elements": str(elements), "description": description }) selector = result["selector"] await self.page.click(selector) return selector async def smart_fill(self, description, value): elements = await self.get_elements() result = await self.chain.ainvoke({ "elements": str(elements), "description": description }) selector = result["selector"] await self.page.fill(selector, value) return selector4.4 完整测试用例
把上面的组件串起来,写一个完整的登录测试:
import asyncio from utils.browser import BrowserManager from chains.element_finder import element_chain from pages.smart_page import SmartPage async def test_login(): manager = BrowserManager(headless=False) await manager.start() page = await manager.new_page() smart = SmartPage(page, element_chain) await page.goto("https://example.com/login") await page.wait_for_load_state("networkidle") await smart.smart_fill("用户名输入框", "testuser") await smart.smart_fill("密码输入框", "testpass") await smart.smart_click("登录按钮") await page.wait_for_timeout(2000) assert "dashboard" in page.url print("登录测试通过") await manager.close() if __name__ == "__main__": asyncio.run(test_login())跑通这段代码,你就有了一个能自动理解页面语义的测试脚本。实测下来,从零开始到跑通第一个用例,熟练的话 5 分钟确实够,新手大概 15 到 20 分钟。
5. 实操中踩过的坑与排查技巧
5.1 模型返回的选择器不唯一
这是最常见的问题。比如页面上有两个"提交"按钮,模型可能返回了一个通用的button选择器,导致点错。我的解决办法是在 prompt 里明确要求返回唯一选择器,并且在代码里加一层校验:
count = await page.locator(selector).count() if count > 1: # 回退到更精确的定位 selector = f"{selector}:has-text('{description}')"5.2 动态加载页面元素抓取不全
有些页面是懒加载的,初始 DOM 里没有目标元素。这时候需要先滚动页面触发加载:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)") await page.wait_for_timeout(1000)5.3 iframe 内的元素定位
如果目标元素在 iframe 里,page.evaluate是抓不到的。需要先切换到对应的 frame:
frame = page.frame_locator("iframe#content") elements = await frame.locator("button").all()5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 模型返回空选择器 | DOM 简化后元素太少 | 扩大提取范围,加入 div 等容器标签 |
| 点击后无反应 | 元素被遮挡 | 用page.click(selector, force=True) |
| 超时错误 | 页面加载慢 | 增加wait_for_load_state等待时间 |
| token 超限 | DOM 太大 | 只提取可见元素,过滤display:none |
| 选择器失效 | 前端改版 | 重新跑一次定位链,让模型重新生成 |
提示:不要完全信任模型的输出。我一般会在关键操作前加一个截图,方便出问题时回溯。
await page.screenshot(path="debug.png")这行代码救过我很多次。
6. 性能优化与成本控制
6.1 缓存选择器
同一个页面反复定位同一个元素是浪费。我加了一个简单的内存缓存:
self.selector_cache = {} async def smart_click(self, description): if description in self.selector_cache: selector = self.selector_cache[description] if await self.page.locator(selector).count() == 1: await self.page.click(selector) return selector # 缓存失效,重新定位 ...这样在同一个测试会话里,第二次点击同一个按钮就不会再调用模型了。
6.2 批量定位
如果一个页面要操作多个元素,可以一次性把所有描述传给模型,让它返回一个映射表,而不是逐个调用。这样能把 N 次模型调用压缩成 1 次,成本直接降一个数量级。
6.3 模型选型建议
| 模型 | 定位准确率 | 单次成本 | 适用场景 |
|---|---|---|---|
| gpt-4o-mini | 85% | 极低 | 常规页面,元素特征明显 |
| gpt-4o | 95% | 中等 | 复杂表单,动态内容 |
| 本地小模型 | 60-70% | 免费 | 内网环境,对准确率要求不高 |
我个人的建议是:日常回归测试用 mini,关键链路用 4o,本地模型只在完全离线场景下考虑。
7. 后续扩展方向
这套框架跑通后,可以往几个方向扩展。一是接入 pytest,把每个测试用例写成独立的 test 函数,用 pytest-asyncio 管理异步执行。二是加入视觉比对,用 Playwright 的截图功能配合图像差异算法,检测页面渲染是否异常。三是把定位链换成 LangGraph,支持多轮交互——比如模型第一次没找到元素时,自动触发"滚动页面再找一次"的循环逻辑。
我在实际项目里还加了一个"自愈"机制:当某个选择器连续三次定位失败时,自动触发模型重新生成,并把新选择器写回缓存。这个机制让脚本的维护频率从每周一次降到了每月一次,省下来的时间相当可观。
最后分享一个小技巧:如果你在调试阶段,把headless设为False,能看到浏览器实际操作过程,定位问题时非常直观。等脚本稳定后再切回True,跑回归测试时速度会快很多。