1. 从零开始:为什么黑马头条需要一个自动化测试框架?

大家好,我是老张,在测试这行摸爬滚打十多年了,带过不少团队,也亲手搭建过好几个自动化测试框架。今天想和大家聊聊一个非常接地气的实战项目——黑马头条的自动化测试框架设计与实现。你可能要问,市面上现成的框架那么多,为啥还要自己折腾?这事儿我深有体会。

黑马头条这个项目,它不是一个简单的单体应用,而是一个包含了用户端APP、自媒体运营平台(PC网站)、后台管理系统(PC网站) 的综合性产品。想象一下,每次版本迭代,测试同学要在这三个端之间反复横跳,验证核心功能,比如用户能不能看文章、自媒体作者能不能发文章、后台管理员能不能审核文章。如果全靠手工点,一次回归测试就得花上好几天,不仅人累得够呛,还容易漏测,上线后半夜被报警电话叫醒的滋味可不好受。

所以,我们的目标很明确:把那些重复、稳定、核心的业务流程交给机器去执行,把人解放出来去做更有价值的探索性测试和复杂场景设计。这不仅仅是“偷懒”,更是提升测试效率、保证产品质量稳定性的关键一步。我见过太多团队,一开始兴致勃勃地搞自动化,结果代码写得乱七八糟,维护成本比手工测试还高,最后不了了之。因此,一个设计良好、易于维护的框架是成功的前提。

接下来,我就带你一步步拆解,我们是如何为黑马头条这个多端产品,量身打造一个既稳健又灵活的自动化测试框架的。我会分享我们当时的设计思路、踩过的坑,以及最终沉淀下来的可复用的代码结构。无论你是刚入门自动化测试的新手,还是想优化现有框架的老兵,相信都能从中获得一些启发。

2. 谋定而后动:自动化测试框架的顶层设计与核心思想

在动手写第一行代码之前,我们花了相当长的时间来讨论和设计框架的整体架构。这一步至关重要,它决定了后续开发是顺风顺水还是举步维艰。我们的核心设计思想可以概括为:“多端隔离、页面对象模式、分层封装、配置驱动”。

首先,多端隔离是基础。 黑马头条的三个前端产品(APP、自媒体平台、后台管理)技术栈和交互方式不同。自媒体和后台是Web,用Selenium;APP是移动端,用Appium。如果混在一起管理驱动和页面,代码会很快变得混乱不堪。我们的做法是为每个端建立独立的“驱动管理中心”和“基础操作层”,从根源上隔离。这样,为自媒体平台写的测试脚本,完全不会影响到APP端的执行环境。

其次,我们坚定地采用了经典的“页面对象模式(Page Object Model, POM)”。 这是减少代码冗余、提升可维护性的不二法门。简单说,就是把每个页面(如登录页、首页、发布文章页)当成一个对象,页面上的元素定位就是它的属性,页面上的操作(输入、点击)就是它的方法。当页面UI发生变化时,比如一个按钮的ID改了,我们只需要去修改对应的页面对象类,而所有用到这个按钮的测试脚本完全不用动。这在实际项目中救了我们无数次。

再者,分层封装让结构更清晰。 我们把代码分为几个层次:

  1. 基础工具层(Utils):管理浏览器/APP驱动,封装一些通用方法(如等待元素、选择下拉框)。
  2. 页面对象层(Page):对应POM模式,这里又细分为“对象库”(只负责定位元素)和“操作层”(对元素进行点击、输入等操作)。
  3. 业务层(Proxy):这是我们的一个特色封装。它把操作层的多个步骤组合成一个完整的业务操作。比如“登录”这个业务,就包含了“输入用户名”、“输入密码”、“点击登录按钮”三个操作。测试脚本直接调用业务层的方法,读起来就像在看自然语言,非常直观。
  4. 测试脚本层(Scripts):这里就是真正的测试用例,使用pytest等测试框架来组织,只关心测试数据、业务调用和断言。

最后,配置驱动提升灵活性。 我们把浏览器类型、APP的Desired Capabilities、测试环境的URL地址、等待超时时间等都放到配置文件里。这样,同一套脚本,通过修改配置就能轻松地在测试环境、预发布环境甚至不同的浏览器上运行,避免了在代码里写死这些参数。

有了这些顶层设计,我们就好比有了施工图纸,接下来就是准备“建筑材料”和“施工环境”了。

