Python自动化测试框架封装实战:从零构建企业级测试架构

在实际工作中,一个良好封装的自动化测试框架可以大幅提高测试效率和代码可维护性。本文将带你从零开始,构建一个企业级的Python自动化测试框架,所有代码均包含详细中文注释,适合初学者学习。

框架设计思路

一个完整的自动化测试框架应该包含以下核心组件:

  1. 测试用例管理 – 组织和执行测试用例

  2. 页面对象模型 – 封装页面元素和操作

  3. 配置管理 – 管理测试环境和配置

  4. 日志和报告 – 记录测试过程和生成测试报告

  5. 工具函数 – 提供常用工具方法

  6. 异常处理 – 优雅地处理测试中的异常

框架目录结构

automation_framework/
│
├── config/                 # 配置文件目录
│   ├── __init__.py
│   ├── config.py          # 主配置文件
│   └── settings.yaml      # 配置文件
│
├── pages/                 # 页面对象目录
│   ├── __init__.py
│   ├── base_page.py       # 页面基类
│   └── login_page.py      # 登录页面
│
├── tests/                 # 测试用例目录
│   ├── __init__.py
│   ├── conftest.py        # pytest配置
│   └── test_login.py      # 登录测试用例
│
├── utils/                 # 工具类目录
│   ├── __init__.py
│   ├── logger.py          # 日志工具
│   └── helpers.py         # 辅助函数
│
├── reports/               # 测试报告目录
├── logs/                  # 日志目录
└── run_tests.py           # 测试运行入口

核心组件实现

1. 配置文件管理 (config/config.py)

# -*- coding: utf-8 -*-
"""
配置文件读取模块
负责从YAML文件中读取配置信息,并提供给框架其他部分使用
"""

import yaml
import os
from pathlib import Path

class Config:
    """配置管理类,用于读取和管理框架配置"""
    
    def __init__(self, config_file="settings.yaml"):
        """
        初始化配置管理器
        
        参数:
            config_file: 配置文件路径,默认为settings.yaml
        """
        # 获取项目根目录
        self.BASE_DIR = Path(__file__).resolve().parent.parent
        
        # 配置文件完整路径
        self.config_file = os.path.join(self.BASE_DIR, "config", config_file)
        
        # 加载配置
        self.settings = self._load_config()
    
    def _load_config(self):
        """
        从YAML文件加载配置
        
        返回:
            dict: 配置字典
        """
        try:
            with open(self.config_file, 'r', encoding='utf-8') as file:
                return yaml.safe_load(file)
        except FileNotFoundError:
            raise Exception(f"配置文件未找到: {self.config_file}")
        except yaml.YAMLError as e:
            raise Exception(f"配置文件格式错误: {e}")
    
    def get(self, key, default=None):
        """
        获取配置值
        
        参数:
            key: 配置键
            default: 默认值(当键不存在时返回)
            
        返回:
            配置值或默认值
        """
        # 支持点分隔的键路径,如"browser.name"
        keys = key.split('.')
        value = self.settings
        
        try:
            for k in keys:
                value = value[k]
            return value
        except (KeyError, TypeError):
            return default

# 创建全局配置实例
config = Config()

2. 日志模块 (utils/logger.py)

# -*- coding: utf-8 -*-
"""
日志记录模块
提供统一的日志记录功能,支持控制台和文件输出
"""

import logging
import os
from pathlib import Path
from datetime import datetime

# 导入配置
from config.config import config

class Logger:
    """日志记录类"""
    
    def __init__(self, name=__name__):
        """
        初始化日志记录器
        
        参数:
            name: 日志记录器名称,通常使用模块名
        """
        # 创建日志记录器
        self.logger = logging.getLogger(name)
        self.logger.setLevel(logging.DEBUG)
        
        # 防止日志重复输出
        if not self.logger.handlers:
            # 创建日志格式
            formatter = logging.Formatter(
                '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
            )
            
            # 创建控制台处理器
            console_handler = logging.StreamHandler()
            console_handler.setLevel(config.get('log.console_level', 'INFO'))
            console_handler.setFormatter(formatter)
            
            # 创建文件处理器
            log_dir = Path(config.get('log.dir', 'logs'))
            log_dir.mkdir(exist_ok=True)  # 确保日志目录存在
            
            # 生成带时间戳的日志文件名
            timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
            log_file = log_dir / f"test_{timestamp}.log"
            
            file_handler = logging.FileHandler(log_file, encoding='utf-8')
            file_handler.setLevel(config.get('log.file_level', 'DEBUG'))
            file_handler.setFormatter(formatter)
            
            # 添加处理器到日志记录器
            self.logger.addHandler(console_handler)
            self.logger.addHandler(file_handler)
    
    def get_logger(self):
        """
        获取配置好的日志记录器
        
        返回:
            logging.Logger: 配置好的日志记录器实例
        """
        return self.logger

