做UI自动化测试的人,十有八九在登录页面栽过跟头,剩下那一两个,多半是被动态图片验证码耗没的。Selenium定位输入框、模拟点击、等待元素加载,这一套组合拳大家早就打得很顺了,结果页面突然弹出一张歪歪扭扭、带着干扰线、每次刷新还长得不一样的动态图片验证码,整个自动化脚本当场卡死,只能干瞪眼。这篇文章就是来填这个坑的:基于Selenium做UI自动化测试时,如何把动态图片验证码“拿下”,实现自动获取、自动识别、自动填充,让登录流程的自动化用例能顺畅跑完。
这篇文章适合被测系统测试环境开启了图片验证码、且无法修改验证码校验逻辑的测试团队参考,也适合正在学习UI自动化测试、想搞懂验证码处理思路的工程师阅读。我会把整体方案、关键代码、图像预处理细节、常见坑位和排查方法全部拆开讲。代码都是可直接抄的,但哪些地方容易翻车,我也会提前说明。
1. 项目概述:自动化测试里的验证码硬骨头
先说清楚一个前提:我们需要处理的验证码,是系统在测试环境中开启的动态图片验证码。这种验证码的特点是每次刷新都会生成一张新的图片,字符集通常是数字、字母或数字加字母混合,图片上会叠加干扰线、噪点、背景纹理,某些系统还会对字符做扭曲变形。Selenium虽然能拿到页面元素、模拟操作,但它本身没有“看懂图片”的能力,这就是整个自动化链路里最棘手的一环。
有人可能会问:测试环境里能不能直接把验证码功能关掉?或者找开发在代码里留个万能验证码?这两种方案在实际工作中确实常用,但并不总是行得通。很多企业的测试环境会尽量贴近生产环境的配置,验证码是安全策略的一部分,不能随随便便屏蔽;开发也不一定愿意为了测试去改代码逻辑。还有一个更现实的问题:验证码相关的测试用例本身就属于功能测试范围,自动化脚本理应有能力覆盖这类场景,而不是靠环境后门绕过去。
另一个思路是人工介入:脚本在验证码这里暂停,测试人员手动看图填码,再让脚本接管。这种方案实现起来最简单,但违背了自动化“无人值守”的核心目标。尤其是执行回归测试或者夜间巡检时,不可能有人在旁边等着输入验证码。所以我们需要一套能在脚本里自动完成的验证码处理方案。
总结下来,项目目标很明确:在UI自动化测试中,用Selenium截取页面上的动态图片验证码,通过图像处理技术提升图片质量,再交给OCR引擎识别出文本,最后自动填入输入框并提交。识别结果不要求100%正确,配合重试机制,只要整体识别成功率能支撑自动化用例稳定跑完,就算达到目标。
2. 整体方案设计:四条路线对比与选型思路
做验证码自动识别,业内大致有四条路线,我先把对比表列出来,再讲为什么最终选了OCR这条路。
| 方案 | 实现成本 | 识别率 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| 人工介入 | 最低 | 100% | 差(依赖人在场) | 少量用例、调试阶段 |
| 屏蔽验证码 | 低 | 100% | 好 | 开发愿意配合、非验证码专项 |
| OCR自动识别 | 中等 | 60%~90% | 较好(配合重试) | 测试环境、无人值守 |
| 第三方打码服务 | 高(付费) | 90%以上 | 好 | 高并发、生产级压力测试 |
人工介入的问题刚才已经说过,不适合自动化回归。屏蔽验证码虽然省事,但只适合在测试环境权限充足且开发愿意配合的情况下使用,而且如果团队想覆盖“验证码错误提示”“验证码刷新”“验证码过期”这些功能点,屏蔽方案反而让这些用例没办法执行。
第三方打码服务识别率确实高,但需要在项目里接入外部服务、产生调用费用,还依赖网络环境。在一般企业的测试环境里,为了处理一个登录验证码就引入付费服务,有点“杀鸡用牛刀”。
所以核心方案定为:Selenium截图 + OpenCV/Pillow图像预处理 + Tesseract OCR识别。这条路线完全基于开源工具,无额外费用,逻辑链路完全在本地可控。识别率不够高的问题,用两招弥补:第一,做足图像预处理,把识别率尽可能拉高;第二,在自动化用例中加入重试机制,识别失败就刷新验证码重新来一遍,把单次识别成功率转化为整个用例的最终通过率。
整体流程我梳理为六个环节:定位验证码元素 → Selenium对元素截图 → 图像预处理(灰度、二值化、降噪) → OCR识别 → 回填输入框 → 提交并校验结果。后面会逐一展开。这里面最关键的一个设计决策是:一定要用Selenium对页面元素直接截图,而不是用requests去拿图片URL再识别。原因是很多系统的验证码图片接口是动态token绑定的,脚本直接请求接口拿到的不一定是当前页面上这个验证码,甚至可能因为缺少Cookie或签名信息直接拿不到图。用Selenium元素截图拿到的就是用户在页面上真实看到的那张图,保证“所见即所识”。
3. 环境准备与关键工具选型
先把工具链列清楚。所有工具都是开源或免费的,不需要额外申请授权。
Python环境:
- Python 3.9以上即可,我用的是3.10
- selenium库:4.x版本。4.x和3.x的API有区别,下面代码基于4.x编写
- Pillow库:图像基础处理
- opencv-python库:图像预处理,安装后Python里import cv2
- pytesseract库:Python调用Tesseract OCR的封装库
- 浏览器驱动程序:Chrome用ChromeDriver,Edge用EdgeDriver,Firefox用GeckoDriver。版本必须和本地浏览器大版本一致,这个不匹配会导致WebDriver无法启动
Tesseract OCR引擎是核心,安装时需要注意两点。第一,Windows下安装时记得勾选需要的语言数据包,或者安装后单独下载语言包放到tessdata目录下;第二,代码里调用pytesseract时,如果报“tesseract is not installed”或者“无法找到tesseract.exe”,多半是pytesseract库找可执行文件的路径不对,需要手动指定tesseract_cmd参数。
安装命令汇总在这里:
pip install selenium pip install opencv-python pip install pillow pip install pytesseractTesseract引擎本身不能用pip装,要去官方项目仓库下载对应系统的安装包。装完可以用命令行验证一下:
tesseract --version tesseract --list-langs如果列表里有eng,说明英文语言包就绪。验证码以数字和字母为主时,eng包基本够用;如果系统有中文字符需求,再安装中文简体包。
环境准备里还要考虑Selenium运行模式。我的建议是:调试阶段用有头模式,肉眼能看到浏览器操作过程,方便定位问题;正式跑测试用例时再用无头模式,减少资源占用。这里有个经验:无头模式下截图容易拿到空白图片,原因很可能是窗口尺寸设置不当或者页面渲染还没完成。解决办法是在启动选项里设置固定的窗口尺寸,像这样:
options = webdriver.ChromeOptions() options.add_argument('--headless') options.add_argument('--window-size=1920,1080') options.add_argument('--disable-gpu') driver = webdriver.Chrome(options=options)window-size一定要设置,不设置的话页面视口可能只有默认的800x600,某些动态渲染的验证码图片反而没有加载出来。
4. 核心环节实现:完整操作流程与代码拆解
4.1 定位验证码图片元素
要截图,先要把验证码图片的WebElement拿到。首先要分析页面上验证码图片的DOM结构。常见的有这三种定位方式:
# 方式1:通过id定位,最精确 captcha_element = driver.find_element(By.ID, 'captcha_img') # 方式2:通过CSS类名定位 captcha_element = driver.find_element(By.CLASS_NAME, 'captcha-code') # 方式3:通过XPath定位,适合没有唯一标识的页面 captcha_element = driver.find_element(By.XPATH, '//div[@class="login-box"]//img')这里有个细节:有些系统的验证码图片不是一个img标签,而是一段通过canvas绘制的图形。canvas元素同样可以定位并截图,Selenium的element.screenshot()方法对canvas也有效。还有更特殊的,用CSS背景图实现的验证码,此时需要定位承载背景图的div元素。判断依据就是看页面里哪个元素包含了验证码的可视区域。
如果定位时报“找不到元素”的错误,排查思路是:先切换到iframe。很多登录页的验证码区域在整个登录框内部,如果登录框本身是一个iframe,那Selenium默认在主文档里是找不到内部元素的。需要执行driver.switch_to.frame()先切进去。这是个很常见的坑,我见过不少脚本卡在这一步。
4.2 对验证码元素截图
Selenium 4.x里,元素截图可以直接调用WebElement.screenshot()方法,不用先截整个页面再裁剪,省掉很多麻烦。
# 先确保元素滚动到可视区域 driver.execute_script("arguments[0].scrollIntoView();", captcha_element) time.sleep(0.5) # 直接对元素截图,保存到本地文件 captcha_element.screenshot('captcha.png')为什么建议先执行scrollIntoView?原因是无头模式或者窄视口下,元素可能位于页面可视区域之外,直接调用screenshot虽然有时候也能拿到图片,但偶尔会返回空白图。加了滚动操作之后,稳定性明显提升。截图后建议立刻用Pillow确认图片文件不是全白的:
from PIL import Image img = Image.open('captcha.png') print(img.size, img.mode)如果打印出来的size是0x0,或者图片内容全白,请不要继续处理,先回上面排查元素定位和渲染问题。这是节省排查时间的好习惯。
4.3 图像预处理:把识别率从惨不忍睹拉到勉强能用的关键
很多人OCR识别率低,问题不在OCR引擎,而在图片压根没处理过。原始验证码图片通常是带背景纹理、干扰线、噪点的彩色图,直接丢给Tesseract,识别结果会很感人。预处理的目标是:把彩色图变成规则的黑白图,突出字符本身,削弱背景干扰。
我这里用OpenCV做一套组合预处理,步骤分别是灰度化、中值滤波降噪、二值化。先看代码:
import cv2 # 读取截图 img = cv2.imread('captcha.png') # 转灰度图 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 中值滤波,去孤立噪点 blurred = cv2.medianBlur(gray, 3) # 二值化:像素值大于阈值设为白色,否则设为黑色 _, binary = cv2.threshold(blurred, 180, 255, cv2.THRESH_BINARY) # 保存处理后的图片,方便调试 cv2.imwrite('captcha_binary.png', binary)这段代码里,二值化的阈值180是个经验值。不同系统的验证码图片明暗程度不一样,有些很深的背景纹理在180的阈值下依然会残留。更稳妥的办法是先用OTSU自适应阈值代替固定阈值:
_, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)OTSU会根据图片像素分布自动计算最佳分割阈值,比拍脑袋填一个数字靠谱得多。我在实际项目里统一用OTSU,省去针对每种验证码调阈值的功夫,识别率反而还更稳定。
还有一个很有效的小技巧:图片放大。验证码原图通常只有几十到一百多像素宽,字符笔画很细,OCR引擎识别时容易分不清。把图片放大2到3倍再做二值化,识别率会有肉眼可见的提升。放大操作用Pillow来做更顺手:
from PIL import Image img = Image.open('captcha.png') # 放大3倍,使用Lanczos重采样保留边缘细节 img = img.resize((img.width * 3, img.height * 3), Image.LANCZOS) img.save('captcha_scale.png')通常我会先截图、再放大、再转OpenCV做灰度滤波二值化。处理完之后,字符区域干净、背景干扰少,OCR识别率能提高30个百分点以上,这不是夸张。
4.4 OCR识别:调用Tesseract并解析结果
图像处理完,轮到pytesseract上场。这里有一个容易被忽视但很关键的参数:--psm。Tesseract的page segmentation mode(页面分割模式)影响它对图片结构的理解。验证码通常是单行短文本,设置psm为7(把图片当作单行文本处理)或8(把图片当作单个单词处理),比默认的psm 3识别率更高。
import pytesseract # 指定Tesseract可执行文件路径(Windows常见) pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe' text = pytesseract.image_to_string( Image.open('captcha_binary.png'), config='--psm 7 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' )注意char_whitelist这个参数,它限定OCR只从指定的字符集合里选结果。如果确认验证码只包含数字,那白名单就只写数字:
--psm 7 -c tessedit_char_whitelist=0123456789白名单限定的作用非常明显,它直接杜绝了把“0”识别成“O”、把“1”识别成“I”这类低级错误。如果页面验证码本身不区分大小写,全转大写或全转小写再做白名单,又能再提一档准确率。
识别完别忘了清理结果字符串。OCR输出的文本里常混着空格、换行符、甚至OCR自己拼接的奇怪字符,需要做一轮清洗:
# 清理识别结果 captcha_code = ''.join(filter(str.isalnum, text))4.5 自动化填入,以及识别失败的重试机制
OCR识别不是100%命中的,所以用例逻辑里必须带上重试机制。完整的自动化流程应该这样写:
- 定位输入框,清空,填入识别出的验证码
- 点击登录按钮
- 等待1到2秒,判断是否登录成功(比如出现某个成功标志元素,或者URL发生变化)
- 如果失败,刷新验证码图片,重新执行上述步骤
- 重试超过3次,标记该用例执行失败,并保存调试截图
刷新验证码图片常见的方式是点击验证码图片本身,或者点击“换一张”链接。如果两种方式都没有,可以刷新整个页面。刷新之后需要重新等待验证码图片加载完成,保险起见用显式等待或简单sleep。
给出的完整核心流程示例代码如下,这是可以直接放到测试框架里改造的版本:
import time import cv2 import pytesseract from selenium import webdriver from selenium.webdriver.common.by import By from typing import Optional from PIL import Image def get_captcha_text(driver, captcha_element) -> Optional[str]: """ 对验证码元素截图、预处理、OCR识别 """ # 1. 截图 captcha_element.screenshot('captcha.png') # 2. 放大 img = Image.open('captcha.png') img = img.resize((img.width * 3, img.height * 3), Image.LANCZOS) img.save('captcha_scale.png') # 3. 转灰度+滤波+OTSU二值化 gray = cv2.cvtColor(cv2.imread('captcha_scale.png'), cv2.COLOR_BGR2GRAY) blurred = cv2.medianBlur(gray, 3) _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) cv2.imwrite('captcha_binary.png', binary) # 4. OCR识别 text = pytesseract.image_to_string( Image.open('captcha_binary.png'), config='--psm 7 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' ) cleaned = ''.join(filter(str.isalnum, text)) return cleaned if cleaned else None def login_with_captcha(driver, username, password, max_retry=3): """ 带验证码识别重试机制的登录流程 """ for attempt in range(max_retry): try: # 1. 定位验证码图片元素并识别 captcha_element = driver.find_element(By.ID, 'captcha_img') code = get_captcha_text(driver, captcha_element) if not code: print(f"第{attempt + 1}次识别结果为空,刷新重试") driver.refresh() time.sleep(1) continue # 2. 填写账号密码和验证码 driver.find_element(By.ID, 'username').clear() driver.find_element(By.ID, 'username').send_keys(username) driver.find_element(By.ID, 'password').clear() driver.find_element(By.ID, 'password').send_keys(password) driver.find_element(By.ID, 'captcha').clear() driver.find_element(By.ID, 'captcha').send_keys(code) # 3. 提交 driver.find_element(By.ID, 'login_btn').click() time.sleep(2) # 4. 判断是否成功 if driver.find_element(By.ID, 'welcome').is_displayed(): print("登录成功") return True else: print(f"第{attempt + 1}次登录失败,可能验证码识别错误,刷新重试") driver.refresh() time.sleep(1) except Exception as e: print(f"第{attempt + 1}次执行异常: {e}") driver.refresh() time.sleep(1) return False代码中有几个值得展开的细节。
截图的路径建议用绝对路径或者动态生成的临时文件名,不要在循环里反复覆盖同一个相对路径文件,容易在并发执行或清理临时文件时出问题。图片缩放用的LANCZOS重采样,是一种高清晰度的缩放算法,比默认的NEAREST或者BILINEAR在处理字符边缘时保留更多细节,这一点对识别率是有实际提升的。
另一个细节是每步处理后的图片都保存下来了。截图原图、放大图、二值化图,三张图都落盘。这是调试的好习惯:如果最终识别结果不对,回头看这三张图,就知道是截图截错了、预处理把字符处理坏了、还是OCR本身识别错误。没有中间过程的图片,出问题只能盲猜。
4.6 登录接口的额外校验:剩下一点工作别忘记
识别并填入验证码之后,点击登录只是动作,系统后端会校验验证码是否匹配。验证码正确与否的反馈通常有两种模式。第一种是页面局部刷新提示“验证码错误”,不跳转;第二种是直接弹窗或者跳转页面。自动化脚本需要根据实际系统来适配成功判断逻辑。
如果系统弹出的是alert弹窗,Selenium可以用switch_to.alert处理,弹窗内容反向判定验证码是否正确。如果系统在验证码输入框下方用红色小字提示“验证码错误”,则可以用XPath定位这个提示文本是否存在来判断。根据我的经验,第二种方式更常见,判断脚本可以这样写:
error_toast = driver.find_elements(By.XPATH, '//*[contains(text(),"验证码错误")]') if error_toast: # 识别失败,重试还有一点,字符大小写问题。验证码本身是不区分大小写的,但OCR识别出来可能是大小写混在一起,系统后端校验时可能统一转大写。建议填写时也统一转大写,减少因大小写不匹配导致的误判:
driver.find_element(By.ID, 'captcha').send_keys(code.upper())5. 常见问题与排查技巧实录
做这个项目的过程中,我遇到并排查了不少问题,整理成一张速查表,方便大家对照解决。
| 问题现象 | 可能原因 | 排查思路与解决办法 |
|---|---|---|
| 截图结果为空白图片 | 元素未加载完成 | 截图前增加显式等待,等待img元素加载完成 |
| 截图结果为空白 | 页面有无头模式视口过小 | 设置--window-size=1920,1080 |
| 找不到验证码元素 | 验证码在iframe内 | 先driver.switch_to.frame()再定位 |
| 识别结果包含奇怪字符 | 图片未做白名单限制 | 设置tessedit_char_whitelist |
| 识别率很低,输出乱码 | 图片未预处理或预处理不足 | 执行灰度化、滤波、OTSU二值化 |
| 识别结果长度不对 | 有干扰线或字符粘连 | 考虑形态学操作,如膨胀让笔画连通 |
| 每次都提示验证码错误 | 刷新后识别太慢,验证码已过期 | 优化预处理耗时,或缩短页面等待时间 |
| 直接requests请求图片识别却失败 | 动态token绑定,图片已过期 | 改用Selenium元素截图,保证图码一致 |
| 登录成功后跳转又要求再次验证 | 第一次验证码请求在点击时被刷新 | 检查页面是否存在自动刷新验证码逻辑 |
这里特别提两个开发中容易踩的隐蔽问题。
第一,部分系统的验证码图片是在点击登录按钮之后才会过期,而另外一些系统是验证码每次刷新都会把之前的验证码作废。如果是第二种,那么OCR识别耗时太长反而会把自己坑了。解决方案是优化图像处理代码的执行速度,减少不必要的图片保存和重复读写,让识别到填写的间隔控制在1秒以内。
第二,识别结果为空时,不要直接判定用例失败。我在实际测试中遇到过连续两次识别为空,第三次才识别成功的情况,这通常是因为验证码图片恰好和页面背景色接近,预处理时整个图片被二值化成全白或全黑。可以在预处理之后加一道检查:统计二值化图片的黑色像素占比,如果占比过高或过低,说明图片内容异常,直接跳过这次识别、刷新重试,不要浪费OCR识别的时间。
import numpy as np binary_array = np.array(binary) black_ratio = (binary_array == 0).sum() / binary_array.size print(f"黑色像素占比: {black_ratio:.2f}") if black_ratio < 0.02 or black_ratio > 0.95: print("图片内容异常,跳过本次识别") return None这种“提前止损”的思路,可以让自动化用例的执行效率提升不少。因为OCR识别本身是个耗时的操作,如果图片预处理后明显没有有效内容,再调用OCR就是白白等上几秒。
关于OCR识别失败的调试方法,我建议把每次识别的三张中间图(原图、放大图、二值图)和识别结果、实际提交结果都写进日志。跑一个晚上,第二天统计一下识别率大概是多少、失败集中在哪些图片上,用小批量样本针对性地调整预处理参数,比东改一下西改一下效率高得多。
6. 实操效果与后续优化空间
这套方案在小规模项目中实测下来,简单背景、无干扰线的数字验证码识别率能到90%以上;中等复杂度的英文数字混合、带干扰线的验证码,识别率大约在70%到85%之间。配合3次重试机制,登录流程的最终通过率能稳定在95%以上,基本能够支撑日常自动化回归。
如果只把识别逻辑当作一个工具方法封装起来,后续还可以做三件事提升健壮性。第一,接入一个备用识别通道,当本地OCR识别结果置信度低或者连续失败时,自动切换;第二,维护一个“特征模板库”,针对该项目特定的验证码风格,在预处理阶段添加针对性操作;第三,把验证码图片存入测试数据目录,定期人工回顾,持续调整阈值参数。
这里多提一句边界问题。验证码本质上是系统防自动化攻击的安全机制,本文介绍的处理方案只用于合法的测试环境,也就是你作为测试人员拥有该系统测试授权的场景。把验证码识别技术用于未经授权的系统、批量注册、恶意攻击等行为,既违反法律法规,也不符合工程师的职业道德,这一点请务必把握清楚。
从做这个项目的体感来说,最想分享的一个经验是:处理动态图片验证码的核心挑战不是Selenium,也不是Tesseract,而是“图片干净程度”。很多OCR识别结果不理想的案例,都是因为没有做好预处理就匆忙调用了识别引擎。截图之后先放大、去噪、二值化,再考虑OCR参数调优,这条路是性价比最高的优化路径。
另外一个小技巧放在最后:跑自动化用例时,如果验证码图片始终识别困难,可以试试把图片做一次“反色”处理。有些验证码是深色背景浅色字符,直接二值化后字符和背景的明暗关系是反的,OCR识别时会很吃力。做一次反色,再接常规处理流程,识别率会有明显回升。这不是一个常规操作,但确实是我踩过几次坑之后总结出来的有效办法,以后遇到类似情况记得试一下。