☰
Python+Selenium UI自动化测试框架搭建:分层设计、等待机制与用例实践
2026/10/11 12:10:49 网站建设 项目流程

做UI自动化测试差不多五年,Python和Selenium一直是我最常用的组合。身边常有测试同行问我:一套能真正跑起来、能进团队的自动化测试框架到底应该怎么搭?很多人学了一堆教程,最后还是卡在元素定位、等待机制和用例写法上。今天这篇就围绕Python+Selenium这套技术栈,从设计思路到手写代码,完整拆一个实用框架。

这套框架能做什么?它能帮你把重复的回归测试从手工点击变成自动执行,把测试用例组织成清晰的分层结构,失败时自动截图、生成报告,跑完大概十分钟就能知道所有功能是否正常。适合刚开始接触自动化测试的测试工程师,也适合想重构旧脚本体系的开发测试人员。下面不聊虚的,直接讲设计、结构和我能跑起来的Demo。

1. 框架整体设计与思路拆解

1.1 为什么是Python+Selenium,而不是其它组合

选择Python+Selenium,很大程度是团队现实决定的。Python语法简单,测试人员不需要花三个月去熟悉语法;Selenium对应WebDriver标准协议,能驱动Chrome、Firefox、Edge等主流浏览器,网上的问题和案例几乎搜一个有一个。相比之下,老牌QTP(现在叫UFT)录制回放上手快,但脚本结构和维护成本真的一言难尽;Robot Framework关键字驱动很灵活,可封装的复杂度也不低;带Node背景的团队可能选WebdriverIO,但它的资料和插件生态没有Python这边好养活。

我个人Build这套组合的逻辑很简单:用最少的学习成本,换取最高的可维护性。Python生态里有Pytest做测试执行、Allure或Pytest-HTML出报告、PyYAML管配置、Requests管接口,这些跟Selenium都能无缝衔接。真正复杂的是被测系统本身,而不是测试工具,工具上能少折腾就少折腾。

1.2 框架分层:让代码不再是一盘散沙

很多新手写的自动化脚本,问题不在于代码跑不起来,而在于所有逻辑混在一个文件里:元素定位写在函数里,测试步骤写在用例里,测试数据硬编码在脚本里。第一版能跑,到了第二个版本就没人敢动了。

我常用的是四层结构:用例层、页面对象层、驱动层、配置数据层。打个比方,用例层是顾客点单,页面对象层是后厨做菜,驱动层是采购食材,配置数据层是菜单和配料表。顾客不需要知道后厨怎么炒,后厨也不需要直接跟顾客解释。替换其中一层,其他层不会受到牵连。

层级职责对应代码位置
用例层描述业务场景,做断言test_cases/
页面对象层封装元素定位和操作,一个页面一个类pages/
驱动层负责创建浏览器实例,处理浏览器选项conftest.py
配置数据层管理URL、账号、超时时间、数据文件config/、data/

页面改动时只需要改页面对象层,用例层不用动;测试数据变了,改配置文件和数据文件就行。这套模式的本质是控制变化点,因为UI自动化测试里变化最频繁的就是元素属性和页面流程。

1.3 稳定性比花哨功能更重要

自动化测试最让人头疼的不是写不出用例,而是今天能过、明天就挂。所以框架设计里,稳定性必须排在所有功能前面。

我每次搭框架都强制内置三样东西:显式等待封装、统一日志、失败截图。等待不到位,用例会随机失败;日志不统一,出了问题很难定位是在哪一步挂的;失败没有截图,报错信息里只有一堆HTML,调试全靠猜。这三样东西在后面的章节会逐个演示。框架不需要为了炫技去引入一堆组件,能用最简单的代码解决稳定性问题,才是长期最省事的方案。

2. 环境搭建与工具选型

2.1 安装Python、Selenium和浏览器驱动

环境这块,新手比较容易卡在浏览器驱动上。先唠叨一下版本关系:Selenium通过浏览器驱动(比如ChromeDriver)来指挥浏览器,驱动版本必须和浏览器主版本匹配,否则启动时直接报错。