# 创建全局日志实例
logger = Logger(__name__).get_logger()

3. 页面基类 (pages/base_page.py)

# -*- coding: utf-8 -*-
"""
页面对象基类
所有页面对象都应继承此类,提供常用的页面操作方法
"""

from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, NoSuchElementException
from utils.logger import logger

class BasePage:
    """页面对象基类"""
    
    def __init__(self, driver):
        """
        初始化基类
        
        参数:
            driver: WebDriver实例
        """
        self.driver = driver
        self.timeout = 10  # 默认等待超时时间(秒)
    
    def find_element(self, locator, timeout=None):
        """
        查找单个元素,支持显式等待
        
        参数:
            locator: 元素定位器,格式为(定位方式, 定位表达式)
            timeout: 超时时间,默认为类默认超时时间
            
        返回:
            WebElement: 找到的元素
            
        异常:
            TimeoutException: 超时未找到元素时抛出
        """
        if timeout is None:
            timeout = self.timeout
            
        try:
            logger.info(f"查找元素: {locator}")
            element = WebDriverWait(self.driver, timeout).until(
                EC.presence_of_element_located(locator)
            )
            return element
        except TimeoutException:
            logger.error(f"元素查找超时: {locator}")
            raise
    
    def find_elements(self, locator, timeout=None):
        """
        查找多个元素
        
        参数:
            locator: 元素定位器
            timeout: 超时时间
            
        返回:
            list: 找到的元素列表
        """
        if timeout is None:
            timeout = self.timeout
            
        try:
            logger.info(f"查找多个元素: {locator}")
            elements = WebDriverWait(self.driver, timeout).until(
                EC.presence_of_all_elements_located(locator)
            )
            return elements
        except TimeoutException:
            logger.error(f"元素查找超时: {locator}")
            return []
    
    def click(self, locator, timeout=None):
        """
        点击元素
        
        参数:
            locator: 元素定位器
            timeout: 超时时间
        """
        element = self.find_element(locator, timeout)
        logger.info(f"点击元素: {locator}")
        element.click()
    
    def input_text(self, locator, text, timeout=None):
        """
        在输入框中输入文本
        
        参数:
            locator: 元素定位器
            text: 要输入的文本
            timeout: 超时时间
        """
        element = self.find_element(locator, timeout)
        logger.info(f"在元素 {locator} 中输入文本: {text}")
        element.clear()
        element.send_keys(text)
    
    def get_text(self, locator, timeout=None):
        """
        获取元素文本
        
        参数:
            locator: 元素定位器
            timeout: 超时时间
            
        返回:
            str: 元素文本
        """
        element = self.find_element(locator, timeout)
        text = element.text
        logger.info(f"获取元素 {locator} 的文本: {text}")
        return text
    
    def is_element_visible(self, locator, timeout=None):
        """
        检查元素是否可见
        
        参数:
            locator: 元素定位器
            timeout: 超时时间
            
        返回:
            bool: 元素是否可见
        """
        try:
            element = self.find_element(locator, timeout)
            return element.is_displayed()
        except (TimeoutException, NoSuchElementException):
            return False
    
    def take_screenshot(self, name):
        """
        截取屏幕截图
        
        参数:
            name: 截图名称,用于生成文件名
        """
        import os
        from datetime import datetime
        
        # 创建截图目录
        screenshot_dir = "screenshots"
        if not os.path.exists(screenshot_dir):
            os.makedirs(screenshot_dir)
        
        # 生成带时间戳的文件名
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"{screenshot_dir}/{name}_{timestamp}.png"
        
        # 截取截图
        self.driver.save_screenshot(filename)
        logger.info(f"已截取屏幕截图: {filename}")