3. 工欲善其事:一站式环境搭建与项目初始化

环境搭建是新手最容易卡住的地方,我们力求把这个过程做到最简。我们的框架主要支持Web和APP自动化,所以环境也围绕这两块来准备。

对于Web自动化(自媒体平台和后台管理):

  1. Python环境:这是我们的脚本语言,选择3.7以上版本。我推荐直接用PyCharm作为IDE,它对项目管理、虚拟环境支持都很好。
  2. 浏览器与驱动:我们主要用Chrome。关键一步是下载和你Chrome浏览器版本匹配的chromedriver,并把它放在系统PATH路径下,或者直接在代码里指定路径。这一步不匹配,后面会报各种找不到驱动的错误,我踩过这个坑。
  3. 核心Python包:通过pip安装几个必备的库。
    pip install selenium  # Web自动化核心
    pip install pytest    # 测试框架,用来组织和运行用例
    pip install pytest-html  # 生成HTML测试报告
    pip install pytest-rerunfailures  # 失败重试,应对网络不稳定等情况
    

对于APP自动化(用户端APP):

  1. 基础环境:需要安装Java JDK和Android SDK,用于提供Appium所需的运行环境。
  2. Appium Server:可以桌面版,也可以用命令行安装。它是一个Node.js应用,负责接收我们的测试脚本指令,转发给手机或模拟器。
    npm install -g appium
    
  3. 模拟器或真机:开发测试阶段,用Android模拟器(如Android Studio自带的)非常方便。记得开启开发者选项和USB调试模式。
  4. Python客户端库:
    pip install Appium-Python-Client
    

环境准备好后,我们开始初始化项目。我强烈建议从一开始就规划好清晰的目录结构,这对团队协作和后期维护有巨大好处。这是我们当时创建的项目骨架:

uiAutoTestHmtt/               # 项目根目录
├── base/                     # 基础封装层,存放各端的基类
│   ├── mp/                   # 自媒体平台基类
│   ├── mis/                  # 后台管理系统基类
│   └── app/                  # APP基类
├── page/                     # 页面对象层,按端再分子目录
│   ├── mp/                   # 自媒体平台页面对象
│   ├── mis/                  # 后台管理系统页面对象
│   └── app/                  # APP页面对象
├── scripts/                  # 测试脚本层
│   ├── mp/                   # 自媒体平台测试用例
│   ├── mis/                  # 后台管理系统测试用例
│   └── app/                  # APP测试用例
├── data/                     # 存放测试数据文件,如JSON、Excel
├── report/                   # 存放自动生成的测试报告
├── log/                      # 存放运行日志
├── screenshot/               # 存放失败用例的截图
├── utils.py                  # 工具函数,如驱动管理、通用方法
├── config.py                 # 配置文件,放URL、账号等
├── pytest.ini                # pytest配置文件
└── conftest.py               # pytest的共享fixture配置

这个结构一目了然,新人加入项目,很快就能知道该去哪里找对应的代码。接下来,我们就要往这个骨架里填充最有灵魂的部分——驱动管理和基础封装。

4. 框架基石:驱动管理与基础类的精妙封装

这是整个框架最核心、最体现设计功力的部分。我们封装的目标是:让测试脚本编写者完全不用操心驱动从哪里来、怎么关、元素怎么找、操作怎么做这些底层细节。

首先,我们实现了一个全局的驱动管理工具类(UtilsDriver)。 它的核心思想是单例模式,确保在整个测试运行过程中,同一个端的驱动只有一个实例,避免重复创建和端口冲突。同时,它提供了优雅的退出机制。

# utils.py 中的关键部分
from selenium import webdriver
from appium import webdriver as appdriver

class UtilsDriver:
    # 使用类变量来保存驱动实例,实现单例
    _mp_driver = None  # 自媒体平台驱动
    _mis_driver = None # 后台管理系统驱动
    _app_driver = None # APP驱动

    @classmethod
    def get_mp_driver(cls):
        """获取自媒体平台Web驱动"""
        if cls._mp_driver is None:  # 如果驱动不存在,才创建
            cls._mp_driver = webdriver.Chrome()
            cls._mp_driver.maximize_window()
            cls._mp_driver.get("http://ttmp.research.itcast.cn/") # 配置化URL更好
        return cls._mp_driver

    @classmethod
    def quit_mp_driver(cls):
        """退出自媒体平台驱动"""
        if cls._mp_driver is not None:
            cls._mp_driver.quit()
            cls._mp_driver = None  # 置空,防止下次使用旧引用
    # ... 类似的 get_mis_driver, get_app_driver 方法

