1. 整体思路:为什么是 python + unittest + html
先说清楚一个核心观点:UI 自动化测试框架不是越复杂越好,而是越适合团队当前阶段越好。
我刚接到这个任务时,团队里没有专门的测试开发岗,测试同学普遍只会写简单的 Python 脚本,对 pytest、BDD、Allure 这套生态并不熟悉。如果一上来就引入 pytest + requests + Allure + CI/CD 全链路,学习成本和维护成本都很高,落地周期会被拉得很长。所以我选择了一条“够用、好懂、快上手”的技术路线:Python + Selenium + unittest + HTMLTestRunner。
这套组合的特点,我用一句话概括:Python 负责写逻辑,Selenium 负责操作浏览器,unittest 负责组织用例和断言,HTMLTestRunner 负责把执行结果变成一份能直接发给领导和开发看的 HTML 报告。
简单拆解一下每个组件的定位:
- Python:开发语言,生态成熟,第三方库丰富,写起脚本来比 Java 少很多样板代码。
- Selenium:UI 自动化的事实标准,支持 Chrome / Firefox / Edge 等主流浏览器,能模拟用户的点击、输入、滚动、悬停等操作。
- unittest:Python 标准库自带的测试框架,不需要额外安装,支持用例组织、批量执行、断言、fixture(setUp / tearDown)等机制。
- HTMLTestRunner:一个基于 unittest 结果生成 HTML 报告的三方扩展库。虽然官方原版还停留在 Python 2 时代,但社区有适配 Python 3 的魔改版,用起来完全没问题。
这个组合适合的人群非常明确:自动化测试从 0 到 1 阶段的测试工程师、想快速搭建一套可持续回归 UI 用例的技术团队、以及那些不想把框架搞成“玩具项目”但又没空研究复杂生态的务实派。
接下来,我会从框架设计、目录结构、核心代码、问题排查四个层面,完整复盘这套框架的搭建过程。这篇文章不是教程式的堆叠,而是一个踩过坑的人的实践总结,很多细节不亲自动手是写不出来的。
2. 框架设计与目录结构:先把地基打稳
2.1 设计原则:分层、配置化、可维护
很多新手搭 UI 自动化框架,最大的问题不是不会写用例,而是把用例和底层操作全部搅在一起。比如在用例里直接driver.find_element(...)、直接写time.sleep(5)、直接处理弹窗……这样写 10 条用例还行,写到 50 条的时候,一旦页面元素 ID 变了,你得一个一个去改用例里的定位表达式,维护成本直接爆炸。
所以我在搭建这套框架时,坚持了三个原则:
第一,分层隔离。把用例、页面元素、公共操作、配置信息拆成不同模块。用例层只关心“做什么业务操作、断言什么结果”,底层通过 Page Object 模式把元素定位和操作细节封装掉。这样页面变了,动的只是对应页面类,用例本身不受影响。
第二,配置驱动。浏览器类型、被测系统地址、超时时间、账号密码这些信息,不硬编码在代码里,而是统一放到配置文件(config)中。换环境、换浏览器,改配置文件即可,不用改代码。
第三,结果可视化。自动化测试跑了之后,必须能生成直观、带截图、带错误信息的报告。HTMLTestRunner 在这里承担了“中间件”的角色,它能把 unittest 的执行结果转换成带统计信息的网页报告。
2.2 目录结构设计
我最终采用的目录结构如下,你可以根据自己的项目规模做删减:
ui_test_framework/ ├── config/ │ ├── __init__.py │ └── config.py # 全局配置(URL、浏览器、超时、账号等) ├── common/ │ ├── __init__.py │ ├── logger.py # 日志封装 │ ├── screenshot.py # 失败截图处理 │ └── send_mail.py # 测试报告邮件发送 ├── pages/ │ ├── __init__.py │ ├── base_page.py # Page Object 基类,封装 driver 公共操作 │ ├── login_page.py # 登录页对象 │ └── home_page.py # 首页对象 ├── testcases/ │ ├── __init__.py │ ├── test_login.py # 登录功能的测试用例 │ └── test_home.py # 首页相关测试用例 ├── test_data/ │ ├── __init__.py │ └── data.yaml # 测试数据(可选,用例少时可直接写在用例里) ├── reports/ │ └── (生成的 HTML 报告和截图会放这里) ├── logs/ │ └── (运行日志会放这里) ├── run_all.py # 测试执行入口 └── requirements.txt # 依赖清单这里我重点解释两个容易被忽略的设计细节:
一个是 pages 目录的存在意义。没有 pages 目录的框架,用例里会到处是driver.find_element(By.ID, "username").send_keys("admin")这种代码。有了 pages 目录,业务页面被抽象成对象,登录页长什么样、首页有哪些功能按钮,都在对象内部描述。用例层只需要调用login_page.login("admin", "123456")就行。这就是 Page Object 模式的核心价值:把“页面结构”和“业务用例”解耦。
另一个是 common 目录下的公共模块。logger、screenshot、send_mail 这些不是锦上添花,而是自动化测试跑起来之后的刚需。日志能帮你定位问题是在哪一步挂的,截图能在报告里直观展示页面当时的状态,邮件通知能让团队成员在早上看到昨晚回归的结果。
2.3 依赖清单(requirements.txt)
依赖文件看起来简单,但版本锁定是个良心活。我的 requirements.txt 内容如下,供参考:
selenium==4.15.2 webdriver-manager==4.0.1 PyYAML==6.0.1很多教程会让你直接pip install selenium装最新版,但我在实践中遇到过升级后 API 不兼容的情况。所以建议你固定大版本,尤其是团队协作时,依赖不一致会导致“我本地能跑,你本地报错”的尴尬场面。
注意:HTMLTestRunner 默认不在 PyPI 官方源里,需要单独下载源文件放到项目中。我一般创建一个
common/HTMLTestRunnerCN.py或common/HTMLTestRunner.py的模块,从国内社区下载适配 Python 3 的版本放进去。后面代码里会用from common import HTMLTestRunner这样的方式导入。
另外推荐安装webdriver-manager,它会自动帮你下载和管理对应浏览器的 driver 版本。省去手动下载 chromedriver 的痛苦,尤其当你本地浏览器更新后 driver 版本不匹配时,这个库能救你一把。
3. 核心代码逐层实现:从配置到报告一次跑通
3.1 配置文件:config/config.py
配置模块是所有其他模块的地基。我把环境和账号信息都放在这里,并且通过dict组织,方便后续扩展多套环境(测试环境、预发布环境)。
# -*- coding: utf-8 -*- import os BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 环境配置,可切换 ENV = "test" ENVIRONMENTS = { "test": { "url": "https://test.example.com", "username": "test_user", "password": "test_pass", }, "pre": { "url": "https://pre.example.com", "username": "pre_user", "password": "pre_pass", }, } CURRENT_ENV = ENVIRONMENTS[ENV] # Selenium 配置 BROWSER = "chrome" # chrome / firefox / edge HEADLESS = False # 是否无头模式(本地调试时建议 False) IMPLICITLY_WAIT = 10 # 隐式等待,单位秒 PAGE_LOAD_TIMEOUT = 30 # 页面加载超时 EXPLICIT_WAIT = 15 # 显式等待默认超时 # 报告和日志 BASE_URL = CURRENT_ENV["url"] REPORT_DIR = os.path.join(BASE_DIR, "reports") LOG_DIR = os.path.join(BASE_DIR, "logs") SCREENSHOT_DIR = os.path.join(REPORT_DIR, "screenshots") # 确保目录存在 for _dir in [REPORT_DIR, LOG_DIR, SCREENSHOT_DIR]: if not os.path.exists(_dir): os.makedirs(_dir)说一个配置细节:隐式等待和显式等待不要混用成双重等待导致效率低下。隐式等待是全局的,一旦设置,所有find_element都会生效。显式等待是针对特定条件的,比如等待某个元素可点击。我一般全局设一个 10 秒的隐式等待,在需要精细化控制的地方再叠加显式等待。如果你把隐式等待设成 30 秒,用例失败时每个元素定位都要等满 30 秒,调试体验会非常差。
3.2 日志模块:common/logger.py
日志在 UI 自动化里特别重要。因为用例跑失败时,不能保证每次都有截图,而日志记录了每一步操作的时间点和动作内容。我封装了一个轻量的 logger:
# -*- coding: utf-8 -*- import logging import os import time from config.config import LOG_DIR class Logger: """统一日志管理器""" def __init__(self, name="ui_test"): self.logger = logging.getLogger(name) self.logger.setLevel(logging.INFO) # 避免重复初始化 if not self.logger.handlers: fmt = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") # 文件输出 log_file = os.path.join(LOG_DIR, f"test_{time.strftime('%Y%m%d')}.log") fh = logging.FileHandler(log_file, encoding="utf-8") fh.setLevel(logging.INFO) fh.setFormatter(fmt) self.logger.addHandler(fh) # 控制台输出 ch = logging.StreamHandler() ch.setLevel(logging.INFO) ch.setFormatter(fmt) self.logger.addHandler(ch) def get_logger(self): return self.logger log = Logger().get_logger()我踩过的坑是:如果Logger()被多个模块重复调用,每次都会加 handler,最终日志会重复打印好几遍。所以我在 init 里加了if not self.logger.handlers的判断。这个细节虽然不起眼,但能让你的日志干净很多。
3.3 基类页面封装:pages/base_page.py
base_page.py 是整个框架的“底盘”,所有页面对象都会继承它。这里我封装了 Selenium 的常用操作:元素定位、点击、输入、等待、截图、滚动等。
# -*- coding: utf-8 -*- import time import os from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from selenium.common.exceptions import TimeoutException, NoSuchElementException from config.config import EXPLICIT_WAIT, SCREENSHOT_DIR from common.logger import log class BasePage: def __init__(self, driver): self.driver = driver def find_element(self, locator, timeout=EXPLICIT_WAIT): """显式等待并查找元素,locator 格式为 (By.ID, "username")""" try: element = WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) return element except TimeoutException: log.error(f"元素定位超时: {locator}") self.take_screenshot() raise def find_element_clickable(self, locator, timeout=EXPLICIT_WAIT): """查找可点击元素,用于按钮等场景""" try: element = WebDriverWait(self.driver, timeout).until( EC.element_to_be_clickable(locator) ) return element except TimeoutException: log.error(f"元素不可点击: {locator}") self.take_screenshot() raise def input_text(self, locator, text): """输入文本前先清空再输入,避免残留内容""" element = self.find_element(locator) element.clear() element.send_keys(text) log.info(f"输入文本: {text} -> {locator}") def click(self, locator): """点击元素""" element = self.find_element_clickable(locator) element.click() log.info(f"点击元素: {locator}") def take_screenshot(self, name=None): """失败时截屏保存,文件名为时间戳+场景名""" if name is None: name = time.strftime("%Y%m%d_%H%M%S") file_path = os.path.join(SCREENSHOT_DIR, f"{name}.png") self.driver.save_screenshot(file_path) log.info(f"截图保存: {file_path}") return file_path def switch_to_iframe(self, iframe_locator): """处理 iframe 场景""" self.driver.switch_to.frame(self.find_element(iframe_locator)) log.info(f"切换到 iframe: {iframe_locator}")我特意在input_text里写了element.clear(),因为很多输入框默认有 placeholder 或者上次输入的残留值,不清空直接send_keys可能导致文本拼接。这个细节看似零碎,但真实业务里出现率极高。
另外,截图逻辑必须放进公共方法中。这样一旦元素定位失败,无论哪个页面对象,都会自动截图,不用在每条用例里单独处理。
3.4 页面对象:pages/login_page.py
有了基类,写页面对象就轻松多了。以登录页为例:
# -*- coding: utf-8 -*- from selenium.webdriver.common.by import By from pages.base_page import BasePage from common.logger import log class LoginPage(BasePage): """登录页面对象""" # 页面元素定位:统一集中在页面类顶部,方便维护 username_input = (By.ID, "username") password_input = (By.ID, "password") login_button = (By.ID, "loginBtn") error_toast = (By.CLASS_NAME, "el-message") def login(self, username, password): """登录操作,返回实例以便链式调用或断言""" self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button) log.info(f"用户 {username} 执行登录操作") return self def get_error_message(self): """获取登录失败时的错误提示""" return self.find_element(self.error_toast).text这里使用了selenium.webdriver.common.by.By的元组定位方式。这种方式比直接传字符串更规范,ide 还能自动补全,减少低级拼写错误。
关于页面对象要不要返回新页面的对象,比如return HomePage(self.driver),我可以告诉你:框架简单时别过度设计。返回self足够支撑大多数场景了。真要切换到新页面,直接在用例里driver已经是最新页面状态,不需要搞复杂的页面跳转关系。
3.5 测试用例:testcases/test_login.py
用例层是最直观的一层,也是测试同学最常写的部分。我强烈建议你遵守 unittest 的规范:用例类继承unittest.TestCase,方法名以test_开头。
# -*- coding: utf-8 -*- import unittest from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service from config.config import BASE_URL, BROWSER, HEADLESS from common.logger import log from pages.login_page import LoginPage class TestLogin(unittest.TestCase): """登录功能测试用例""" @classmethod def setUpClass(cls): """整个测试类只启动一次浏览器""" log.info("========== 开始执行登录用例 ==========") options = webdriver.ChromeOptions() if HEADLESS: options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") # webdriver-manager 自动匹配浏览器版本 service = Service(ChromeDriverManager().install()) cls.driver = webdriver.Chrome(service=service, options=options) cls.driver.maximize_window() cls.driver.get(BASE_URL) cls.driver.implicitly_wait(10) @classmethod def tearDownClass(cls): """测试类结束后关闭浏览器""" cls.driver.quit() log.info("========== 登录用例执行结束 ==========") def test_login_success(self): """正常流程:正确账号密码登录成功后跳转首页""" login_page = LoginPage(self.driver) login_page.login("test_user", "test_pass") # 断言 URL 发生变化,说明跳转成功 self.assertIn("home", self.driver.current_url, "登录成功后未跳转到首页") def test_login_failed_with_wrong_password(self): """异常流程:密码错误时出现错误提示""" login_page = LoginPage(self.driver) login_page.login("test_user", "wrong_pass") error_text = login_page.get_error_message() self.assertIn("密码错误", error_text, f"错误提示文案不符,实际为: {error_text}") def test_login_with_empty_password(self): """边界场景:密码为空时给出提示""" login_page = LoginPage(self.driver) login_page.login("test_user", "") error_text = login_page.get_error_message() self.assertNotEqual(error_text, "", "密码为空时未出现错误提示") if __name__ == "__main__": unittest.main()关于setUpClass和setUp的选择,我说说自己的使用经验:用例量少、且用例之间没有太强依赖时,我倾向于使用 setUpClass 只启动一次浏览器,能节省大量时间。如果用例量很多,或者每条用例需要独立环境(比如不同用户身份登录),那就用setUp,每个用例都启动新浏览器。这要看你业务场景具体决定,没有绝对对错。
不得不提的是assertIn("home", self.driver.current_url)这种断言。UI 自动化的断言不能只停留在“元素存在”层面,要充分利用页面状态:URL 变化、按钮可用性、标题变化、元素是否存在。这样用例才能更真实地反映业务预期。
3.6 入口文件:run_all.py
这是框架的“总开关”,作用包括:收集所有用例、生成 HTML 报告、记录日志。
# -*- coding: utf-8 -*- import unittest import time import os from common import HTMLTestRunner from config.config import REPORT_DIR def all_cases(): """加载 testcases 目录下所有测试用例""" suite = unittest.TestLoader().discover( start_dir=os.path.join(os.path.dirname(os.path.abspath(__file__)), "testcases"), pattern="test_*.py", ) return suite def run(): suite = all_cases() # 报告文件名带时间戳,方便历史对比 report_name = f"ui_test_report_{time.strftime('%Y%m%d_%H%M%S')}.html" report_path = os.path.join(REPORT_DIR, report_name) with open(report_path, "wb") as fp: runner = HTMLTestRunner.HTMLTestRunner( stream=fp, title="UI 自动化测试报告", description="测试环境: 测试环境 | 执行人: QA", verbosity=2, ) runner.run(suite) print(f"测试报告已生成: {report_path}") return report_path if __name__ == "__main__": run()unittest.TestLoader().discover()会自动扫描指定目录下所有符合pattern的文件,把里面的TestCase收集到一个测试集里。这就是为什么我建议测试文件命名必须用test_开头,否则 discover 会漏掉。
关于 HTMLTestRunner 的导入方式,不同版本差异很大。有的版本是from HTMLTestRunner import HTMLTestRunner,有的是from HTMLTestRunnerCN import HTMLTestRunner。建议你先在本地写个空用例跑一遍,确认报告能生成再继续。
3.7 报告中的截图展示:把失败证据嵌入报告
默认 HTMLTestRunner 的失败信息只有文字 traceback,看不到页面现场。为了更直观,我在失败的时候会调用take_screenshot()并给addFailure加自定义逻辑。不过这里有个麻烦:HTMLTestRunner 原生的addFailure方法不会自动嵌入截图。
我的处理方式比较轻量:在断言失败的地方,通过self.failureException配合自定义函数,把截图路径写入日志;同时在报告里通过增加description在测试方法的 docstring 里写用例描述,报告就会自动显示用例名称和 docstring。如果你想要截图直接嵌入 HTML 报告,需要对 HTMLTestRunner 的TestResult类做定制,我会在第 4 章里详细讲这个问题。
3.8 邮件通知:一条命令把报告发给团队
自动化测试的价值在于持续回归,而持续回归如果没有通知机制,基本等于“黑盒”。我封装了一个 send_mail 模块,执行完用例后自动把报告作为附件发送:
# -*- coding: utf-8 -*- import smtplib import os from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.application import MIMEApplication SMTP_SERVER = "smtp.example.com" SMTP_PORT = 465 SENDER = "qa@example.com" PASSWORD = "your_password" RECEIVERS = ["dev@example.com", "pm@example.com"] def send_report(report_path): msg = MIMEMultipart() msg["From"] = SENDER msg["To"] = ",".join(RECEIVERS) msg["Subject"] = "UI 自动化测试报告" body = MIMEText("UI 自动化测试已执行完毕,报告见附件。", _charset="utf-8") msg.attach(body) with open(report_path, "rb") as f: part = MIMEApplication(f.read()) part.add_header("Content-Disposition", "attachment", filename=os.path.basename(report_path)) msg.attach(part) # 如果使用 465 端口,用 SMTP_SSL;如果使用 587,用 SMTP + starttls server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT) server.login(SENDER, PASSWORD) server.sendmail(SENDER, RECEIVERS, msg.as_string()) server.quit()邮件发送这个功能,在真实团队里非常实用。不过要注意:密码不要硬编码到代码里,可以用环境变量或单独的配置文件管理,避免把敏感信息推到代码仓库中。
4. 实操过程与核心环节实现:把框架跑起来
4.1 环境准备:Python 版本与依赖安装
环境准备是很多新手第一个“拦路虎”。我的建议是使用 Python 3.8 及以上版本,推荐 3.10。就这套框架来说,Python 3.10 兼容性最好。
建立虚拟环境也是一个好习惯。我见过很多同事在电脑上直接pip install,结果不同项目依赖冲突,最后要么升级破碎,要么重启大法。推荐这样做:
# 创建虚拟环境 python -m venv venv # 激活(Windows) venv\Scripts\activate # 激活(macOS / Linux) source venv/bin/activate # 安装依赖 pip install -r requirements.txt依赖安装好后,可以快速验证 Selenium 是否可用:
python -c "from selenium import webdriver; print('selenium ok')"4.2 浏览器驱动自动化管理:webdriver-manager 的使用
以前手动下载 chromedriver 是件烦人的事,你还需要找到与自己 Chrome 版本完全匹配的驱动。后来我发现webdriver-manager这个库能自动检测 Chrome 版本并下载对应驱动。
from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service)不过说句实话,在公司的 CI 服务器上,我一般会直接下载好驱动放到固定的目录,用环境变量PATH指定,避免每次都用 webdriver-manager 从网上下载占用时间。本地开发用 webdriver-manager 是真方便,双管齐下。
4.3 元素定位策略:少用 xpath 绝对路径
UI 自动化的头号困难就是元素定位。我的经验排序是:
- 优先使用
id - 其次是
name、class name - 再是
css selector - 最后才考虑
xpath(尤其不要用绝对路径)
xpath看起来万能,但页面结构调整时极容易碎。//*[@id="app"]/div[1]/div[2]/div[1]/form/input这种绝对路径,只要多套一层 div 就全挂。我通常只在确实没有好的属性可用时才写 xpath,而且尽量用相对定位:
# 推荐:通过文本定位按钮 (By.XPATH, "//button[contains(text(), '登录')]") # 推荐:通过 placeholder 属性定位输入框 (By.XPATH, "//input[@placeholder='请输入用户名']")4.4 等待策略:远离 time.sleep
新手最喜欢写time.sleep(5),好像不 sleep 页面就加载不出来。但 sleep 的问题很严重:固定时间等待,页面快了浪费时间,页面慢了直接报错。
正确做法是使用 Selenium 的显式等待(WebDriverWait)配合 expected_conditions:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.ID, "username")) )这样做的效率远高于 sleep:元素一出现立刻继续执行,最长只等设定的 10 秒。后面我在 BasePage 里封装的find_element就集成了这一套逻辑,所以页面对象里不要再去写driver.find_element_by_id这样的裸调用。
4.5 用例执行顺序:unittest 默认并非文件顺序
有个坑必须提醒你:unittest 的用例执行顺序不是定义顺序,而是按方法名的 ASCII 码排序。
比如你定义了:
def test_login_success(self): # 先执行 def test_logout(self): # 后执行?实际上 unittest 可能会先执行test_login_success,再执行test_logout,因为 'l' 在 'o' 前面。如果你有明确的顺序依赖,建议用例之间保持独立,或者通过命名前缀控制执行顺序(test_01_login、test_02_logout)。但我的建议是:用例一定要独立,不要有顺序依赖。自动化最怕的是“前一条影响后一条”,排查问题翻车率极高。
4.6 HTMLTestRunner 报告的深度定制
很多网络上流传的 HTMLTestRunner 版本界面比较粗糙。我看了一下我手里这个版本,其实可以做得更精致一点,在某些版本里你可以通过修改HTMLTestRunner.py里的 CSS 来调整报告样式。
但比起样式,我更推荐关注三大核心功能:
- 失败用例的 Traceback 展示:报告默认会显示失败详情,这非常重要,能让人一眼看出断言失败原因。
- 测试用例的分组和统计:报告顶部通常有通过/失败/错误数量的统计饼图或数字,适合在汇报时直接展示。
- 持续集成的存档:报告中带上时间戳文件名,方便和上一轮结果做 diff,看回归趋势。
我一般还会通过unittest.TestSuite把冒烟用例单独分出,跑一个快速子集:
def smoke_suite(): """只跑冒烟用例""" suite = unittest.TestSuite() suite.addTest(TestLogin("test_login_success")) return suite这样在每天快速回归前,可以先跑冒烟用例,挂了就报警,剩下的大批量用例没必要浪费时间。
4.7 多浏览器的执行策略
框架如果只支持一种浏览器,说服力就打折了。所以我习惯把浏览器参数化:
webdriver.Chrome() webdriver.Firefox() webdriver.Edge()在 config.py 里加一个BROWSER变量,然后根据变量的值选择不同 driver:
def get_driver(browser_name): if browser_name == "chrome": return webdriver.Chrome(...) elif browser_name == "firefox": return webdriver.Firefox(...) elif browser_name == "edge": return webdriver.Edge(...) else: raise ValueError(f"不支持的浏览器: {browser_name}")实测下来,Chrome 和 Edge 的兼容性最好,Firefox 偶尔会遇到关闭浏览器时 driver 进程不释放的坑。
5. 常见问题与排查技巧实录
5.1 元素定位超时:明明看得到,但脚本说找不到
这是 UI 自动化最典型的问题。我的排查顺序是:
- 看截图:先打开失败自动截图,确认页面是否停留在预期位置。
- 看等待条件:如果页面有异步加载(AJAX),需要增加显式等待,比如等待某个元素出现、等待按钮可点击。
- 检查 iframe:如果元素在 iframe 里,直接 find 是找不到的,需要先
switch_to.frame()。 - 检查窗口/标签页:如果点击后弹出了新窗口,要
switch_to.window()切换句柄,再定位元素。 - 检查元素属性是否动态变化:很多前端框架(比如 React / Vue)的 class 是动态生成的,比如
class="el-input el-input--medium"。这种用 class 定位很容易挂,改用固定的id或>options = webdriver.ChromeOptions() options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False)但请注意,这类反爬反自动化手段会持续升级,遇到较强的风控系统时,最可靠的方式是跟开发沟通开放测试后门,或者使用企业内部的测试专用账号。不要去研究绕过风控的“黑科技”,投入产出比太低,容易走歪。
5.3 报告样式错乱:HTMLTestRunner 与 Python3 的兼容问题
原版 HTMLTestRunner 是 Python 2 时代的作品,直接用到 Python 3 会报错或报告样式异常。解决路径有两条:
- 使用开源社区维护好的 Python 3 版本,直接下载放到项目里。
- 如果只是小问题,打开
HTMLTestRunner.py源码,看报错的具体行,通常是把import StringIO改成from io import StringIO、把sys.stdout.write这类改掉就行了。
我这边已经处理过这类问题,如果你用的是我给的目录结构,把
HTMLTestRunner.py放到common目录下后,在run_all.py中from common import HTMLTestRunner就能正常工作了。5.4 中文乱码问题:报告全是问号
报告里中文乱码,一般是因为文件编码问题。解决方法是:
- 生成报告时,以
wb模式打开文件并在标题/描述中写中文; - 源码文件头部统一加
# -*- coding: utf-8 -*-; - 如果还乱码,可能是 HTMLTestRunner 源码中写死了某些编码,检查有没有
io.open之类的写法,并指定encoding="utf-8"。
5.5 用例之间的耦合影响:driver 被意外关闭
我在测试早期遇到过一个问题:一条用例因为某个异常导致
driver被关闭,后续所有用例全部报错。排查下来发现,问题主要出在有些用例的 finally 块中调用了driver.quit(),而其他用例还在用同一个 driver。解决方案:driver 的创建和销毁只能发生在
setUpClass/tearDownClass中,用例内部不要随意 quit。如果要隔离浏览器实例,就使用setUp/tearDown让每个用例独立驱动,而不是在用例中间夹杂quit和重新get的逻辑。5.6 CI 流水线中跑 UI 自动化:需要无头模式
本地跑能过,上了 CI 服务器死活不行。最常见原因是:CI 服务器是 Linux 系统,没有图形界面。解决办法就是在 get_driver 里根据环境变量打开无头模式:
HEADLESS = os.environ.get("HEADLESS", "false") == "true" if HEADLESS: options.add_argument("--headless=new") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--window-size=1920,1080")--no-sandbox和--disable-dev-shm-usage在容器环境里几乎必备,否则 Chrome 会报诡异的异常。5.7 元素点击失败:明明元素存在,点击却无效
这种问题也特别多。原因可能是:
- 元素被其他 div 遮住了,Selenium 的 click 会先做可见性检查,如果元素不可见或不可点击会抛
ElementClickInterceptedException。 - 元素在页面的可视区域之外,需要先
driver.execute_script("arguments[0].scrollIntoView();", element)滚动过去。 - 页面有 loading 遮罩层没消失,按钮虽然存在但被 loader 挡了。
我的处理办法是在 BasePage 的 click 方法里增加一个“滚动到元素 + 强制点击”的 fallback:
def click(self, locator): try: element = self.find_element_clickable(locator) element.click() except Exception: element = self.find_element(locator) self.driver.execute_script("arguments[0].scrollIntoView();", element) self.driver.execute_script("arguments[0].click();", element)这个技巧能解决很多“能看到但点不动”的怪问题。当然,这只是应急手段,真正的根因是前端页面可能渲染延迟,加入显式等待比强点更好。
6. 经验总结:这套框架还能往哪走
框架跑起来只是第一步,真正让它产生价值的是持续迭代。根据我实际操作中的体会,这套 python + unittest + html 组合在支撑中小规模 UI 自动化回归时完全够用,而且因为结构简单、依赖少,团队成员接手门槛很低。
如果后续项目规模变大,可以考虑几个方向:
- 引入数据驱动:把测试数据抽到 Excel 或 YAML 中,用
ddt或parameterized库参数化用例,减少大量重复的用例方法。 - 往 pytest 迁移:pytest 的 fixture 机制和插件生态确实更强,如果你有精力,可以逐步迁移,但前提是团队有共识,不要中途反复横跳。
- 结合 Allure 报告:Allure 能生成更美观、交互更强的报告,还支持历史趋势。如果你经常向管理层汇报,Allure 值得换。
- 接入 Jenkins / GitLab CI:把
python run_all.py当成一个 CI 任务,定时触发,报告自动归档,这是自动化测试发挥持续回归价值的核心一步。
最后再分享一个我在实际使用中的体会:UI 自动化测试的价值不在于用例数量多,而在于稳定、可信、可持续。我见过很多人堆了几百条用例,结果每天跑完一大半在报错,开发已经免疫了报告,看到红灯根本不慌。真正好用的框架,是一方面用例设计得干净稳定,另一方面能快速定位问题。这套框架的打法,就是为此设计的。你按这个思路落地,至少不会走得偏。