4. 登录页面对象 (pages/login_page.py)

# -*- coding: utf-8 -*-
"""
登录页面对象
封装登录页面的元素和操作
"""

from selenium.webdriver.common.by import By
from pages.base_page import BasePage
from utils.logger import logger

class LoginPage(BasePage):
    """登录页面对象类"""
    
    # 页面元素定位器
    USERNAME_INPUT = (By.ID, "username")
    PASSWORD_INPUT = (By.ID, "password")
    LOGIN_BUTTON = (By.ID, "loginBtn")
    ERROR_MESSAGE = (By.CLASS_NAME, "error-message")
    SUCCESS_MESSAGE = (By.CLASS_NAME, "welcome-message")
    
    def __init__(self, driver):
        """
        初始化登录页面
        
        参数:
            driver: WebDriver实例
        """
        super().__init__(driver)
        self.url = "https://example.com/login"  # 登录页面URL
    
    def open(self):
        """
        打开登录页面
        
        返回:
            LoginPage: 登录页面实例,支持链式调用
        """
        logger.info(f"打开登录页面: {self.url}")
        self.driver.get(self.url)
        return self
    
    def enter_username(self, username):
        """
        输入用户名
        
        参数:
            username: 用户名
            
        返回:
            LoginPage: 登录页面实例,支持链式调用
        """
        self.input_text(self.USERNAME_INPUT, username)
        return self
    
    def enter_password(self, password):
        """
        输入密码
        
        参数:
            password: 密码
            
        返回:
            LoginPage: 登录页面实例,支持链式调用
        """
        self.input_text(self.PASSWORD_INPUT, password)
        return self
    
    def click_login(self):
        """
        点击登录按钮
        
        返回:
            LoginPage: 登录页面实例,支持链式调用
        """
        self.click(self.LOGIN_BUTTON)
        return self
    
    def login(self, username, password):
        """
        执行完整登录操作
        
        参数:
            username: 用户名
            password: 密码
            
        返回:
            LoginPage: 登录页面实例,支持链式调用
        """
        logger.info(f"执行登录操作,用户名: {username}")
        return self.open().enter_username(username).enter_password(password).click_login()
    
    def get_error_message(self):
        """
        获取错误消息
        
        返回:
            str: 错误消息文本,如果没有错误消息则返回空字符串
        """
        if self.is_element_visible(self.ERROR_MESSAGE):
            return self.get_text(self.ERROR_MESSAGE)
        return ""
    
    def get_success_message(self):
        """
        获取成功消息
        
        返回:
            str: 成功消息文本,如果没有成功消息则返回空字符串
        """
        if self.is_element_visible(self.SUCCESS_MESSAGE):
            return self.get_text(self.SUCCESS_MESSAGE)
        return ""

5. 测试用例示例 (tests/test_login.py)

# -*- coding: utf-8 -*-
"""
登录功能测试用例
使用pytest框架编写登录功能测试
"""

import pytest
from pages.login_page import LoginPage
from utils.logger import logger