对于APP驱动,配置稍微复杂点,但原理一样:

    @classmethod
    def get_app_driver(cls):
        if cls._app_driver is None:
            des_cap = {
                "platformName": "Android",
                "platformVersion": "10", # 根据你的模拟器调整
                "deviceName": "emulator-5554",
                "appPackage": "com.itcast.toutiaoApp",
                "appActivity": ".MainActivity",
                "noReset": True, # 非常重要!避免每次重启APP清空数据
                "unicodeKeyboard": True # 支持中文输入
            }
            cls._app_driver = appdriver.Remote("http://127.0.0.1:4723/wd/hub", des_cap)
        return cls._app_driver

然后,我们为每个端封装了它们的基础类(BasePage和BaseHandle)。 这是页面对象模式的起点。BasePage负责元素的查找,并内置了显式等待,让元素定位更稳定。BaseHandle提供最基础的元素操作,如输入文本。

# base/mp/base.py 示例
from selenium.webdriver.support.wait import WebDriverWait
from utils import UtilsDriver

class BasePage:
    """自媒体平台-页面对象层基类"""
    def __init__(self):
        # 直接获取全局唯一的驱动实例
        self.driver = UtilsDriver.get_mp_driver()

    def get_element(self, location):
        """封装查找元素,加入显式等待"""
        wait = WebDriverWait(self.driver, timeout=10, poll_frequency=0.5)
        element = wait.until(lambda x: x.find_element(*location))
        return element

class BaseHandle:
    """自媒体平台-操作层基类"""
    @staticmethod
    def input_text(element, text):
        """封装输入操作,先清空再输入"""
        element.clear()
        element.send_keys(text)
    # 还可以封装 click, get_text 等方法

为mis(后台管理)和app端封装类似的基类,只是__init__中获取驱动的方法分别改为UtilsDriver.get_mis_driver()和UtilsDriver.get_app_driver()。这样,各端的页面对象类继承自各自的基类,天然就获得了正确的驱动和基础方法。基础打牢了,后面建造“页面”这座大楼就快多了。

5. 实战演练:以自媒体平台为例拆解页面对象封装

理论说再多,不如看一个完整的例子。我们就拿自媒体平台最核心的“登录->进入首页->发布文章”这个流程来拆解,看看如何将POM模式和我们的分层思想落地。

第一步:封装登录页面(login_page.py) 我们把这个页面放到page/mp/目录下。严格按照对象库层、操作层、业务层来写。

# page/mp/login_page.py
from base.mp.base import BasePage, BaseHandle
from selenium.webdriver.common.by import By

# 1. 对象库层:只关心这个页面上有哪些元素,以及如何找到它们
class LoginPage(BasePage):
    def __init__(self):
        super().__init__() # 初始化父类,获得self.driver
        # 使用By类来定义元素定位器,这是最佳实践
        self.mobile_loc = (By.XPATH, "//input[@placeholder='请输入手机号']")
        self.code_loc = (By.XPATH, "//input[@placeholder='验证码']")
        self.login_btn_loc = (By.CSS_SELECTOR, ".el-button--primary")

    def find_mobile(self):
        """定位手机号输入框"""
        return self.get_element(self.mobile_loc)

    def find_code(self):
        """定位验证码输入框"""
        return self.get_element(self.code_loc)

    def find_login_btn(self):
        """定位登录按钮"""
        return self.get_element(self.login_btn_loc)

# 2. 操作层:对找到的元素执行具体操作
class LoginHandle(BaseHandle):
    def __init__(self):
        self.login_page = LoginPage() # 组合一个页面对象

    def input_mobile(self, mobile):
        self.input_text(self.login_page.find_mobile(), mobile)

    def input_code(self, code):
        self.input_text(self.login_page.find_code(), code)

    def click_login_btn(self):
        self.login_page.find_login_btn().click()

# 3. 业务层:将操作组合成有业务意义的流程
class LoginProxy:
    def __init__(self):
        self.login_handle = LoginHandle()

    def login(self, mobile, code):
        """登录业务:输入手机号、验证码,点击登录"""
        self.login_handle.input_mobile(mobile)
        self.login_handle.input_code(code)
        self.login_handle.click_login_btn()
        # 这里可以加入等待登录成功的逻辑

