一、项目测试背景
本次开发的个人博客系统基于 SSM(Spring + SpringMVC + MyBatis)框架搭建,包含五个核心页面:用户登录页面、博客列表页面、博客详情页面、博客编辑页面及博客发布页面。为确保系统功能稳定、交互流畅,对系统开展了手动与自动化双重测试。
在自动化测试中,我们选择 Python + Pytest + Selenium + Allure 报告 + Logging 日志 技术栈,要求:
- 覆盖登录功能(正常/异常) 、导航栏跳转 、页面标题验证 、博客CRUD四大模块
- 每个模块用例数控制在合理范围,采用等价类划分、边界值分析等方法设计
- 日志分级输出(DEBUG/INFO/ERROR),三个日志文件,按大小自动分割(10MB/文件)
- 结构简单:测试脚本按页面拆分,公共逻辑放在
conftest.py与utils,不使用 YAML 与复杂设计模式 - 快照功能:仅在测试失败时自动拍摄截图,便于问题排查
- 浏览器复用:session 级别 fixture,整个测试会话只启动一次浏览器
本文首先给出手动测试用例设计(表格形式),再详细阐述自动化测试的实现方式。
二、手动测试用例设计
2.1 登录功能测试用例(共 10 条)
采用等价类划分 (正确/错误账号密码、空值)和边界值分析 (超长输入)设计,并增加 SQL 注入防护测试,分别测试用户名和密码字段。
| 用例编号 | 用例名称 | 优先级 | 操作步骤 | 预期结果 |
|---|---|---|---|---|
| TC-LOGIN-01 | 正确账号密码登录 | P0 | 1. 打开登录页 2. 输入账号lisi 3. 输入密码123456 4. 点击登录 |
跳转到博客列表页,左侧显示用户昵称"李四" |
| TC-LOGIN-02 | 密码错误 | P0 | 1. 账号lisi,密码123 2. 点击登录 |
弹窗提示"密码错误" |
| TC-LOGIN-03 | 用户不存在 | P0 | 1. 账号admin,密码123456 2. 点击登录 |
弹窗提示"用户不存在" |
| TC-LOGIN-04 | 账号为空,密码非空 | P1 | 1. 账号空,密码123456 2. 点击登录 |
弹窗提示"账号或密码不能为空" |
| TC-LOGIN-05 | 账号非空,密码为空 | P1 | 1. 账号lisi,密码空 2. 点击登录 |
弹窗提示"账号或密码不能为空" |
| TC-LOGIN-06 | 账号密码均为空 | P1 | 1. 不输入任何内容 2. 点击登录 | 弹窗提示"账号或密码不能为空" |
| TC-LOGIN-07 | 账号超长(51 个字符) | P2 | 1. 账号输入 51 个a,密码123456 2. 点击登录 |
系统不崩溃,提示"用户不存在"或"账号过长" |
| TC-LOGIN-08 | 密码超长(51 个字符) | P2 | 1. 账号lisi,密码 51 个1 2. 点击登录 |
提示"密码错误" |
| TC-LOGIN-09 | SQL 注入测试-用户名 | P2 | 1. 账号' OR '1'='1,密码任意 2. 点击登录 |
安全处理,提示"用户不存在",无异常跳转 |
| TC-LOGIN-10 | SQL 注入测试-密码 | P2 | 1. 账号lisi,密码' OR '1'='1 2. 点击登录 |
安全处理,提示"密码错误",无异常跳转 |
2.2 博客列表页功能测试用例(共 8 条)
验证博客列表页的博客标题、时间、用户信息、导航链接等元素是否存在。
| 用例编号 | 用例名称 | 优先级 | 操作步骤 | 预期结果 |
|---|---|---|---|---|
| TC-LIST-01 | 博客列表页标题验证 | P1 | 登录成功后进入列表页 | 页面标题显示"博客列表页" |
| TC-LIST-02 | 博客标题存在验证 | P1 | 进入列表页 | 博客标题元素存在 |
| TC-LIST-03 | 博客时间存在验证 | P1 | 进入列表页 | 博客发布时间存在且有内容 |
| TC-LIST-04 | 用户信息存在验证 | P1 | 进入列表页 | 左侧用户昵称存在且有内容 |
| TC-LIST-05 | 导航-首页链接存在 | P0 | 进入列表页 | "首页"链接存在 |
| TC-LIST-06 | 导航-写博客链接存在 | P0 | 进入列表页 | "写博客"链接存在 |
| TC-LIST-07 | 导航-注销功能 | P0 | 点击"注销" | 跳转到登录页,输入框清空 |
| TC-LIST-08 | 未登录访问列表页 | P1 | 未登录直接访问列表页 | 重定向到登录页 |
2.3 博客详情页功能测试用例(共 4 条)
验证博客详情页的标题、时间、内容等元素是否存在。
| 用例编号 | 用例名称 | 优先级 | 操作步骤 | 预期结果 |
|---|---|---|---|---|
| TC-DETAIL-01 | 详情页跳转验证 | P1 | 点击"查看全文" | 跳转到详情页,URL正确 |
| TC-DETAIL-02 | 详情页标题存在验证 | P1 | 进入详情页 | 博客标题存在且有内容 |
| TC-DETAIL-03 | 详情页时间存在验证 | P1 | 进入详情页 | 博客时间存在且有内容 |
| TC-DETAIL-04 | 详情页内容存在验证 | P1 | 进入详情页 | 博客内容区域存在 |
2.4 博客编辑页功能测试用例(共 6 条)
验证博客编辑页的元素存在性及发布功能。
| 用例编号 | 用例名称 | 优先级 | 操作步骤 | 预期结果 |
|---|---|---|---|---|
| TC-EDIT-01 | 编辑页跳转验证 | P1 | 点击"写博客" | 跳转到编辑页,URL正确 |
| TC-EDIT-02 | 标题输入框存在验证 | P1 | 进入编辑页 | 标题输入框存在 |
| TC-EDIT-03 | 内容编辑区存在验证 | P1 | 进入编辑页 | 内容编辑区存在 |
| TC-EDIT-04 | 发布按钮存在验证 | P1 | 进入编辑页 | 发布按钮存在 |
| TC-EDIT-05 | 发布博客功能验证 | P0 | 输入标题并点击发布 | 跳转回列表页,新博客可见 |
| TC-EDIT-06 | 未登录访问编辑页 | P1 | 未登录直接访问编辑页 | 可访问编辑页(系统小bug) |
2.5 页面标题验证测试用例(共 4 条)
验证各核心页面的浏览器标题栏文字是否正确。
| 用例编号 | 用例名称 | 优先级 | 操作步骤 | 预期结果 |
|---|---|---|---|---|
| TC-TITLE-01 | 登录页标题 | P1 | 打开/blog_login.html |
浏览器标题栏显示"博客登陆页" |
| TC-TITLE-02 | 博客列表页标题 | P1 | 登录成功后进入列表页 | 标题显示"博客列表页" |
| TC-TITLE-03 | 博客详情页标题 | P1 | 登录后点击任意博客"查看全文" | 标题显示"博客详情页" |
| TC-TITLE-04 | 博客编辑页标题 | P1 | 登录后点击"写博客" | 标题显示"博客编辑页" |
三、自动化测试设计
3.1 技术栈与版本
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.10+ | 编程语言 |
| Selenium | ≥4.15.0 | Web 自动化 |
| Pytest | ≥7.4.0 | 测试框架 |
| pytest-html | ≥4.1.0 | HTML 测试报告 |
| allure-pytest | ≥2.13.0 | Allure 报告适配 |
| PyYAML | ≥6.0.1 | YAML 解析(扩展备用) |
| webdriver-manager | ≥4.0.1 | WebDriver 自动管理 |
| pytest-order | ≥1.3.0 | 控制用例执行顺序 |
3.2 环境搭建与目录结构
3.2.1 安装依赖
项目根目录 requirements.txt 内容如下:
text
# 项目依赖
# Selenium - Web自动化测试框架
selenium>=4.15.0
# pytest - 测试框架
pytest>=7.4.0
# pytest-html - 生成HTML测试报告
pytest-html>=4.1.0
# allure-pytest - Allure测试报告
allure-pytest>=2.13.0
# PyYAML - YAML文件解析
PyYAML>=6.0.1
# WebDriver Manager - 自动管理WebDriver
webdriver-manager>=4.0.1
# pytest-order - 控制测试用例执行顺序
pytest-order>=1.3.0
执行:
bash
pip install -r requirements.txt
需本机已安装 Edge 浏览器。将与浏览器版本匹配的 msedgedriver.exe 放入 drivers/ 目录。
3.2.2 项目目录结构
WebUiTest/
│
├── requirements.txt # 项目依赖
├── drivers/ # 浏览器驱动(手动下载,与Edge版本匹配)
│ └── msedgedriver.exe
├── tests/ # 测试用例(按页面拆分)
│ ├── __init__.py
│ ├── test_login.py # 登录功能(10条:TC-LOGIN-01~10)
│ ├── test_blog_list.py # 列表页(8条:TC-LIST-01~08)
│ ├── test_blog_detail.py # 详情页(4条:TC-DETAIL-01~04)
│ └── test_blog_edit.py # 编辑页(6条:TC-EDIT-01~06)
├── logs/ # 日志文件(分级输出,按大小分割)
│ ├── all.log # 所有日志(DEBUG及以上)
│ ├── info.log # INFO级别日志
│ └── error.log # ERROR级别日志
├── reports/ # Allure 报告
│ ├── allure_results
│ └── allure_report
├── screenshots/ # 失败截图(仅测试失败时生成)
├── html/ # 本地HTML文件(参考用)
├── utils/ # 工具模块
│ ├── logger.py # 日志配置(分级、分文件、按大小分割)
│ └── helpers.py # 简单页面操作函数
├── conftest.py # 浏览器、登录等公共 fixture
└── pytest.ini # Pytest 配置
用例覆盖总览(总计 28 条):
| 脚本文件 | 覆盖用例数 | 对应TC编号 |
|---|---|---|
test_login.py |
10条 | TC-LOGIN-01 ~ 10 |
test_blog_list.py |
8条 | TC-LIST-01~08 |
test_blog_detail.py |
4条 | TC-DETAIL-01~04 |
test_blog_edit.py |
6条 | TC-EDIT-01~06 |
3.3 日志配置(utils/logger.py)
实现分级日志输出,三个日志文件,按大小自动分割:
python
import logging
import os
from logging.handlers import RotatingFileHandler
class LevelFilter(logging.Filter):
def __init__(self, level):
super().__init__()
self.level = level
def filter(self, record):
return record.levelno == self.level
def setup_logger(name="BlogAutoTest"):
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(logging.DEBUG)
log_dir = "logs"
os.makedirs(log_dir, exist_ok=True)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d:%(funcName)s] - %(message)s"
)
all_handler = RotatingFileHandler(
filename=os.path.join(log_dir, "all.log"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
all_handler.setLevel(logging.DEBUG)
all_handler.setFormatter(formatter)
logger.addHandler(all_handler)
info_handler = RotatingFileHandler(
filename=os.path.join(log_dir, "info.log"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
info_handler.setLevel(logging.INFO)
info_handler.addFilter(LevelFilter(logging.INFO))
info_handler.setFormatter(formatter)
logger.addHandler(info_handler)
error_handler = RotatingFileHandler(
filename=os.path.join(log_dir, "error.log"),
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
error_handler.setLevel(logging.ERROR)
error_handler.addFilter(LevelFilter(logging.ERROR))
error_handler.setFormatter(formatter)
logger.addHandler(error_handler)
return logger
日志输出说明:
| 文件 | 日志级别 | 说明 |
|---|---|---|
logs/all.log |
DEBUG、INFO、WARNING、ERROR | 记录所有日志 |
logs/info.log |
只有 INFO | 通过 LevelFilter 精确过滤 |
logs/error.log |
只有 ERROR | 通过 LevelFilter 精确过滤 |
日志特性:
- 每个文件最大 10MB,超过自动分割
- 最多保留 5 个备份文件
- 格式包含:时间、logger名称、等级、文件名、行号、函数名、消息
- 不输出到控制台
3.4 工具函数(utils/helpers.py)
封装页面操作函数,不引入页面对象层:
python
import os
import datetime
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException
BASE_URL = "http://49.235.61.184:19090"
LOC_USERNAME = (By.CSS_SELECTOR, "#username")
LOC_PASSWORD = (By.CSS_SELECTOR, "#password")
LOC_SUBMIT = (By.CSS_SELECTOR, "#submit")
LOC_NAV_HOME = (By.CSS_SELECTOR, "body > div.nav > a[href='blog_list.html']")
LOC_NAV_WRITE = (By.CSS_SELECTOR, "body > div.nav > a[href='blog_edit.html']")
LOC_NAV_LOGOUT = (By.CSS_SELECTOR, "body > div.nav > a[onclick='logout()']")
def open_url(driver, path):
driver.get(f"{BASE_URL}/{path.lstrip('/')}")
def wait_visible(driver, locator, timeout=10):
try:
return WebDriverWait(driver, timeout).until(
EC.visibility_of_element_located(locator)
)
except TimeoutException:
raise TimeoutException(f"等待元素可见超时: {locator}")
def click(driver, locator):
try:
wait_visible(driver, locator).click()
except Exception as e:
raise WebDriverException(f"点击元素失败: {locator}, 错误: {str(e)}")
def input_text(driver, locator, text):
try:
el = wait_visible(driver, locator)
el.clear()
el.send_keys(text)
except Exception as e:
raise WebDriverException(f"输入文本失败: {locator}, 错误: {str(e)}")
def do_login(driver, username, password):
open_url(driver, "blog_login.html")
input_text(driver, LOC_USERNAME, username)
input_text(driver, LOC_PASSWORD, password)
click(driver, LOC_SUBMIT)
try:
WebDriverWait(driver, 3).until(lambda d: "blog_list.html" in d.current_url)
except TimeoutException:
pass
def get_alert_text(driver):
try:
WebDriverWait(driver, 5).until(EC.alert_is_present())
alert = driver.switch_to.alert
text = alert.text
alert.accept()
return text
except TimeoutException:
return ""
except Exception as e:
raise WebDriverException(f"获取弹窗文本失败: {str(e)}")
def take_screenshot(driver, name):
try:
os.makedirs("screenshots", exist_ok=True)
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
path = f"screenshots/{name}_{ts}.png"
driver.save_screenshot(path)
return path
except Exception as e:
raise IOError(f"截图保存失败: {name}, 错误: {str(e)}")
截图功能说明:
- 截图文件命名格式:
{name}_{时间戳}.png - 文件名包含测试场景名称和时间,便于定位问题
- 仅在测试失败时调用,避免生成大量无用截图
3.5 公共 Fixture(conftest.py)
提供 session 级别浏览器驱动和已登录状态:
python
import os
import pytest
from selenium import webdriver
from selenium.webdriver.edge.service import Service
from selenium.webdriver.edge.options import Options
from utils.logger import setup_logger
from utils.helpers import open_url
DRIVER_PATH = os.path.join(os.getcwd(), "drivers", "msedgedriver.exe")
@pytest.fixture(scope="session")
def logger():
return setup_logger("BlogAutoTest")
@pytest.fixture(scope="session")
def driver(logger):
options = Options()
options.add_argument("--ignore-certificate-errors")
options.add_experimental_option("excludeSwitches", ["enable-logging"])
if not os.path.exists(DRIVER_PATH):
logger.error(f"驱动文件未找到: {DRIVER_PATH}")
raise FileNotFoundError(f"驱动未找到: {DRIVER_PATH}")
try:
drv = webdriver.Edge(service=Service(DRIVER_PATH), options=options)
drv.implicitly_wait(10)
drv.maximize_window()
logger.info("浏览器启动成功")
except Exception as e:
logger.error(f"浏览器启动失败: {str(e)}")
raise
yield drv
try:
drv.quit()
logger.info("浏览器关闭成功")
except Exception as e:
logger.error(f"浏览器关闭失败: {str(e)}")
@pytest.fixture(scope="session")
def logged_in_driver(driver, logger):
try:
open_url(driver, "blog_login.html")
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
driver.find_element(By.CSS_SELECTOR, "#username").clear()
driver.find_element(By.CSS_SELECTOR, "#username").send_keys("lisi")
driver.find_element(By.CSS_SELECTOR, "#password").clear()
driver.find_element(By.CSS_SELECTOR, "#password").send_keys("123456")
driver.find_element(By.CSS_SELECTOR, "#submit").click()
WebDriverWait(driver, 10).until(lambda d: "blog_list.html" in d.current_url)
logger.info("登录成功,已进入列表页")
return driver
except Exception as e:
logger.error(f"登录失败: {str(e)}")
raise
Fixture 说明:
| Fixture | Scope | 说明 |
|---|---|---|
logger |
session | 日志记录器,整个测试会话只创建一次 |
driver |
session | 浏览器驱动,整个测试会话只启动一次,所有测试共享 |
logged_in_driver |
session | 已登录状态,登录成功后在整个会话中复用 |
3.6 测试用例实现
登录测试(tests/test_login.py)
python
import allure
import pytest
from utils.helpers import open_url, do_login, get_alert_text, input_text, click, LOC_USERNAME, LOC_PASSWORD, LOC_SUBMIT, take_screenshot
LOGIN_FAIL_CASES = [
pytest.param("lisi", "123", "密码错误", id="TC-LOGIN-02"),
pytest.param("admin", "123456", "用户不存在", id="TC-LOGIN-03"),
pytest.param("", "123456", "账号或密码不能为空", id="TC-LOGIN-04"),
pytest.param("lisi", "", "账号或密码不能为空", id="TC-LOGIN-05"),
pytest.param("", "", "账号或密码不能为空", id="TC-LOGIN-06"),
pytest.param("a" * 51, "123456", "用户不存在", id="TC-LOGIN-07"),
pytest.param("lisi", "1" * 51, "密码错误", id="TC-LOGIN-08"),
pytest.param("' OR '1'='1", "x", "用户不存在", id="TC-LOGIN-09"),
pytest.param("lisi", "' OR '1'='1", "密码错误", id="TC-LOGIN-10"),
]
@allure.feature("登录模块")
@pytest.mark.order(1)
class TestLogin:
@allure.story("异常登录")
@pytest.mark.order(1)
@pytest.mark.parametrize("username,password,expected", LOGIN_FAIL_CASES)
def test_login_failure(self, driver, username, password, expected, logger):
try:
open_url(driver, "blog_login.html")
input_text(driver, LOC_USERNAME, username)
input_text(driver, LOC_PASSWORD, password)
click(driver, LOC_SUBMIT)
alert_text = get_alert_text(driver)
assert alert_text == expected
logger.info(f"[{username}] 登录失败测试通过,弹窗提示: {alert_text}")
except AssertionError as e:
logger.error(f"[{username}] 登录失败断言失败: 预期弹窗为'{expected}',实际弹窗为'{alert_text}'")
take_screenshot(driver, f"login_fail_{username[:5]}_error")
raise
except Exception as e:
logger.error(f"[{username}] 登录失败异常: {str(e)}")
take_screenshot(driver, f"login_fail_{username[:5]}_error")
raise
@allure.story("正常登录")
@pytest.mark.order(2)
def test_login_success(self, driver, logger):
try:
do_login(driver, "lisi", "123456")
assert "blog_list.html" in driver.current_url
logger.info("登录成功,已进入列表页")
except AssertionError as e:
logger.error(f"登录成功断言失败: 预期URL包含blog_list.html,实际为{driver.current_url}")
take_screenshot(driver, "login_success_error")
raise
except Exception as e:
logger.error(f"登录成功异常: {str(e)}")
take_screenshot(driver, "login_success_error")
raise
登录测试说明:
- 先执行9个失败用例(TC-LOGIN-02~10),最后执行成功登录(TC-LOGIN-01)
- 使用
@pytest.mark.order控制执行顺序
列表页测试(tests/test_blog_list.py)
python
import allure
import pytest
from selenium.webdriver.common.by import By
from utils.helpers import open_url, click, wait_visible, take_screenshot, LOC_NAV_LOGOUT
LOC_BLOG_TITLE = (By.CSS_SELECTOR, ".blog .title")
LOC_BLOG_DATE = (By.CSS_SELECTOR, ".blog .date")
LOC_USER_INFO = (By.CSS_SELECTOR, ".left .card h3")
@allure.feature("博客列表页")
@pytest.mark.order(2)
class TestBlogList:
@allure.story("页面标题")
def test_list_page_title(self, logged_in_driver, logger):
try:
assert logged_in_driver.title == "博客列表页"
logger.info("列表页标题验证通过")
except AssertionError as e:
logger.error(f"列表页标题断言失败: 预期'博客列表页',实际'{logged_in_driver.title}'")
take_screenshot(logged_in_driver, "list_page_title_error")
raise
except Exception as e:
logger.error(f"列表页标题异常: {str(e)}")
take_screenshot(logged_in_driver, "list_page_title_error")
raise
@allure.story("博客标题存在")
def test_blog_list_has_titles(self, logged_in_driver, logger):
try:
titles = logged_in_driver.find_elements(*LOC_BLOG_TITLE)
assert len(titles) > 0
logger.info(f"博客标题存在,共 {len(titles)} 篇")
except AssertionError as e:
logger.error("博客标题不存在")
take_screenshot(logged_in_driver, "list_has_titles_error")
raise
except Exception as e:
logger.error(f"博客标题异常: {str(e)}")
take_screenshot(logged_in_driver, "list_has_titles_error")
raise
@allure.story("博客时间存在")
def test_blog_list_has_dates(self, logged_in_driver, logger):
try:
dates = logged_in_driver.find_elements(*LOC_BLOG_DATE)
assert len(dates) > 0
assert all(d.text.strip() for d in dates)
logger.info(f"博客时间存在,共 {len(dates)} 篇")
except AssertionError as e:
logger.error("博客时间不存在或为空")
take_screenshot(logged_in_driver, "list_has_dates_error")
raise
except Exception as e:
logger.error(f"博客时间异常: {str(e)}")
take_screenshot(logged_in_driver, "list_has_dates_error")
raise
@allure.story("用户信息存在")
def test_blog_list_user_info(self, logged_in_driver, logger):
try:
user_info = wait_visible(logged_in_driver, LOC_USER_INFO)
assert user_info is not None
assert user_info.text.strip()
logger.info(f"用户信息存在: {user_info.text}")
except AssertionError as e:
logger.error("用户信息不存在或为空")
take_screenshot(logged_in_driver, "list_user_info_error")
raise
except Exception as e:
logger.error(f"用户信息异常: {str(e)}")
take_screenshot(logged_in_driver, "list_user_info_error")
raise
@allure.story("导航栏-首页链接存在")
def test_nav_home_exists(self, logged_in_driver, logger):
try:
home_link = logged_in_driver.find_element(By.CSS_SELECTOR, "a[href='blog_list.html']")
assert home_link is not None
logger.info("首页链接存在")
except AssertionError as e:
logger.error("首页链接不存在")
take_screenshot(logged_in_driver, "nav_home_exists_error")
raise
except Exception as e:
logger.error(f"首页链接异常: {str(e)}")
take_screenshot(logged_in_driver, "nav_home_exists_error")
raise
@allure.story("导航栏-写博客链接存在")
def test_nav_write_exists(self, logged_in_driver, logger):
try:
write_link = logged_in_driver.find_element(By.CSS_SELECTOR, "a[href='blog_edit.html']")
assert write_link is not None
logger.info("写博客链接存在")
except AssertionError as e:
logger.error("写博客链接不存在")
take_screenshot(logged_in_driver, "nav_write_exists_error")
raise
except Exception as e:
logger.error(f"写博客链接异常: {str(e)}")
take_screenshot(logged_in_driver, "nav_write_exists_error")
raise
@allure.story("导航栏-注销")
def test_nav_logout(self, logged_in_driver, logger):
try:
click(logged_in_driver, LOC_NAV_LOGOUT)
assert "blog_login.html" in logged_in_driver.current_url
logger.info("注销成功,跳转至登录页")
except AssertionError as e:
logger.error(f"注销断言失败: URL={logged_in_driver.current_url}")
take_screenshot(logged_in_driver, "nav_logout_error")
raise
except Exception as e:
logger.error(f"注销异常: {str(e)}")
take_screenshot(logged_in_driver, "nav_logout_error")
raise
@allure.story("未登录访问列表页")
def test_unlogged_list_redirect(self, driver, logger):
try:
open_url(driver, "blog_login.html")
assert "blog_login.html" in driver.current_url
logger.info("未登录访问列表页重定向验证通过")
except AssertionError as e:
logger.error(f"未登录访问列表页断言失败: URL={driver.current_url}")
take_screenshot(driver, "unlogged_list_redirect_error")
raise
except Exception as e:
logger.error(f"未登录访问列表页异常: {str(e)}")
take_screenshot(driver, "unlogged_list_redirect_error")
raise
详情页测试(tests/test_blog_detail.py)
python
import allure
from selenium.webdriver.common.by import By
from utils.helpers import wait_visible, take_screenshot
LOC_BLOG_DETAIL_TITLE = (By.CSS_SELECTOR, ".content .title")
LOC_BLOG_DETAIL_DATE = (By.CSS_SELECTOR, ".content .date")
LOC_BLOG_DETAIL_CONTENT = (By.CSS_SELECTOR, ".content .detail")
@allure.feature("博客详情页")
class TestBlogDetail:
@allure.story("页面标题")
def test_detail_page_title(self, logged_in_driver, logger):
try:
link = wait_visible(
logged_in_driver,
(By.CSS_SELECTOR, "a[href*='blog_detail.html']"),
)
link.click()
assert "blog_detail.html" in logged_in_driver.current_url
assert logged_in_driver.title == "博客详情页"
logger.info("博客详情页跳转成功")
except AssertionError as e:
logger.error(f"博客详情页跳转断言失败: URL={logged_in_driver.current_url}, title={logged_in_driver.title}")
take_screenshot(logged_in_driver, "detail_page_title_error")
raise
except Exception as e:
logger.error(f"博客详情页跳转异常: {str(e)}")
take_screenshot(logged_in_driver, "detail_page_title_error")
raise
@allure.story("检查博客标题存在")
def test_detail_has_title(self, logged_in_driver, logger):
try:
title = wait_visible(logged_in_driver, LOC_BLOG_DETAIL_TITLE).text
assert len(title) > 0
logger.info(f"博客详情页标题验证通过: {title}")
except AssertionError as e:
logger.error("博客详情页标题不存在或为空")
take_screenshot(logged_in_driver, "detail_has_title_error")
raise
except Exception as e:
logger.error(f"博客详情页标题异常: {str(e)}")
take_screenshot(logged_in_driver, "detail_has_title_error")
raise
@allure.story("检查博客时间存在")
def test_detail_has_date(self, logged_in_driver, logger):
try:
date = wait_visible(logged_in_driver, LOC_BLOG_DETAIL_DATE).text
assert len(date) > 0
logger.info(f"博客详情页时间验证通过: {date}")
except AssertionError as e:
logger.error("博客详情页时间不存在或为空")
take_screenshot(logged_in_driver, "detail_has_date_error")
raise
except Exception as e:
logger.error(f"博客详情页时间异常: {str(e)}")
take_screenshot(logged_in_driver, "detail_has_date_error")
raise
@allure.story("检查博客内容存在")
def test_detail_has_content(self, logged_in_driver, logger):
try:
content = wait_visible(logged_in_driver, LOC_BLOG_DETAIL_CONTENT)
assert content is not None
logger.info("博客详情页内容验证通过")
except AssertionError as e:
logger.error("博客详情页内容不存在")
take_screenshot(logged_in_driver, "detail_has_content_error")
raise
except Exception as e:
logger.error(f"博客详情页内容异常: {str(e)}")
take_screenshot(logged_in_driver, "detail_has_content_error")
raise
编辑页测试(tests/test_blog_edit.py)
python
import allure
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from utils.helpers import open_url, click, wait_visible, take_screenshot, LOC_NAV_WRITE
LOC_EDIT_TITLE = (By.CSS_SELECTOR, "#title")
LOC_EDIT_CONTENT = (By.CSS_SELECTOR, "#content")
LOC_EDIT_SUBMIT = (By.CSS_SELECTOR, "#submit")
LOC_EDIT_EDITOR = (By.CSS_SELECTOR, "#editor")
@allure.feature("博客编辑页")
class TestBlogEdit:
@allure.story("页面标题")
def test_edit_page_title(self, logged_in_driver, logger):
try:
click(logged_in_driver, LOC_NAV_WRITE)
assert "blog_edit.html" in logged_in_driver.current_url
assert logged_in_driver.title == "博客编辑页"
logger.info("博客编辑页跳转成功")
except AssertionError as e:
logger.error(f"博客编辑页跳转断言失败: URL={logged_in_driver.current_url}, title={logged_in_driver.title}")
take_screenshot(logged_in_driver, "edit_page_title_error")
raise
except Exception as e:
logger.error(f"博客编辑页跳转异常: {str(e)}")
take_screenshot(logged_in_driver, "edit_page_title_error")
raise
@allure.story("检查标题输入框存在")
def test_edit_has_title_input(self, logged_in_driver, logger):
try:
title_input = wait_visible(logged_in_driver, LOC_EDIT_TITLE)
assert title_input is not None
logger.info("编辑页标题输入框存在")
except AssertionError as e:
logger.error("编辑页标题输入框不存在")
take_screenshot(logged_in_driver, "edit_has_title_input_error")
raise
except Exception as e:
logger.error(f"编辑页标题输入框异常: {str(e)}")
take_screenshot(logged_in_driver, "edit_has_title_input_error")
raise
@allure.story("检查内容编辑区存在")
def test_edit_has_content_editor(self, logged_in_driver, logger):
try:
editor = wait_visible(logged_in_driver, LOC_EDIT_EDITOR)
assert editor is not None
logger.info("编辑页内容编辑区存在")
except AssertionError as e:
logger.error("编辑页内容编辑区不存在")
take_screenshot(logged_in_driver, "edit_has_content_editor_error")
raise
except Exception as e:
logger.error(f"编辑页内容编辑区异常: {str(e)}")
take_screenshot(logged_in_driver, "edit_has_content_editor_error")
raise
@allure.story("检查发布按钮存在")
def test_edit_has_submit_button(self, logged_in_driver, logger):
try:
submit_btn = wait_visible(logged_in_driver, LOC_EDIT_SUBMIT)
assert submit_btn is not None
logger.info("编辑页发布按钮存在")
except AssertionError as e:
logger.error("编辑页发布按钮不存在")
take_screenshot(logged_in_driver, "edit_has_submit_button_error")
raise
except Exception as e:
logger.error(f"编辑页发布按钮异常: {str(e)}")
take_screenshot(logged_in_driver, "edit_has_submit_button_error")
raise
@allure.story("发布博客功能")
def test_edit_publish_blog(self, logged_in_driver, logger):
try:
click(logged_in_driver, LOC_NAV_WRITE)
wait_visible(logged_in_driver, LOC_EDIT_TITLE)
title_input = logged_in_driver.find_element(*LOC_EDIT_TITLE)
title_input.clear()
title_input.send_keys("自动化测试标题")
logger.info("已输入博客标题")
click(logged_in_driver, LOC_EDIT_SUBMIT)
WebDriverWait(logged_in_driver, 10).until(lambda d: "blog_list.html" in d.current_url)
assert "blog_list.html" in logged_in_driver.current_url
logger.info("博客发布成功,已跳转至列表页")
except AssertionError as e:
logger.error(f"博客发布断言失败: URL={logged_in_driver.current_url}")
take_screenshot(logged_in_driver, "edit_publish_error")
raise
except Exception as e:
logger.error(f"博客发布异常: {str(e)}")
take_screenshot(logged_in_driver, "edit_publish_error")
raise
@allure.story("未登录访问编辑页-可访问")
def test_unlogged_edit_redirect(self, driver, logger):
try:
open_url(driver, "blog_edit.html")
assert "blog_edit.html" in driver.current_url
logger.info("未登录可访问编辑页")
except AssertionError as e:
logger.error(f"未登录访问编辑页断言失败: URL={driver.current_url}")
take_screenshot(driver, "unlogged_edit_error")
raise
except Exception as e:
logger.error(f"未登录访问编辑页异常: {str(e)}")
take_screenshot(driver, "unlogged_edit_error")
raise
3.7 pytest.ini 配置
ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -s -v
配置说明:
addopts = -s -v:自动带上-s(显示输出)和-v(详细模式)选项testpaths = tests:指定测试目录- 命名规则:文件
test_*.py、类Test*、方法test_*
3.8 测试执行命令
bash
# 运行全部用例并生成 Allure 原始数据
pytest --alluredir=reports/allure_results
# 生成并打开报告(需已安装 Allure 命令行)
allure generate reports/allure_results -o reports/allure_report --clean
# 运行指定测试类
pytest tests/test_login.py::TestLogin
# 运行指定测试方法
pytest tests/test_login.py::TestLogin::test_login_success
3.9 测试执行顺序
通过 pytest-order 插件控制测试执行顺序:
| 测试类 | 类级别顺序 | 类内部方法顺序 |
|---|---|---|
TestLogin |
@pytest.mark.order(1) |
先失败(@order(1)),后成功(@order(2)) |
TestBlogList |
@pytest.mark.order(2) |
内部无顺序要求 |
TestBlogDetail |
无(在列表页之后) | 内部无顺序要求 |
TestBlogEdit |
无(在详情页之后) | 内部无顺序要求 |
执行流程:
TestLogin::test_login_failure- 9个异常登录用例(TC-LOGIN-02~10)TestLogin::test_login_success- 正常登录(TC-LOGIN-01)TestBlogList- 8个列表页测试用例TestBlogDetail- 4个详情页测试用例TestBlogEdit- 6个编辑页测试用例
3.10 异常处理与失败截图
- 元素查找统一通过
wait_visible显式等待,避免time.sleep硬等待 - 登录失败场景依赖浏览器原生
alert弹窗,与手动用例一致 - 所有测试方法都包含 try-except 包装,断言失败时记录 ERROR 级别日志
- 快照功能 :仅在测试失败时(
AssertionError或Exception)调用take_screenshot保存截图
四、自动化测试优势
| 特性 | 说明 |
|---|---|
| 浏览器复用 | session 级别 fixture,整个测试会话只启动一次浏览器,提高效率 |
| 日志分级 | 三个日志文件(all.log/info.log/error.log),按大小自动分割 |
| 智能截图 | 仅在失败时生成截图,避免占用过多存储空间 |
| 执行顺序可控 | 通过 pytest-order 控制测试执行顺序 |
| 易扩展 | 按页面拆分,新增页面只需添加对应 test_*.py |
| 与手动用例对齐 | 每个测试方法对应一个手动用例编号 |
五、总结
本文介绍了基于 Pytest + Selenium + Allure + Logging 的博客系统自动化测试实践。从手动用例设计到脚本实现,遵循简单、按页拆分、无 YAML 的原则:
test_login.py覆盖登录正常与异常场景(10条,含SQL注入测试)test_blog_list.py覆盖列表页元素验证、导航(8条)test_blog_detail.py覆盖详情页标题、时间、内容验证(4条)test_blog_edit.py覆盖编辑页元素验证及发布功能(6条)- 公共浏览器与登录状态由
conftest.py统一管理(session 级别) - 日志分级输出,按大小自动分割
- 仅失败时自动截图,便于问题排查