class TestLogin:
    """登录功能测试类"""
    
    @pytest.mark.parametrize("username,password,expected", [
        ("admin", "password123", True),  # 正确用户名和密码
        ("admin", "wrongpassword", False),  # 正确用户名,错误密码
        ("nonexistent", "password123", False),  # 不存在用户名
        ("", "password123", False),  # 空用户名
        ("admin", "", False),  # 空密码
    ])
    def test_login_with_different_credentials(self, browser, username, password, expected):
        """
        测试使用不同凭据登录
        
        参数:
            browser: 浏览器fixture,提供WebDriver实例
            username: 测试用户名
            password: 测试密码
            expected: 期望结果(True表示登录成功,False表示登录失败)
        """
        logger.info(f"测试登录: 用户名={username}, 密码={password}, 期望结果={expected}")
        
        # 创建登录页面对象
        login_page = LoginPage(browser)
        
        # 执行登录操作
        login_page.login(username, password)
        
        if expected:
            # 期望登录成功,验证成功消息
            assert login_page.get_success_message() != "", "登录成功,但未显示成功消息"
            logger.info("登录成功测试通过")
        else:
            # 期望登录失败,验证错误消息
            assert login_page.get_error_message() != "", "登录失败,但未显示错误消息"
            logger.info("登录失败测试通过")
    
    def test_login_success_redirect(self, browser, config):
        """
        测试登录成功后是否重定向到正确页面
        
        参数:
            browser: 浏览器fixture
            config: 配置fixture
        """
        logger.info("测试登录成功后的重定向")
        
        # 获取正确的用户名和密码
        username = config.get("test_account.valid_username")
        password = config.get("test_account.valid_password")
        expected_url = config.get("test_account.redirect_after_login")
        
        # 执行登录
        login_page = LoginPage(browser)
        login_page.login(username, password)
        
        # 验证是否重定向到预期页面
        assert expected_url in browser.current_url, f"登录后未重定向到预期页面。当前URL: {browser.current_url}"
        logger.info("登录重定向测试通过")

6. Pytest配置 (tests/conftest.py)

# -*- coding: utf-8 -*-
"""
Pytest配置文件
定义全局fixture和插件配置
"""

import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from config.config import config
from utils.logger import logger

@pytest.fixture(scope="session")
def config():
    """
    配置fixture,提供全局配置访问
    
    返回:
        Config: 配置实例
    """
    from config.config import config
    return config

@pytest.fixture(scope="function")
def browser(config):
    """
    浏览器fixture,为每个测试函数提供浏览器实例
    
    参数:
        config: 配置fixture
        
    返回:
        WebDriver: 浏览器驱动实例
    """
    # 根据配置选择浏览器
    browser_name = config.get("browser.name", "chrome")
    
    if browser_name.lower() == "chrome":
        # Chrome浏览器选项
        chrome_options = Options()
        if config.get("browser.headless", False):
            chrome_options.add_argument("--headless")  # 无头模式
        chrome_options.add_argument("--window-size=1920,1080")
        
        # 初始化Chrome浏览器
        driver = webdriver.Chrome(options=chrome_options)
    elif browser_name.lower() == "firefox":
        # 初始化Firefox浏览器
        driver = webdriver.Firefox()
    else:
        raise ValueError(f"不支持的浏览器: {browser_name}")
    
    logger.info(f"启动浏览器: {browser_name}")
    
    # 设置隐式等待时间
    driver.implicitly_wait(config.get("browser.implicit_wait", 10))
    
    # 返回浏览器实例给测试函数
    yield driver
    
    # 测试结束后关闭浏览器
    logger.info("关闭浏览器")
    driver.quit()

@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """
    pytest钩子函数,用于在测试失败时自动截图
    
    参数:
        item: 测试项目
        call: 测试调用信息
    """
    # 执行测试并获取结果
    outcome = yield
    report = outcome.get_result()
    
    # 检查测试是否失败
    if report.when == "call" and report.failed:
        # 获取浏览器实例
        browser = item.funcargs.get("browser")
        
        if browser is not None:
            # 获取页面对象(如果存在)
            for argname, argvalue in item.funcargs.items():
                if hasattr(argvalue, "take_screenshot"):
                    # 使用页面对象的截图方法
                    argvalue.take_screenshot(f"test_failure_{item.name}")
                    break
            else:
                # 没有页面对象,直接使用浏览器截图
                from pages.base_page import BasePage
                base_page = BasePage(browser)
                base_page.take_screenshot(f"test_failure_{item.name}")

7. 测试运行入口 (run_tests.py)

# -*- coding: utf-8 -*-
"""
测试运行入口脚本
提供命令行界面来运行测试并生成报告
"""

import argparse
import subprocess
import sys
from pathlib import Path
from datetime import datetime

# 导入配置和日志
from config.config import config
from utils.logger import logger

