简介:基于Python Selenium的Web自动化测试设计与实现文档,面向软件测试工程师与自动化测试入门者,系统阐述如何借助开源工具Selenium搭建Web自动化测试体系,以解决手工回归测试效率低、Bug修复成本高等问题。文档从自动化测试的重要性入手,分析其适用条件、优势与不足,并详细解析Selenium支持多浏览器、跨平台、免费开源等特性,重点介绍pageobject设计模式在页面对象模块、测试用例模块、公共模块及Log管理模块中的具体应用。同时,按环境搭建、页面对象设计、测试用例编写、公共模块构建到测试执行的全流程展开,帮助读者掌握从零构建Web自动化项目的关键步骤与排错思路;文档语言通俗,示例清晰,便于边读边动手实践。资源为单个doc文档,约727KB,已有242人学习,内容结构完整,既有理论铺垫也有实现细节,适合需要规范化测试流程、提升测试效率的中初级测试人员以及正在开展相关课程设计的开发者参考。
1. 自动化测试是把 bug 发现成本向前移的工程手段
Web 项目功能越多,回归测试要覆盖的组合就越多,手工执行时漏点、输错数据这类低频但致命的失误也会跟着变多。自动化测试最直接的收益不是替代测试人员,而是把每次回归的耗时从几十分钟压到几十秒,同时让“同一条用例每次执行步骤完全一致”成为默认前提。WebTours 是一个需求稳定的机票订票系统,注册、登录、订票三个主流程重复度极高、预期结果明确,很适合用来验证 Python + Selenium 这条技术链路。下面从 Selenium 的通信原理开始拆,再落到 POM 分层、unittest 组织、最终稳定执行这几个关键节点。
2. Selenium 工作原理与元素定位:先搞清楚请求链路,再写 find_element
2.1 WebDriver 协议下的三个角色
用 Selenium 写自动化,看上去只是把手工点击换成driver.find_element,但整条链路里至少有脚本、浏览器驱动、浏览器三个角色在协作。Python 脚本调用 WebDriver API 时,命令会被组装成 HTTP 请求发送给浏览器驱动;驱动收到请求后,把命令转换成浏览器能识别的 native 调用;浏览器执行完点击、输入、滚动等操作后,再把结果原路返回给脚本。
这条链路最容易翻车的点是版本匹配。Selenium 3 时代,Chrome 有对应的 chromedriver,Firefox 用 geckodriver,IE 用 IEDriverServer,驱动版本必须和浏览器主版本接近,否则启动阶段就会直接报 session 创建失败。安装 Selenium 本身不复杂:
pip install selenium==3.141.0提示:不要只记
pip install selenium。Selenium 3 和 Selenium 4 的 API 大部分兼容,但浏览器驱动匹配逻辑有差异,项目里最好锁定版本,避免团队里有人环境不一致。
驱动是否在 PATH 里、是否和浏览器版本匹配、被测系统地址是否在 config 里统一维护,这三件事应该在写第一条用例前完成。项目里 WebTours 默认跑在 1080 端口,启动被测系统后先用一个最小脚本验证环境通不通:
from selenium import webdriver from selenium.webdriver.common.by import By driver = webdriver.Chrome() driver.get("http://localhost:1080/WebTours/") driver.find_element(By.NAME, "username").send_keys("auto_user") driver.find_element(By.NAME, "password").send_keys("123456") driver.find_element(By.XPATH, "//input[@value='Login']").click() assert "Welcome" in driver.page_source driver.quit()这里By.NAME和By.XPATH是定位方式参数,后面跟的字符串是具体的属性值或路径表达式。send_keys负责模拟键盘输入,click负责模拟鼠标点击。最后用driver.quit()关闭浏览器进程,不能只写driver.close(),否则 chromedriver 进程还会残留。
2.2 八种定位方式:什么时候用哪个
Selenium 官方把元素定位分成八种,项目里最常用的是 id、name、link_text 和 xpath。它们的适用场景差别很大:
| 定位方式 | Selenium 写法 | 典型场景 |
|---|---|---|
| id | driver.find_element(By.ID, "username") | 登录框、搜索框等唯一且稳定的 id |
| name | driver.find_element(By.NAME, "password") | WebTours 这类老系统大量使用 name |
| class name | driver.find_element(By.CLASS_NAME, "btn") | 样式类名短且唯一时 |
| tag name | driver.find_element(By.TAG_NAME, "input") | 枚举同类型元素 |
| link_text | driver.find_element(By.LINK_TEXT, "REGISTER") | 导航菜单文字链接 |
| partial_link_text | driver.find_element(By.PARTIAL_LINK_TEXT, "REG") | 链接文字长且动态变化 |
| xpath | driver.find_element(By.XPATH, "//input[@value='Login']") | 没有 id/name,需要按层级找 |
| css_selector | driver.find_element(By.CSS_SELECTOR, "input.btn") | 样式选择器定位,语法更紧凑 |
单个元素用find_element,枚举一组元素用find_elements。很多新手在重复结构页面里只取第一个元素,结果取到隐藏元素;这种情况先用find_elements拿列表,再按可见状态过滤。
2.3 定位不到时的排查顺序
元素定位失败不一定是 xpath 写错。常见的排查顺序是:先确认页面加载完成,再确认元素在 iframe 还是 shadow DOM 里,最后才回头检查表达式。WebTours 这类老系统页面结构稳定,登录页用 name 定位就够;但到了订票流程,日期控件和下拉列表往往是动态渲染的,xpath 写得太长很容易在页面改版后失效。定位表达式尽量只锚定元素自己的稳定属性,不要从<html>一路写到目标元素,否则中间任何一层结构变化都会让整条表达式报废。
3. POM 模式落地:page、testcase、common、config 四个模块怎么分工
3.1 为什么不用线性脚本
把登录、注册、订票写成一段从上到下的线性脚本,跑通很容易,但维护成本会快速上升。页面元素一变,所有引用这个元素的用例都要跟着改;登录逻辑变了,订票用例里写死的那段登录操作也要逐个找出来改。Page Object 模式的关键是让页面对象和测试用例分离:每个 Web 页面维护一个 page 类,类里保存页面元素定位和操作这些元素的方法,测试用例只面向业务操作,不关心元素长什么样。
POM 的另一个收益是基类复用。BasePage 封装公共的查找、点击、输入、等待逻辑后,LoginPage、RegisterPage、MainPage 都从它继承。页面元素发生改变时,只改对应 page 类,不需要动测试用例。项目里 page 模块下的子类就是围绕 loginpage、mainpage、registerpage 来展开的。
3.2 BasePage 基类封装
BasePage 至少要封装一个带显式等待的查找方法,避免页面还没渲染完就执行输入。常见做法是把WebDriverWait放到基类里:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver, timeout=10): self.driver = driver self.timeout = timeout def find_element(self, locator): return WebDriverWait(self.driver, self.timeout).until( EC.presence_of_element_located(locator) )locator是(By.NAME, "username")这样的元组,传入find_element后,WebDriverWait 会每隔 500ms 检查一次元素是否出现,直到超时。默认 10 秒超时,内网测试环境可以缩到 5 秒,外网测试环境建议放宽到 20 秒。
子类里把元素定位和业务动作放在一起,测试用例调用时不需要知道元素细节:
from selenium.webdriver.common.by import By from page.basepage import BasePage class LoginPage(BasePage): username = (By.NAME, "username") password = (By.NAME, "password") login_btn = (By.XPATH, "//input[@value='Login']") def login(self, user, pwd): self.find_element(self.username).send_keys(user) self.find_element(self.password).send_keys(pwd) self.find_element(self.login_btn).click() def get_error_tip(self): return self.find_element((By.XPATH, "//font[contains(text(),'Error')]")).textusername这类类属性定义的是定位方式与值的组合,login()方法描述业务动作,get_error_tip()把断言需要的页面结果抽取出来。即使 WebTours 登录按钮从 xpath 改成 css,也只需要改 LoginPage 里的login_btn,测试用例不用动。
3.3 common 公共模块的七个工具类
项目里 common 模块集中了和业务无关的公共能力,包括报告、日志、浏览器配置、截图、邮件、数据读取。它们通常被测试用例或 runner 调用,不直接操作页面:
| 文件 | 职责 | 调用时机 |
|---|---|---|
| HTMLTestRunner | 生成 HTML 格式测试报告 | 用例集执行后 |
| LogGen | 统一输出运行日志 | 每个用例执行时 |
| OpenBrowser | 按配置启动指定浏览器 | setUp 阶段 |
| ReadConfig | 读取 config 配置 | 启动浏览器前 |
| ScreenShot | 失败时保存截图 | 断言异常时 |
| SendMail | 发送测试报告附件 | 全部用例结束后 |
| ReadData | 读取 Excel 测试数据 | 参数化用例 |
以 LogGen 为例,日志模块最怕重复添加 handler,导致同一行日志打三遍。实现时要做一次 handler 存在性判断:
import logging import os import time def loggen(): logger = logging.getLogger("auto_test") logger.setLevel(logging.INFO) if not logger.handlers: log_dir = "logs" os.makedirs(log_dir, exist_ok=True) log_file = os.path.join(log_dir, time.strftime("%Y%m%d_%H%M%S") + ".log") fh = logging.FileHandler(log_file, encoding="utf-8") fmt = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") fh.setFormatter(fmt) logger.addHandler(fh) return loggergetLogger("auto_test")返回同一个 logger 实例,if not logger.handlers保证重复调用时不会累积第二个 FileHandler。日志文件名用时间戳,方便按运行批次回溯问题。
3.4 config 配置文件集中管理环境差异
被测系统地址、浏览器类型、超时时间这些环境相关参数,应该从配置文件中读取,而不是散落在脚本里。项目里 config 模块使用 ini 文件:
[app] url = http://localhost:1080/WebTours/ browser = chrome timeout = 10对应读取类用 Python 内置 configparser 实现:
import configparser import os class ReadConfig: def __init__(self, path="config/config.ini"): self.cf = configparser.ConfigParser() self.cf.read(path, encoding="utf-8") def get_url(self): return self.cf.get("app", "url") def get_browser(self): return self.cf.get("app", "browser") def get_timeout(self): return self.cf.getint("app", "timeout")OpenBrowser 再根据get_browser()的返回值决定创建 Chrome、Firefox 还是 IE 驱动。这样换一台机器执行时,只需要改 config.ini,不需要动代码。
4. unittest 组织测试:TestFixture、TestSuite、断言与参数化
4.1 四个核心类串起执行流程
unittest 是 Python 标准库里的单元测试框架,Web 自动化测试里也一样适用。它把测试组织成四个角色:
- TestFixture:用例执行前后的环境准备和清理,对应
setUp/tearDown - TestCase:一个具体的测试类,继承
unittest.TestCase - TestSuite:多个测试用例的集合,控制执行顺序
- TestRunner:执行 TestSuite 并输出结果
4.1.1 setUp 的粒度要控制好
使用类级别的setUpClass只启动一次浏览器,效率高;但用例之间存在登录状态依赖时,类级别共享 session 会把失败扩散。WebTours 这样的系统,建议登录流程用setUpClass,注册、订票流程用方法级setUp,保证每条用例的初始状态可控。
4.2 TestSuite 按业务顺序组合用例
如果直接运行测试类,unittest 默认按方法名的 ASCII 顺序执行,test_01login会在test_02register之前执行,但多个类之间就没有天然顺序。要保证“先注册、再登录、最后订票”,用 TestSuite 显式组装:
import unittest from common.HTMLTestRunner import HTMLTestRunner from testcase.test_01login import LoginTest from testcase.test_02register import RegisterTest from testcase.test_03booking import BookingTest suite = unittest.TestSuite() suite.addTest(RegisterTest("test_register_success")) suite.addTest(LoginTest("test_login_success")) suite.addTest(BookingTest("test_booking_ticket")) with open("reports/report.html", "wb") as f: runner = HTMLTestRunner( stream=f, title="WebTours 自动化测试报告", description="Chrome + Selenium 3.141.0" ) runner.run(suite)addTest参数是测试类和测试方法名的字符串组合,suite.run会按照 add 的顺序执行,而不是按命名排序。HTMLTestRunner 是 unittest 的第三方扩展,作用是把执行结果渲染成带统计图表和失败堆栈的 HTML 页面,便于测试人员直接查看。
注意:HTMLTestRunner 老版本基于 Python 2 的 StringIO,Python 3 下运行时需要把
import StringIO改成import io,否则导入阶段就会报 ModuleNotFoundError。
4.3 断言设计:不只判断对错,还要能快速定位失败点
登录成功与否常用assertTrue判断某个元素是否存在,但 WebTours 登录失败时页面会弹出红色错误提示,失败原因不同,提示文本也不同。断言时优先比较具体的提示文本,而不是只判断页面有没有变化:
import unittest class LoginTest(unittest.TestCase): def test_login_wrong_password(self): self.login_page.login("auto_user", "000000") tip = self.login_page.get_error_tip() self.assertIn("Password", tip)assertIn是成员断言,这里用来判断错误提示里是否包含Password关键字。相比assertEqual(tip, "Invalid password"),assertIn能容忍页面文案部分变化,断言稳定性更好,但也不能太宽泛,否则错误类型会被漏掉。
4.4 参数化:让多组用户名密码走同一条用例
登录测试最怕写五个几乎一模一样的test_login_001、test_login_002。用 ddt 做参数化,一组数据对应一条执行记录,报告里也能看到每条数据各自的执行结果:
import ddt import unittest @ddt.ddt class LoginTest(unittest.TestCase): @ddt.data( ("auto_user", "123456", True), ("auto_user", "000000", False), ) @ddt.unpack def test_login_with_cases(self, username, password, expected): result = self.login_page.is_login_success(username, password) self.assertEqual(result, expected)@ddt.data里的每个元组被拆成三个方法参数:用户名、密码、预期结果。如果测试数据量很大,可以再由 ReadData 从 Excel 读取测试数据,把数据源从代码中剥离出来。参数化不是让用例数量变少,而是让同一业务规则在所有数据组合上都执行一遍,方便发现某个边界值触发的 bug。
5. 让脚本能跑十次不坏:截图、用例隔离、重试三个收敛点
5.1 失败截图与日志关联
断言失败时只记录一句AssertionError,事后很难判断是页面没加载出来,还是断言条件真的不满足。在tearDown里检查测试状态,失败时调用 ScreenShot 保存现场:
import time def tearDown(self): if not self._outcome.success: timestamp = time.strftime("%Y%m%d_%H%M%S") self.driver.get_screenshot_as_file(f"screenshots/{self._testMethodName}_{timestamp}.png") self.driver.quit()get_screenshot_as_file只接受本地路径,截图命名里带上测试方法名和时间戳,可以在后续排查时把图片和日志里同一时间点的异常堆栈对应起来。日志里建议同时记录当前页面的 URL,很多定位失败是点击跳到了错误页面。
WebTours 订票流程里的目的地如果用了非原生下拉框,也就是不是<select>而是<div><ul><li>组合,Selenium 的 Select 类无法直接处理。常见做法是先点击外层触发控件,再按文本匹配列表项:
driver.find_element(By.CSS_SELECTOR, "div.select-trigger").click() driver.find_element(By.XPATH, "//li[text()='Denver']").click()5.2 注册用例的可重复执行
注册测试最隐蔽的问题是重复运行。第一次运行注册了auto_user,第二次再注册同一个用户名会提示用户已存在,用例就会失败。解决方式是用时间戳生成唯一用户名,避免用例对历史数据产生依赖:
username = "auto_user_" + time.strftime("%Y%m%d%H%M%S")订票用例还要在用例内先完成注册和登录,不能假设上一个用例已经留下了登录态。用例之间通过浏览器会话共享状态,前期看起来省事,后期一旦失败,排查范围会扩大到整个执行链。
5.3 重试策略与最终校验
网络抖动或元素加载慢导致的偶发失败,可以通过失败重跑收敛。但重试不是简单地把用例包在 while 循环里,而是只对明确由超时引起的问题做重跑,断言失败和元素找不到要区分处理。更稳妥的做法是连续完整跑三次,三次都通过且报告里无人工修复,才认为框架可以交给每日回归。最后一次重跑通过后,再在 Jenkins 上把python run_tests.py挂到每日定时构建里,让 HTMLTestRunner 生成的报告通过 SendMail 自动发送到测试组邮箱。
本文还有配套的精品资源,点击获取