第二步:封装首页和发布文章页 模式是一样的。首页(home_page.py)需要定位用户信息(用于断言登录成功)、内容管理菜单和发布文章按钮。发布文章页(publish_page.py)则复杂一些,因为它涉及iframe切换(富文本编辑器)、下拉框选择等。

在封装发布文章页的下拉框选择时,我们把这个通用操作抽到了utils.py里,因为三个端都可能用到。

# utils.py 补充
import time
from selenium.webdriver.common.by import By

def select_by_text(driver, trigger_element, text):
    """
    通用下拉框选择方法
    :param driver: 浏览器驱动
    :param trigger_element: 触发下拉框显示的元素(比如那个输入框)
    :param text: 要选择的选项文本
    """
    trigger_element.click()
    time.sleep(0.5)  # 等待下拉动画,可以用显式等待优化
    # 构造XPath定位到下拉列表中文本匹配的选项
    option_xpath = f"//div[contains(@class, 'el-select-dropdown')]//span[text()='{text}']"
    driver.find_element(By.XPATH, option_xpath).click()

第三步:编写测试用例脚本 页面对象都封装好后,写测试用例就变成了一件非常愉快的事情,读起来就像写自然语言一样。

# scripts/mp/test_publish_article.py
import pytest
from page.mp.login_page import LoginProxy
from page.mp.home_page import HomeProxy
from page.mp.publish_page import PublishProxy
from utils import UtilsDriver

class TestPublishArticle:

    def setup_class(self):
        """测试类开始前执行:初始化所有业务对象"""
        self.login_proxy = LoginProxy()
        self.home_proxy = HomeProxy()
        self.publish_proxy = PublishProxy()

    def teardown_class(self):
        """测试类结束后执行:关闭驱动"""
        UtilsDriver.quit_mp_driver()

    def test_login(self):
        """测试登录功能"""
        self.login_proxy.login("13800138000", "123456")
        # 断言:首页是否显示了正确的用户名
        username = self.home_proxy.get_username_msg()
        assert username == "测试账号"

    def test_publish_article(self):
        """测试发布文章功能"""
        # 1. 从首页跳转到发布页
        self.home_proxy.go_to_publish_page()
        # 2. 执行发布文章业务
        self.publish_proxy.publish_article(
            title="自动化测试发布的文章",
            content="这里是文章内容,由自动化测试脚本自动填充。",
            channel="前端开发" # 选择频道
        )
        # 3. 断言:页面是否出现“发布成功”的提示
        # 这里可以调用一个通用的文本判断工具
        assert self.utils.is_text_present("发布成功")

你看,在测试用例里,我们完全看不到任何find_element、send_keys、click这样的底层API,也看不到复杂的元素定位表达式。我们只关心业务:登录、跳转、发布、断言。这样的代码,可读性极高,维护成本极低。当页面UI改动时,我们只需要去修改对应的page下的页面对象类,而这些测试脚本几乎不用动。这就是分层封装和POM模式带来的最大好处。

6. 高效执行与报告:用pytest组织测试并生成漂亮报告

框架和用例写好了,怎么方便地运行它们并直观地看到结果呢?我们选择了pytest作为测试执行框架,它比Python自带的unittest更强大、更灵活。

首先,配置pytest(pytest.ini)。 这个配置文件可以让我们自定义pytest的默认行为。

# pytest.ini
[pytest]
# 默认命令行参数
addopts = -v -s --html=report/report.html --reruns 2 --reruns-delay 1
# -v: 详细输出
# -s: 打印print信息
# --html: 生成HTML报告到指定路径
# --reruns: 失败用例重试2次,这对付前端偶尔的网络抖动或加载慢特别有用
# --reruns-delay: 重试间隔1秒

# 指定测试用例的搜索路径
testpaths = ./scripts
# 指定测试文件名的模式
python_files = test_*.py
# 指定测试类的模式
python_classes = Test*
# 指定测试函数/方法的模式
python_functions = test_*

然后,使用conftest.py来管理共享的fixture。 Fixture是pytest的精华,用于测试前置和后置工作。比如,我们可以把每个用例都需要“登录”这个步骤写成一个fixture。

# conftest.py
import pytest
from page.mp.login_page import LoginProxy
from page.mp.home_page import HomeProxy