def run_tests(test_path=None, browser=None, headless=None, report=None):
    """
    运行测试并生成报告
    
    参数:
        test_path: 测试路径,可以是文件或目录
        browser: 指定浏览器(chrome/firefox)
        headless: 是否使用无头模式
        report: 报告格式(html/allure)
    """
    # 构建pytest命令
    cmd = [sys.executable, "-m", "pytest"]
    
    # 添加测试路径
    if test_path:
        cmd.append(test_path)
    else:
        cmd.append("tests/")
    
    # 添加浏览器选项
    if browser:
        cmd.extend(["--browser", browser])
    
    # 添加无头模式选项
    if headless is not None:
        cmd.extend(["--headless", str(headless)])
    
    # 添加HTML报告选项
    if report == "html":
        # 创建报告目录
        report_dir = Path("reports") / datetime.now().strftime("%Y%m%d_%H%M%S")
        report_dir.mkdir(parents=True, exist_ok=True)
        
        # 添加HTML报告参数
        html_report = report_dir / "report.html"
        cmd.extend(["--html", str(html_report), "--self-contained-html"])
    
    # 添加Allure报告选项
    elif report == "allure":
        # 创建Allure结果目录
        allure_results = Path("allure-results")
        allure_results.mkdir(exist_ok=True)
        
        # 添加Allure参数
        cmd.extend(["--alluredir", str(allure_results)])
    
    logger.info(f"执行命令: {' '.join(cmd)}")
    
    # 运行测试
    result = subprocess.run(cmd)
    
    # 返回退出码
    return result.returncode

def main():
    """主函数,解析命令行参数并运行测试"""
    parser = argparse.ArgumentParser(description="自动化测试运行器")
    parser.add_argument("test_path", nargs="?", help="测试文件或目录路径")
    parser.add_argument("--browser", choices=["chrome", "firefox"], help="指定浏览器")
    parser.add_argument("--headless", action="store_true", help="使用无头模式")
    parser.add_argument("--report", choices=["html", "allure"], help="生成测试报告")
    
    args = parser.parse_args()
    
    # 运行测试
    exit_code = run_tests(
        test_path=args.test_path,
        browser=args.browser,
        headless=args.headless,
        report=args.report
    )
    
    # 退出程序,返回测试结果
    sys.exit(exit_code)

if __name__ == "__main__":
    main()

8. 配置文件示例 (config/settings.yaml)

# 浏览器配置
browser:
  name: "chrome"           # 浏览器类型:chrome/firefox
  headless: false          # 是否使用无头模式
  implicit_wait: 10        # 隐式等待时间(秒)

# 日志配置
log:
  dir: "logs"              # 日志目录
  console_level: "INFO"    # 控制台日志级别
  file_level: "DEBUG"      # 文件日志级别

# 测试账户配置
test_account:
  valid_username: "admin"              # 有效用户名
  valid_password: "password123"        # 有效密码
  redirect_after_login: "/dashboard"   # 登录后重定向的URL

# 应用配置
app:
  base_url: "https://example.com"      # 应用基础URL
  timeout: 30                          # 全局超时时间(秒)

# 数据库配置(可选)
database:
  host: "localhost"
  port: 3306
  name: "test_db"
  user: "test_user"
  password: "test_password"

框架使用指南

1. 安装依赖

# 创建虚拟环境
python -m venv venv

# 激活虚拟环境(Windows)
venv\Scripts\activate

# 激活虚拟环境(Linux/Mac)
source venv/bin/activate

# 安装依赖
pip install -r requirements.txt

2. 运行测试

# 运行所有测试
python run_tests.py

# 运行特定测试文件
python run_tests.py tests/test_login.py

# 使用无头模式运行测试并生成HTML报告
python run_tests.py --browser chrome --headless --report html

# 使用Allure报告
python run_tests.py --report allure

3. 编写新测试

  1. pages/目录中创建页面对象

  2. tests/目录中编写测试用例

  3. 使用@pytest.mark.parametrize进行参数化测试

  4. 使用logger记录测试过程

框架扩展建议

  1. API测试集成:添加Requests库支持API测试

  2. 数据库操作:添加数据库连接和操作功能

  3. 移动端测试:集成Appium进行移动端测试

  4. 性能测试:集成Locust进行性能测试

  5. CI/CD集成:添加Jenkins/GitHub Actions集成支持

THE END
喜欢就支持一下吧
赞赏 分享