基于 Pytest + Selenium + Allure 的博客系统自动化测试实践

一、项目测试背景

本次开发的个人博客系统基于 SSM(Spring + SpringMVC + MyBatis)框架搭建,包含五个核心页面:用户登录页面、博客列表页面、博客详情页面、博客编辑页面及博客发布页面。为确保系统功能稳定、交互流畅,对系统开展了手动与自动化双重测试。

在自动化测试中,我们选择 Python + Pytest + Selenium + Allure 报告 + Logging 日志 技术栈,要求:

  • 覆盖登录功能(正常/异常)导航栏跳转页面标题验证博客CRUD四大模块
  • 每个模块用例数控制在合理范围,采用等价类划分、边界值分析等方法设计
  • 日志分级输出(DEBUG/INFO/ERROR),三个日志文件,按大小自动分割(10MB/文件)
  • 结构简单:测试脚本按页面拆分,公共逻辑放在 conftest.pyutils,不使用 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 无(在详情页之后) 内部无顺序要求

执行流程:

  1. TestLogin::test_login_failure - 9个异常登录用例(TC-LOGIN-02~10)
  2. TestLogin::test_login_success - 正常登录(TC-LOGIN-01)
  3. TestBlogList - 8个列表页测试用例
  4. TestBlogDetail - 4个详情页测试用例
  5. TestBlogEdit - 6个编辑页测试用例

3.10 异常处理与失败截图

  • 元素查找统一通过 wait_visible 显式等待,避免 time.sleep 硬等待
  • 登录失败场景依赖浏览器原生 alert 弹窗,与手动用例一致
  • 所有测试方法都包含 try-except 包装,断言失败时记录 ERROR 级别日志
  • 快照功能 :仅在测试失败时(AssertionErrorException)调用 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 级别)
  • 日志分级输出,按大小自动分割
  • 仅失败时自动截图,便于问题排查
相关推荐
金玉满堂@bj19 小时前
Pytest 完整使用教程
运维·服务器·pytest
软件测试慧姐1 天前
软件测试面试题总结【含答案】
软件测试·测试工具·面试
菜_小_白1 天前
tcpdump
linux·网络·测试工具·http·tcpdump
littlebigbar1 天前
亲身体验AI智能体在实际项目中展现的核心能力
人工智能·selenium·测试工具
测试员周周1 天前
【Appium 系列】第09节-数据驱动测试 — YAML 数据 + parametrize
服务器·数据库·人工智能·python·测试工具·语言模型·appium
测试员周周1 天前
【Appium 系列】第10节-手势操作实战 — 滑动、拖拽、缩放与轻拂
linux·服务器·开发语言·人工智能·python·appium·pytest
金玉满堂@bj1 天前
pytest+uiautomation+allure 数据驱动桌面自动化项目搭建指南-yaml版本
运维·自动化·pytest
金玉满堂@bj1 天前
pytest+uiautomation+allure+Excel 数据驱动桌面自动化
自动化·excel·pytest
阿斯加德D2 天前
我的世界生活大冒险整合包下载高版本2026最新分享
测试工具·游戏·游戏程序·生活·材质