@pytest.fixture(scope='class')
def login_and_to_home():
    """一个类级别的fixture:完成登录并返回首页业务对象"""
    login_proxy = LoginProxy()
    home_proxy = HomeProxy()
    login_proxy.login("13800138000", "123456")
    yield home_proxy  # yield之前是前置,之后是后置
    # 这里可以做一些清理工作,比如退出登录(如果提供的话)

这样,在测试类中就可以直接使用这个fixture了:

class TestArticleManagement:
    def test_something(self, login_and_to_home):
        home_proxy = login_and_to_home
        # 现在home_proxy已经是登录后的状态了,可以直接使用

最后,运行测试并查看报告。 在项目根目录下,只需要执行一条命令:

pytest

pytest会根据pytest.ini的配置,自动发现scripts目录下所有以test_开头的文件,并运行其中以test_开头的函数或方法。运行结束后,会在report目录下生成一个report.html文件。用浏览器打开这个文件,你能看到一个非常清晰的测试结果总览,包括通过数、失败数、跳过数,以及每个失败用例的详细错误堆栈。这对于团队分享测试结果、排查问题非常有帮助。

我们还可以集成更强大的报告工具,比如Allure,它能生成更加美观、交互性更强的报告,并且支持展示截图、附件等。不过对于项目初期,pytest-html生成的报告已经足够清晰实用了。关键在于,整个执行和报告生成的过程是完全自动化的,这才是我们追求的效率。

7. 避坑指南与最佳实践:来自实战的经验之谈

框架搭起来了,用例也能跑了,但要想让它长期稳定地为项目服务,还需要注意很多细节。这里分享几个我们踩过坑后总结出来的最佳实践。

1. 元素定位策略要稳健且可维护。

  • 优先级:ID > Name > CSS Selector > XPath。ID和Name是首选,但现代前端框架生成的ID可能很长且动态变化,这时CSS Selector通常比复杂的XPath更高效、更易读。
  • 慎用绝对XPath:绝对路径(以/开头)极度脆弱,页面结构稍有变动就会失效。尽量使用相对XPath(如//button[contains(@class, 'submit')])或结合其他属性。
  • 处理动态元素:对于加载慢或动态渲染的元素,一定要用WebDriverWait进行显式等待,而不是用time.sleep硬等待。显式等待更智能,元素一出现就继续,不会浪费多余时间。

2. 测试数据要与代码分离。 不要把测试账号、密码、文章标题等数据硬编码在测试脚本里。应该放在data目录下的JSON、YAML或Excel文件中,或者通过config.py配置文件管理。这样,切换测试环境时,改一下配置文件即可。

3. 失败截图和日志是救命稻草。 测试在CI/CD流水线中运行时,失败了你怎么知道当时页面是什么样子?我们要求在conftest.py中写一个自动捕获失败截图的fixture,并保存到screenshot目录,以用例名和时间戳命名。同时,使用Python的logging模块记录关键操作步骤到log文件。这样,排查问题就有据可依了。

4. 保持用例的独立性。 每个测试用例都应该可以独立运行,不依赖于其他用例的执行状态。这意味着你需要在setup/teardown或者fixture中处理好测试环境,比如每个用例都使用独立的测试账号,或者用例执行后清理掉自己产生的数据(如删除测试文章)。依赖性强是自动化测试套件后期难以维护的主要原因之一。

5. 平衡自动化的范围。 不是所有功能都适合自动化。遵循“二八原则”:用20%的精力覆盖80%最核心、最稳定的业务流程。像发布文章、登录、审核这类高频核心功能,是自动化的首选。而一些UI频繁变动、逻辑极其复杂、或者一次性测试的功能,则更适合手工测试。盲目追求覆盖率只会让维护成本爆炸。

6. 代码也需要Review和重构。 测试代码也是代码,它应该具备生产代码一样的质量要求。定期进行代码审查,看看是否有定位器可以优化、是否有重复代码可以抽取、是否有更清晰的写法。随着项目迭代,页面对象和业务流也会变化,测试框架也需要适时重构,保持其活力。

搭建自动化测试框架就像养一个孩子,不是一蹴而就的,需要持续的投入和精心的维护。但当你看到每次代码提交后,自动化测试套件在后台静静运行,快速反馈核心功能是否正常,那种安心感和解放出来的生产力,会让你觉得所有的付出都是值得的。黑马头条的这个框架,至今还在稳定运行并不断演进,支撑着产品的快速迭代。希望我的这些实战解析,能帮你少走一些弯路。

更多推荐