安装步骤按下面走:

  1. 从Python官网下载安装包,记得勾选Add Python to PATH,避免后面找不到命令。
  2. 命令行验证:python --version。
  3. 安装依赖:pip install selenium pytest pytest-html pyyaml。
  4. 根据自己浏览器版本下载对应驱动,把驱动放到能被PATH找到的目录,例如Windows下放C:\WebDriver并配置环境变量。

Selenium 4.x内置了Selenium Manager,很多情况下会自动寻找驱动,省去手动配置的时间,但公司网络限制、浏览器版本太新时,它不一定管用。所以我还是建议你掌握手动下载、手动配置驱动的方法,遇到问题能更快定位。

2.2 用Pytest做测试运行器,而不是unittest

Python自带的unittest不是不能用,只是用它写自动化用例体验不好:断言写法又长又碎,setUp和tearDown做全局初始化很别扭,各种条件跳过和参数化做起来也繁琐。Pytest在社区里几乎成了Python测试的事实标准,几个核心优势直接解决了这些问题:

  • 断言直接使用assert,写起来自然。
  • Fixture替代setup/teardown,能精细控制实例的创建和销毁范围。
  • 大量插件,比如失败重跑、报告生成、并发执行,随用随加。

下面是一个最基础的driver fixture,放在test_cases/conftest.py里:

import pytest from selenium import webdriver from utils.config_reader import Config @pytest.fixture(scope="function") def driver(): browser = Config.get("browser", default="chrome") if browser == "chrome": options = webdriver.ChromeOptions() options.add_argument("--window-size=1920,1080") driver = webdriver.Chrome(options=options) else: driver = getattr(webdriver, browser.capitalize())() driver.get(Config.get("base_url")) yield driver driver.quit()

scope="function"表示每个测试用例独享一个浏览器实例,用例之间互不污染。这是UI自动化稳定性的重要基础。

2.3 项目目录结构规划

目录结构不一定要很庞大,但我建议一开始就按职责分好,后面代码不迷路。我常用的目录骨架如下:

auto_test/ ├── config/ │ └── config.yaml ├── pages/ │ ├── base_page.py │ └── login_page.py ├── test_cases/ │ ├── conftest.py │ └── test_login.py ├── utils/ │ ├── config_reader.py │ ├── logger.py │ └── screenshot.py ├── data/ │ └── login_data.json ├── logs/ └── reports/

每个目录的定位:config存放运行环境、浏览器的参数;pages放页面对象;test_cases放测试用例,自然也可以理解为业务场景和断言;utils放公共工具,比如读取配置、写日志、截图;data存放测试数据,账号、用户名这类内容不要直接写死在脚本里;logs和reports是运行产物。这样划分以后,新人接手也能快速找到自己要改的地方。

3. 核心细节解析与实操要点

3.1 元素定位的细节:不要只会背XPath

元素定位是UI自动化里最碰运气的一环。网上的教程往往只教XPath语法,却很少教你怎么选择稳定定位方式。我自己的优先级是:ID > 唯一属性 > CSS Selector > XPath。ID稳定就优先用ID,没有ID就找name、>username_input = driver.find_element(By.CSS_SELECTOR, "input[name='username']") password_input = driver.find_element(By.CSS_SELECTOR, "input[name='password']") login_button = driver.find_element(By.CSS_SELECTOR, "button.login-btn")

XPath也有必要掌握,尤其是处理复杂页面时:

# 不要这样写,页面结构一变就废 element = driver.find_element(By.XPATH, "/html/body/div[1]/div[3]/form/input") # 尽量基于稳定属性定位 element = driver.find_element(By.XPATH, "//div[@class='login-box']//input[@name='username']")

我踩过一个真实的坑:某个前端框架生成的按钮ID每次刷新都会变,像btn_ab3f89这种动态字符串,脚本经常找不到元素。后来跟开发约定,给核心按钮加了个>from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By def wait_clickable(driver, locator, timeout=10): return WebDriverWait(driver, timeout).until( EC.element_to_be_clickable(locator) )

用显式等待时,默认超时我习惯给10秒,如果某个页面特别慢,单独给这个页面对象传更大的timeout。这样比统一的sleep(5)省时间,也稳定得多。

3.3 Page Object模式与BasePage封装

