Python+Selenium+Pytest实战:豆瓣电影Web UI自动化测试框架搭建指南
1. 项目概述与核心价值
最近在整理团队的技术资产,翻到了一个几年前做的豆瓣电影Web UI自动化测试项目。当时是为了验证一个电影信息聚合页面的改版效果,用Python+Selenium+Pytest搭了一套测试框架。现在回头看,这个项目麻雀虽小,五脏俱全,涵盖了从环境搭建、元素定位、测试用例设计到报告生成的全流程,非常适合作为Web UI自动化测试的入门实战案例。很多新手朋友一上来就想搞复杂的电商或管理系统,往往在环境配置和元素定位上就卡住了,豆瓣电影这个目标网站结构相对清晰,交互不算复杂,但该有的表单、列表、分页、动态加载一个不少,是练手的绝佳沙盒。
这个实战项目的核心目标,是带你走通一个完整的Web UI自动化测试流水线。你不仅能学会如何用Selenium操控浏览器,更能掌握如何用Pytest这样专业的测试框架来组织你的测试代码,让它变得可维护、可扩展。最终,你会得到一套能够自动打开浏览器、登录豆瓣(如果需要)、搜索电影、验证电影详情、检查评分和评论列表,并自动生成一份漂亮测试报告的脚本。这对于测试同学来说,是提升效率、保证回归测试质量的利器;对于开发同学,是理解测试思维、构建自测能力的桥梁;对于想转行自动化测试的朋友,这更是一块分量十足的敲门砖。
2. 环境搭建与核心工具链解析
工欲善其事,必先利其器。在开始写第一行测试代码之前,一个稳定、一致的环境是成功的基石。很多人自动化测试之路的第一次“放弃”,就发生在环境配置的混乱中。
2.1 Python环境与包管理
Python是我们的主语言,选择3.7及以上版本即可,确保对异步等新特性的支持。我强烈建议使用 venv 或 conda 创建独立的虚拟环境,这是避免包冲突的黄金法则。别嫌麻烦,一个干净的环境能为你省下无数排查“为什么在我电脑上就好好的”这种问题的时间。
# 创建虚拟环境
python -m venv douban_ui_test_env
# 激活虚拟环境 (Windows)
douban_ui_test_env\Scripts\activate
# 激活虚拟环境 (MacOS/Linux)
source douban_ui_test_env/bin/activate
接下来,通过 pip 安装核心的三件套。这里有个关键点: 不要盲目安装最新版 。特别是Selenium,其版本需要与你使用的浏览器驱动版本匹配。一个稳定的组合是: selenium==4.x + 对应的浏览器驱动。
pip install selenium==4.15.0
pip install pytest==7.4.0
pip install pytest-html==4.0.0 # 用于生成HTML报告
pip install webdriver-manager==4.0.0 # 自动管理浏览器驱动,强烈推荐给新手
注意 :
webdriver-manager是个神器,它能自动检测你的浏览器版本并下载匹配的驱动,极大降低了环境配置的复杂度。但对于公司内网等无法直接访问外网的环境,你可能需要提前手动下载驱动并指定路径。
2.2 浏览器与驱动配置
我们选择Chrome作为测试浏览器,因为它市场占有率高,开发者工具强大。重点来了: Chrome浏览器版本与ChromeDriver驱动版本必须匹配 。这是Selenium新手最常踩的坑。
方案一(推荐新手):使用webdriver-manager自动管理 在你的代码中,可以这样初始化驱动,让库去处理版本问题:
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
service = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service)
方案二(手动管理):
- 查看你的Chrome浏览器版本(在地址栏输入
chrome://settings/help)。 - 访问ChromeDriver官网或国内镜像站,下载与之匹配的版本号(主版本号一致即可)。
- 将下载的
chromedriver.exe放在项目目录下,或在代码中指定其路径。
from selenium import webdriver
service = Service(executable_path='./drivers/chromedriver.exe') # 指定路径
driver = webdriver.Chrome(service=service)
实操心得 :在团队协作中,我通常会在项目根目录建立一个 drivers/ 文件夹,将匹配团队主流浏览器版本的驱动放进去,并在 README.md 中明确说明。这样能保证所有成员在本地和CI/CD服务器上有一致的执行环境。
2.3 项目目录结构设计
一个清晰的目录结构是测试框架可维护性的基础。不要把所有代码都堆在一个文件里。下面是我为这个豆瓣电影项目设计的结构:
douban_ui_auto_test/
├── config/
│ ├── __init__.py
│ └── settings.py # 存放测试配置,如URL、超时时间、账号密码(勿提交)
├── page_objects/ # 页面对象模型(Page Object Model, POM)
│ ├── __init__.py
│ ├── base_page.py # 页面基类,封装公共方法
│ ├── login_page.py # 登录页面
│ ├── search_page.py # 搜索页面
│ └── movie_detail_page.py # 电影详情页
├── test_cases/ # 测试用例
│ ├── __init__.py
│ ├── conftest.py # Pytest的fixture配置,如驱动初始化
│ ├── test_search.py # 搜索功能测试
│ └── test_movie_detail.py # 详情页功能测试
├── utils/
│ ├── __init__.py
│ └── helper.py # 工具函数,如截图、数据读取
├── reports/ # 测试报告输出目录
├── logs/ # 日志输出目录
├── drivers/ # 浏览器驱动存放目录
├── requirements.txt # 项目依赖包列表
└── pytest.ini # Pytest配置文件
这个结构的核心思想是“分离关注点”。 page_objects 目录负责与页面元素交互的所有操作; test_cases 目录只关心测试逻辑和断言; config 和 utils 提供支持和工具。这样,当豆瓣电影的页面HTML结构发生变化时,你通常只需要修改 page_objects 下的对应文件,而不用到处去改测试用例。
3. 核心测试策略与页面对象模型(POM)实践
直接在被测页面上“裸写”Selenium操作代码是自动化测试的“石器时代”做法。代码会迅速变得难以阅读和维护。页面对象模型(Page Object Model, POM)是解决这一问题的标准设计模式。
3.1 POM设计思想解析
POM的核心是将一个Web页面抽象成一个Python类。这个页面上的元素(如输入框、按钮)成为这个类的属性(通常通过定位器定义),而在这个页面上可能进行的操作(如输入文本、点击)则成为这个类的方法。测试用例脚本不再直接调用Selenium的API,而是通过调用这些页面对象的方法来完成操作。
这样做的好处显而易见:
- 高可维护性 :页面元素定位器只存在于页面对象类中。一旦页面UI改动,只需更新对应的页面类,所有引用该元素的测试用例自动生效。
- 高可读性 :测试用例读起来就像业务文档,例如
search_page.input_keyword(“肖申克的救赎”)和search_page.click_search_button(),意图非常清晰。 - 低冗余 :公共操作(如等待、导航)可以封装在基类中,避免代码重复。
3.2 基础页面类与元素定位实战
我们首先创建所有页面对象的基类 base_page.py ,它封装一些每个页面都可能用到的通用方法。
# page_objects/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException
import logging
class BasePage:
def __init__(self, driver):
self.driver = driver
self.logger = logging.getLogger(__name__)
self.timeout = 10 # 默认显式等待超时时间
def find_element(self, locator):
"""查找单个元素,加入显式等待"""
try:
element = WebDriverWait(self.driver, self.timeout).until(
EC.presence_of_element_located(locator)
)
return element
except TimeoutException:
self.logger.error(f”元素定位超时: {locator}”)
self._take_screenshot(“element_not_found”)
raise
def click(self, locator):
"""点击元素"""
element = self.find_element(locator)
element.click()
def input_text(self, locator, text):
"""向输入框输入文本"""
element = self.find_element(locator)
element.clear()
element.send_keys(text)
def get_text(self, locator):
"""获取元素文本"""
element = self.find_element(locator)
return element.text
def _take_screenshot(self, name):
"""内部方法:截图,用于错误排查"""
screenshot_path = f”./logs/screenshot_{name}_{self._get_timestamp()}.png”
self.driver.save_screenshot(screenshot_path)
self.logger.info(f”截图已保存至: {screenshot_path}”)
def _get_timestamp(self):
from datetime import datetime
return datetime.now().strftime(“%Y%m%d_%H%M%S”)
接下来,以豆瓣电影搜索页为例,创建具体的页面对象类。 元素定位是UI自动化的重中之重 。Selenium提供了8种定位方式,优先级我推荐:ID > Name > CSS Selector > XPath。
# page_objects/search_page.py
from selenium.webdriver.common.by import By
from .base_page import BasePage
class SearchPage(BasePage):
# 1. 定义页面元素定位器(Locators)
# 使用 (By.策略, “值”) 的元组形式
SEARCH_INPUT = (By.ID, “inp-query”) # 豆瓣电影搜索框的ID
SEARCH_BUTTON = (By.CSS_SELECTOR, “.inp-btn > input[type=‘submit’]”)
FIRST_MOVIE_LINK = (By.CSS_SELECTOR, “.item-root .title > a”) # 搜索结果第一个电影链接
# 2. 页面操作方法
def open(self):
"""打开搜索页面"""
self.driver.get(“https://movie.douban.com/“)
return self
def search_movie(self, movie_name):
"""执行搜索操作"""
self.input_text(self.SEARCH_INPUT, movie_name)
self.click(self.SEARCH_BUTTON)
# 返回搜索结果页或详情页的对象,便于链式调用
# 这里简单处理,实际可能需要返回新的页面对象
return self
def get_first_movie_title(self):
"""获取第一个搜索结果的电影标题"""
# 注意:搜索结果可能需要短暂加载,find_element里已有等待
element = self.find_element(self.FIRST_MOVIE_LINK)
return element.text
def click_first_movie(self):
"""点击第一个搜索结果,进入详情页"""
self.click(self.FIRST_MOVIE_LINK)
# 在实际项目中,这里应该返回 MovieDetailPage 对象
from .movie_detail_page import MovieDetailPage
return MovieDetailPage(self.driver)
关键技巧 :定位元素时, 永远优先使用稳定的属性 。
id和name是开发赋予的,通常比较稳定。如果没有,CSS Selector在性能和可读性上通常优于XPath。尽量避免使用包含索引(如div[3])或复杂文本内容的XPath,因为它们极易因页面微调而失效。对于豆瓣这种动态内容,有时需要等待元素出现,这正是我们在base_page.find_element中集成显式等待的原因。
4. 使用Pytest组织与执行测试用例
有了健壮的页面对象,我们就可以用Pytest来编写和组织真正的测试用例了。Pytest比Python自带的unittest更简洁、功能更强大。
4.1 测试夹具(Fixture)的魔力
conftest.py 是Pytest的本地插件配置文件,其中定义的 fixture 可以被同一目录及子目录下的所有测试文件使用。我们在这里定义最关键的 fixture :WebDriver的初始化和清理。
# test_cases/conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
@pytest.fixture(scope=”function”) # 作用域:每个测试函数执行一次
def driver():
“”“初始化WebDriver”“”
# 使用webdriver-manager自动管理驱动
service = Service(ChromeDriverManager().install())
# 添加浏览器选项,使测试更稳定
options = webdriver.ChromeOptions()
options.add_argument(‘--headless’) # 无头模式,不显示GUI,适合CI环境
options.add_argument(‘--no-sandbox’)
options.add_argument(‘--disable-dev-shm-usage’)
options.add_argument(‘--disable-gpu’)
options.add_argument(‘--window-size=1920,1080’)
driver = webdriver.Chrome(service=service, options=options)
driver.implicitly_wait(5) # 设置隐式等待,全局生效
yield driver # 将driver对象提供给测试用例
# 测试函数执行完毕后,执行清理工作
driver.quit()
@pytest.fixture
def search_page(driver):
“”“提供一个已初始化的搜索页面对象”“”
from page_objects.search_page import SearchPage
page = SearchPage(driver)
page.open()
return page
关于 fixture 作用域 :
scope=”function”:默认值,每个测试函数都会重新初始化一次driver,保证测试隔离。scope=”class”:每个测试类初始化一次。scope=”module”:每个.py文件初始化一次。scope=”session”:一次测试会话(运行一次pytest命令)初始化一次。
对于UI自动化,我通常使用 function 级别,确保每个测试都是独立的,避免测试间的状态污染。虽然启动浏览器有一定开销,但换来了更高的测试可靠性。
4.2 编写可读的测试用例
现在,我们可以编写像自然语言一样描述业务场景的测试用例了。
# test_cases/test_search.py
import pytest
class TestDoubanMovieSearch:
“”“测试豆瓣电影搜索功能”“”
def test_search_by_movie_name(self, search_page):
“”“测试通过电影名称进行搜索”“”
# 1. 执行搜索
search_page.search_movie(“肖申克的救赎”)
# 2. 获取结果并断言
first_result_title = search_page.get_first_movie_title()
# 使用assert进行断言,这是pytest的方式
assert “肖申克的救赎” in first_result_title
# 更健壮的断言:忽略大小写和空格
assert “肖申克的救赎”.lower() in first_result_title.lower()
def test_empty_search_result(self, search_page):
“”“测试搜索不存在的电影,验证页面提示”“”
search_page.search_movie(“一个不存在的电影名称xyz123”)
# 假设没有结果时,页面会有提示元素,这里需要根据实际页面调整定位器
# no_result_locator = (By.CLASS_NAME, “no-result”)
# no_result_text = search_page.get_text(no_result_locator)
# assert “没有找到” in no_result_text
# 由于豆瓣页面行为可能变化,这里我们先注释掉具体断言,或改为验证页面URL/标题变化
assert search_page.driver.title is not None
@pytest.mark.parametrize(“movie_name, expected_keyword”, [
(“阿甘正传”, “阿甘”),
(“泰坦尼克号”, “泰坦尼克”),
(“盗梦空间”, “盗梦”),
])
def test_search_multiple_movies(self, search_page, movie_name, expected_keyword):
“”“参数化测试:用多组数据测试搜索功能”“”
search_page.search_movie(movie_name)
first_result_title = search_page.get_first_movie_title()
assert expected_keyword in first_result_title
Pytest的优势在这里体现得淋漓尽致 :
- 无需继承 :测试类不需要继承任何特定类。
- 断言简单 :直接用
assert语句,失败时pytest会给出丰富的上下文信息。 - 参数化测试 :使用
@pytest.mark.parametrize装饰器,轻松用多组数据驱动同一个测试逻辑,极大减少代码重复。 - Fixture依赖注入 :测试函数通过参数
search_page直接请求需要的fixture,框架自动完成初始化和注入。
4.3 测试执行与报告生成
在项目根目录下,你可以通过命令行执行测试:
# 运行所有测试
pytest
# 运行特定测试文件
pytest test_cases/test_search.py
# 运行特定测试类
pytest test_cases/test_search.py::TestDoubanMovieSearch
# 运行带标记的测试 (例如我们之前用的 @pytest.mark.parametrize)
pytest -m “parametrize”
# 详细输出,显示每个测试用例的名字
pytest -v
# 失败时立即停止
pytest -x
生成一份直观的HTML报告对于结果分析至关重要。使用 pytest-html 插件:
pytest --html=reports/report.html --self-contained-html
这条命令会生成一个独立的 report.html 文件,里面包含了测试通过率、执行时间、失败用例的错误日志和截图(如果集成了截图功能),非常便于在团队中分享和归档。
5. 高级技巧与常见问题实战排查
掌握了基础框架后,我们来看看如何应对更复杂的场景和那些让人头疼的“坑”。
5.1 处理动态加载与等待策略
现代Web应用大量使用Ajax和前端框架,元素不会一次性全部加载。 “等待”是UI自动化测试中最核心的同步机制 。
- 强制等待 (不推荐) :
time.sleep(5)。这是最原始的方法,固定等待时间,无论元素是否已加载完成。效率低下,且时间难以设定。 - 隐式等待 (全局设置) :
driver.implicitly_wait(10)。在driver的整个生命周期内,当查找元素时,如果元素没有立即出现,会轮询等待设定的时间。我们在conftest.py的driverfixture中已经设置了。但它只对find_element这类查找操作有效。 - 显式等待 (推荐) :针对特定条件进行等待,条件满足则立即继续,超时则报错。这是我们封装在
BasePage.find_element中的方法,使用了WebDriverWait和expected_conditions。
处理复杂等待场景 :
# 等待元素可点击
from selenium.webdriver.support.expected_conditions import element_to_be_clickable
wait = WebDriverWait(driver, 10)
button = wait.until(element_to_be_clickable((By.ID, “submit-btn”)))
button.click()
# 等待元素消失(如加载动画)
from selenium.webdriver.support.expected_conditions import invisibility_of_element_located
wait.until(invisibility_of_element_located((By.ID, “loading-spinner”)))
# 等待页面标题包含特定文字
wait.until(EC.title_contains(“搜索结果”))
实操心得 :很多间歇性失败的测试,根源都在于等待不充分或不恰当。我的经验是: 优先使用显式等待,辅以隐式等待作为全局保底,彻底避免强制等待 。对于复杂的交互流程,可以将多个等待条件组合。
5.2 处理弹窗、iframe与多窗口
-
弹窗 (Alert/Confirm/Prompt) :
from selenium.webdriver.common.alert import Alert alert = Alert(driver) alert_text = alert.text # 获取弹窗文本 alert.accept() # 点击“确定” # alert.dismiss() # 点击“取消” # alert.send_keys(“输入文本”) # 用于Prompt -
iframe :在操作iframe内的元素前,必须先切换到该iframe。
# 通过ID或Name切换 driver.switch_to.frame(“iframe_id”) # 操作iframe内的元素... # 操作完成后切回主文档 driver.switch_to.default_content() -
多窗口/标签页 :
# 获取当前所有窗口句柄 main_window = driver.current_window_handle all_windows = driver.window_handles # 切换到新窗口 for window in all_windows: if window != main_window: driver.switch_to.window(window) break # 操作新窗口... # 关闭新窗口并切回主窗口 driver.close() driver.switch_to.window(main_window)
5.3 典型问题排查清单
当你发现测试脚本运行时,可以按照以下清单进行排查:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
NoSuchElementException (元素找不到) |
1. 定位器错误或已过期 2. 页面未加载完成 3. 元素在iframe内 4. 元素被遮挡 |
1. 使用浏览器开发者工具重新检查元素属性。 2. 增加显式等待,确保元素加载出来。 3. 检查是否需要 switch_to.frame 。 4. 检查是否有弹窗、固定导航栏遮挡。 |
ElementNotInteractableException (元素不可交互) |
1. 元素不可见或被覆盖 2. 元素被禁用 (disabled) 3. 页面正在执行动画 |
1. 滚动元素到视口: driver.execute_script(“arguments[0].scrollIntoView();”, element) 2. 检查元素 disabled 属性。 3. 等待动画结束或使用 element_to_be_clickable 条件。 |
| 测试在本地通过,在CI服务器失败 | 1. 环境差异(浏览器/驱动版本) 2. 资源加载速度慢 3. 无GUI环境(Headless)差异 |
1. 使用 webdriver-manager 统一驱动版本。 2. 增加全局等待时间,优化网络环境。 3. 在Headless模式下,可能需要额外的浏览器选项,如设置窗口大小。 |
| 脚本执行速度慢 | 1. 过多 time.sleep 2. 隐式等待时间设置过长 3. 网络请求慢 |
1. 用显式等待替换所有 sleep 。 2. 合理设置隐式等待时间(如5-10秒)。 3. 考虑在测试环境中使用本地Mock或Stub服务。 |
| 页面跳转后元素定位失败 | 1. 页面跳转后,旧的WebElement对象失效 2. 未等待新页面加载 |
1. 每次操作后,如果需要,重新查找元素 。这是黄金法则。 2. 在跳转后使用显式等待新页面的关键元素出现。 |
一个关键的调试技巧 :在 find_element 失败时自动截图。我们在 BasePage 类中已经实现了 _take_screenshot 方法,并在异常时调用。这能帮你快速看到失败那一刻的页面状态,比看日志有效得多。
6. 测试数据管理与框架扩展
要让测试框架真正强大,还需要考虑测试数据的管理和一些增强功能。
6.1 数据驱动测试
将测试数据与测试逻辑分离是良好实践。我们可以使用 pytest 的 parametrize ,或者从外部文件(如JSON、YAML、Excel、CSV)读取数据。
例如,使用JSON文件管理测试数据:
// test_data/search_data.json
[
{
“movie_name”: “流浪地球”,
“expected_keyword”: “流浪地球”,
“test_case”: “搜索热门国产科幻片”
},
{
“movie_name”: “The Shawshank Redemption”,
“expected_keyword”: “肖申克”,
“test_case”: “搜索英文原名”
}
]
在测试中读取:
import json
import pytest
def load_search_data():
with open(‘./test_data/search_data.json’, ‘r’, encoding=‘utf-8’) as f:
return json.load(f)
@pytest.mark.parametrize(“data”, load_search_data())
def test_search_with_external_data(search_page, data):
search_page.search_movie(data[“movie_name”])
assert data[“expected_keyword”] in search_page.get_first_movie_title()
6.2 日志记录与失败重试
清晰的日志是排查问题的生命线。使用Python内置的 logging 模块,为你的测试框架添加日志。
# utils/logger.py
import logging
import os
def setup_logger(name, log_file, level=logging.INFO):
“”“Function to setup a logger.”“”
os.makedirs(os.path.dirname(log_file), exist_ok=True)
formatter = logging.Formatter(‘%(asctime)s - %(name)s - %(levelname)s - %(message)s’)
file_handler = logging.FileHandler(log_file)
file_handler.setFormatter(formatter)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger = logging.getLogger(name)
logger.setLevel(level)
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger
# 在conftest.py或测试用例中使用
test_logger = setup_logger(‘ui_test’, ‘./logs/ui_automation.log’)
对于网络不稳定或存在短暂波动的测试,可以使用 pytest-rerunfailures 插件实现失败自动重试。
pip install pytest-rerunfailures
执行时:
pytest --reruns 3 --reruns-delay 2 # 失败后重试3次,每次间隔2秒
6.3 集成到CI/CD流水线
自动化测试只有集成到持续集成/持续部署(CI/CD)流水线中,才能最大化其价值。你可以在Jenkins、GitLab CI、GitHub Actions等工具中配置任务,在每次代码提交或定时触发时自动运行测试。
一个简单的GitHub Actions配置示例 ( .github/workflows/ui-test.yml ):
name: UI Automation Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ‘3.9’
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Run UI Tests with Chrome
run: |
sudo apt-get update
sudo apt-get install -y wget unzip
# 安装Chrome (Headless)
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo “deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main” | sudo tee /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update
sudo apt-get install -y google-chrome-stable
# 运行测试并生成报告
pytest --html=reports/report.html --self-contained-html
- name: Upload Test Report
uses: actions/upload-artifact@v3
if: always() # 即使测试失败也上传报告
with:
name: ui-test-report
path: reports/
这个工作流会在每次推送代码或创建拉取请求时,自动在一个干净的Ubuntu环境中安装Python、Chrome浏览器、项目依赖,然后运行你的Pytest测试套件,并将生成的HTML报告保存为构件,供你下载查看。
走到这一步,你已经从一个编写简单脚本的测试者,升级为能够搭建和维护一个完整、健壮、可集成自动化测试框架的工程师。这套以豆瓣电影为蓝本的实战方案,其设计模式和解决思路完全可以平移到任何其他Web项目的UI自动化测试中。记住,框架是死的,思想是活的,核心在于对POM模式的理解、对等待机制的掌控,以及面对问题时系统化的排查能力。剩下的,就是在更多的实际项目中,去积累属于你自己的“避坑指南”了。
更多推荐
所有评论(0)