Page Object模式的核心思想是:一个页面对应一个类,类里封装页面上的元素和对这些元素的操作,测试用例只调用这些操作方法和断言结果。代码看起来会像这样:

class BasePage: def __init__(self, driver): self.driver = driver def find_element(self, locator, timeout=10): return WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) ) def click(self, locator): self.find_element(locator).click() def input_text(self, locator, text): element = self.find_element(locator) element.clear() element.send_keys(text) def get_text(self, locator): return self.find_element(locator).text

登录页继承这个基类:

from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): username_loc = (By.CSS_SELECTOR, "input[name='username']") password_loc = (By.CSS_SELECTOR, "input[name='password']") login_btn_loc = (By.CSS_SELECTOR, "button.login-btn") success_tip_loc = (By.CSS_SELECTOR, "div.welcome-msg") def login(self, username, password): self.input_text(self.username_loc, username) self.input_text(self.password_loc, password) self.click(self.login_btn_loc) def is_login_success(self): return self.find_element(self.success_tip_loc).text.startswith("欢迎")

这样测试用例的逻辑就变得非常简单,也更容易让人理解到底在测什么:

def test_login_success(driver): page = LoginPage(driver) page.login("tester", "123456") assert page.is_login_success()

4. 实操过程与核心环节实现

4.1 从零搭建框架的完整步骤

这一节我按自己真实的操作顺序走一遍,你跟着做就能跑起来。

第一步,创建项目目录和虚拟环境:

mkdir auto_test && cd auto_test python -m venv venv

在Windows的CMD里面激活:venv\Scripts\activate;在macOS/Linux是source venv/bin/activate。虚拟环境能隔离不同项目的Python依赖,防止全局包互相污染,强烈建议养成习惯。

第二步,安装依赖:

pip install selenium pytest pytest-html pyyaml

如果准备做失败重试,再加pytest-rerunfailures。

第三步,创建utils/config_reader.py,读取YAML配置:

import yaml class Config: _data = None @classmethod def load(cls, path="config/config.yaml"): with open(path, encoding="utf-8") as f: cls._data = yaml.safe_load(f) @classmethod def get(cls, key, default=None): if cls._data is None: cls.load() return cls._data.get(key, default)

config/config.yaml里面放:

base_url: http://127.0.0.1:8080 browser: chrome timeout: 10

第四步,写utils/logger.py,用Python标准库logging输出统一时间、级别和消息。这里可以不展开到很复杂,但日志一定要有,排查问题的时候全屏输出会让自己疯掉。

第五步,实现pages/base_page.py,把元素查找、点击、输入、截图这些通用操作封装好。

第六步,实现具体页面,比如pages/login_page.py。

第七步,在test_cases/conftest.py放driver fixture,负责启动和回收浏览器。

第八步,编写一条能通过的用例,运行pytest -s,看到绿色通过后再逐步加页面、加组件。

这套步骤的核心是“小步快跑”,先让一个流程跑通,再去堆框架组件。我见过很多团队第一周就在写通用框架,结果跑了三周连一条完整用例都没有,这是本末倒置。

4.2 以登录模块为例,写一套能直接用的用例

我们继续完善登录模块的例子。为了让用例不依赖固定账号,可以把测试数据放到data/login_data.json:

{ "valid_user": { "username": "tester", "password": "123456" }, "invalid_user": { "username": "tester", "password": "wrong_password" } }

用例层用参数化读取数据:

import pytest import json from pages.login_page import LoginPage with open("data/login_data.json", encoding="utf-8") as f: login_data = json.load(f) @pytest.mark.parametrize("user", [ login_data["valid_user"], login_data["invalid_user"] ]) def test_login(user, driver): page = LoginPage(driver) page.login(user["username"], user["password"]) if user["username"] == "tester" and user["password"] == "123456": assert page.is_login_success() else: assert page.is_login_fail()

为什么要把数据放到JSON而不是直接写在用例里?因为用例需要覆盖多组正常、异常输入,后期维护时直接改JSON文件,不用动代码。数据驱动的本质是代码逻辑不变,变化的数据和预期结果分离。

4.3 失败截图和HTML报告,给自动化装上仪表盘

用例报错后光看一行AssertionError是不够的,必须能看到页面当时的样子。我在conftest.py里用Pytest的hook实现失败自动截图:

import os import pytest @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: driver = item.funcargs.get("driver") if driver: report_dir = "reports" os.makedirs(report_dir, exist_ok=True) driver.save_screenshot(os.path.join(report_dir, f"{item.name}.png"))

这个hook会在每个测试用例执行结束时被调用,只关心call阶段失败的情况,拿到driver实例后截图到reports目录。这里的item.funcargs就是fixture返回的对象字典,前提是fixture名字叫driver。

报告可以继续用Pytest-HTML生成:

pytest --html=reports/report.html --self-contained-html

或者接Allure,把测试步骤、截图、关键日志都聚合到一个漂亮的面板。团队成员看报告比翻控制台输出直观得多。

5. 常见问题与排查技巧实录

5.1 元素定位失败:先排查,别急着改XPath

定位不到元素,报NoSuchElementException的时候,我的排查顺序是:

  1. 手动打开页面,按F12检查元素是否存在。如果手动能看到但脚本找不到,大多是把元素放进了iframe。
  2. 如果元素在iframe里,先切换到driver.switch_to.frame("frame_id"),再定位。
  3. 检查路径是否包含了动态值,比如元素id每次刷新都变。
  4. 检查页面是不是异步加载,页面虽然出来了,但元素还没渲染,需要增加显式等待。

我之前遇到一个经典的异步问题:登录按钮是在某个接口返回之后才渲染出来的,用find_element一上来就点,必然报错。改成WebDriverWait等待按钮可点击后,问题马上消失。所以在改定位器之前,先确认是不是等待问题,会更有针对性。

5.2 浏览器驱动版本不匹配,怎么快速解决

启动浏览器时报SessionNotCreatedException: This version of ChromeDriver only supports Chrome version xxx,基本就是驱动和浏览器主版本对不上。Chrome自动更新后,很多机器第二天就跑不了了。

处理步骤:

chrome --version # 确认浏览器版本

然后去ChromeDriver仓库下载与浏览器主版本一致的驱动,替换旧驱动。如果你是Selenium 4,可以删除手动配置的driver路径,让Selenium Manager自动匹配试试。但要注意公司网络如果有限制,自动下载可能失败,手动方案永远是最保底的。

5.3 用例之间互相影响,让执行顺序再也不背锅

当我发现测试用例“单个执行全通过,全量执行随机挂”,八成是用例之间出现了状态污染。比如在一个用例里登录了好几个账号,cookie和localStorage被反复覆盖;或者前一个用例改了数据库里某条记录,后一个用例刚好依赖这条记录。

解决思路是让用例尽量独立:

@pytest.fixture(scope="function") def driver(): # 每个用例都开新浏览器,彻底隔离状态 ...

如果确实需要共享登录状态,可以保留一个session级别的fixture,但内部要做好cookie清理和失败恢复。我通常更推荐用例独立执行——UI自动化的慢是可以接受的,但乱是不能接受的。

5.4 测试跑不稳定?试试失败重试和更合理的超时

即使等待都写了,用例偶尔还是会因为网络抖动、第三方服务慢而失败。这就是“Flaky Test”。可以在Pytest里加入失败重试:

pytest --reruns 2 --reruns-delay 1

这个插件会让失败的用例重跑最多2次,如果重试后通过,报告里标记为RERUN,至少能把偶发问题排查范围缩小。但重试不是万能的,不能因为用例不稳定就一律重试5次,那会掩盖真实Bug。我一般设置重试1-2次,同时把显式等待超时控制在合理范围。

还有一个细节:不要在断言前用长长的time.sleep(30)等结果。更稳的做法是轮询接口或页面元素状态,一旦符合预期就立刻继续。

这套框架我从最原始的函数式脚本改到现在的分层版本,中间踩过的坑远比写出来的多。最深的体会是,自动化测试不是为了炫耀代码,而是为了节省回归时间。框架没有绝对标准,团队习惯不同、被测系统的复杂度不同,允许灵活调整。如果你正打算搭自己的框架,建议从小用例开始,先让一条流程跑起来,再逐步加页面、加报表、加重试,一步步迭代。最后留个建议:把日志和截图当成一等公民,它们是你排查问题最可靠的眼睛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询