自动化测试实战项目开发:从零搭建一套可落地的测试框架

1. 引言

很多同学学完 Selenium、Pytest、Requests 之后,依然不知道如何把它们串成一个真正能用的项目。知识点是散的,面试时能说,工作中却落不了地。

本文以一个「电商后台管理系统」为被测对象,从零搭建一套完整的自动化测试项目,覆盖:

  • 项目目录结构设计
  • 接口自动化(Requests + Pytest)
  • UI 自动化(Selenium + Page Object)
  • 数据驱动与配置管理
  • 测试报告与持续集成
  • 常见坑与优化建议

读完之后,你应该能独立完成一个可以写进简历、也能直接搬到工作中的自动化测试项目。

2. 项目整体设计

2.1 被测对象与技术选型

层次 技术选型 说明
接口层 Requests + Pytest 覆盖登录、商品、订单等核心接口
UI 层 Selenium + Pytest 覆盖登录、下单等关键业务流程
数据管理 YAML + Faker 配置与测试数据分离
报告 Allure 生成可视化测试报告
持续集成 Jenkins / GitHub Actions 定时执行与结果通知

2.2 目录结构

text 复制代码
auto-test-project/
├── config/
│   ├── config.yaml          # 环境配置
│   └── __init__.py
├── data/
│   ├── login_data.yaml      # 测试数据
│   └── order_data.yaml
├── common/
│   ├── request_client.py    # 接口请求封装
│   ├── logger.py            # 日志封装
│   ├── yaml_util.py         # YAML 读取
│   └── assert_util.py       # 断言工具
├── api/
│   ├── login_api.py
│   ├── product_api.py
│   └── order_api.py
├── pages/
│   ├── base_page.py
│   ├── login_page.py
│   └── order_page.py
├── testcases/
│   ├── api/
│   │   ├── test_login.py
│   │   └── test_order.py
│   └── ui/
│       └── test_login_ui.py
├── conftest.py
├── pytest.ini
├── requirements.txt
└── run.py

分层清晰是这个项目的第一原则:配置、数据、公共方法、业务封装、测试用例各司其职,后续维护成本会低很多。

3. 环境准备

3.1 依赖安装

bash 复制代码
pip install pytest requests selenium pyyaml allure-pytest faker

requirements.txt 建议固定版本,避免 CI 环境漂移:

text 复制代码
pytest==8.2.0
requests==2.32.0
selenium==4.21.0
PyYAML==6.0.1
allure-pytest==2.13.5
Faker==25.2.0

3.2 配置文件

config/config.yaml:

yaml 复制代码
env: test

test:
  base_url: "http://test.example.com/api"
  ui_url: "http://test.example.com"
  username: "admin"
  password: "123456"

prod:
  base_url: "http://prod.example.com/api"
  ui_url: "http://prod.example.com"
  username: "admin"
  password: "******"

读取配置的封装:

python 复制代码
# common/yaml_util.py
import yaml
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent


def load_yaml(file_path: str) -> dict:
    path = BASE_DIR / file_path
    with open(path, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)


def get_config() -> dict:
    config = load_yaml("config/config.yaml")
    env = config["env"]
    return config[env]
通过环境变量切换环境

把 env 写死在 config.yaml 里,本地和 CI 就得来回改文件,很容易误提交。更稳妥的做法是让环境变量优先:本地默认读 test,CI 里通过 TEST_ENV=prod 之类的变量覆盖。

python 复制代码
# common/yaml_util.py
import os
import yaml
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent


def load_yaml(file_path: str) -> dict:
    path = BASE_DIR / file_path
    with open(path, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)


def get_config() -> dict:
    config = load_yaml("config/config.yaml")
    # 环境变量优先,未设置时回退到配置文件中的 env
    env = os.getenv("TEST_ENV", config.get("env", "test"))
    if env not in config:
        raise ValueError(f"未找到环境配置: {env},请检查 config.yaml 或 TEST_ENV")
    return config[env]

使用方式:

bash 复制代码
# 本地默认走 test
pytest testcases

# CI 或临时切换环境
TEST_ENV=prod pytest testcases

在 GitHub Actions 中只需给 job 加一个环境变量:

yaml 复制代码
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      TEST_ENV: test
    steps:
      - uses: actions/checkout@v4
      # ...
敏感信息不要明文写在配置文件里

config.yaml 会随代码一起提交到仓库,生产环境的账号密码一旦写进去就等于公开。建议:

  • 配置文件里只保留非敏感项(base_url、ui_url、username),密码字段留空或写占位符;
  • 密码统一从环境变量读取,例如 TEST_PASSWORD,代码中做兜底:
python 复制代码
def get_config() -> dict:
    config = load_yaml("config/config.yaml")
    env = os.getenv("TEST_ENV", config.get("env", "test"))
    if env not in config:
        raise ValueError(f"未找到环境配置: {env}")
    cfg = dict(config[env])
    # 环境变量中的密码优先级最高
    cfg["password"] = os.getenv("TEST_PASSWORD", cfg.get("password", ""))
    return cfg
  • CI 中使用仓库 Secrets(如 ${``{ secrets.TEST_PASSWORD }})注入,本地用 .env 或系统环境变量,.env 记得加入 .gitignore;
  • 生产环境尽量使用独立的只读测试账号,避免用管理员账号跑自动化。

4. 接口自动化实战

4.1 请求客户端封装

直接在每个用例里写 requests.post(...) 会导致大量重复代码,也不方便统一加日志、加 token。封装一层:

python 复制代码
# common/request_client.py
import requests
from common.logger import logger


class RequestClient:
    def __init__(self, base_url: str, token: str = None):
        self.base_url = base_url
        self.session = requests.Session()
        if token:
            self.session.headers.update({"Authorization": f"Bearer {token}"})

    def request(self, method: str, url: str, **kwargs):
        full_url = self.base_url + url
        logger.info(f"请求: {method.upper()} {full_url}, 参数: {kwargs}")
        resp = self.session.request(method, full_url, timeout=10, **kwargs)
        logger.info(f"响应: {resp.status_code} {resp.text[:200]}")
        return resp

    def get(self, url, **kwargs):
        return self.request("GET", url, **kwargs)

    def post(self, url, **kwargs):
        return self.request("POST", url, **kwargs)

4.2 业务接口封装

python 复制代码
# api/login_api.py
from common.request_client import RequestClient


class LoginApi:
    def __init__(self, client: RequestClient):
        self.client = client

    def login(self, username: str, password: str):
        return self.client.post(
            "/login",
            json={"username": username, "password": password}
        )

4.3 测试用例与数据驱动

data/login_data.yaml:

yaml 复制代码
- case_name: "正常登录"
  username: "admin"
  password: "123456"
  expect_code: 200
  expect_msg: "success"

- case_name: "密码错误"
  username: "admin"
  password: "wrong"
  expect_code: 401
  expect_msg: "用户名或密码错误"

- case_name: "用户名为空"
  username: ""
  password: "123456"
  expect_code: 400
  expect_msg: "用户名不能为空"
python 复制代码
# testcases/api/test_login.py
import pytest
from common.yaml_util import load_yaml
from api.login_api import LoginApi

login_cases = load_yaml("data/login_data.yaml")


class TestLogin:
    @pytest.mark.parametrize("case", login_cases, ids=[c["case_name"] for c in login_cases])
    def test_login(self, client, case):
        api = LoginApi(client)
        resp = api.login(case["username"], case["password"])
        body = resp.json()
        assert resp.status_code == case["expect_code"]
        assert body["msg"] == case["expect_msg"]

4.4 conftest 统一管理 fixture

python 复制代码
# conftest.py
import pytest
from common.yaml_util import get_config
from common.request_client import RequestClient


@pytest.fixture(scope="session")
def config():
    return get_config()


@pytest.fixture(scope="session")
def client(config):
    return RequestClient(config["base_url"])


@pytest.fixture(scope="session")
def token(client, config):
    resp = client.post(
        "/login",
        json={"username": config["username"], "password": config["password"]}
    )
    return resp.json()["data"]["token"]

5. UI 自动化实战

5.1 Page Object 模式

UI 自动化的核心痛点是「页面一改,用例全崩」。Page Object 把元素定位和操作封装到页面类里,用例只关心业务动作。

python 复制代码
# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


class BasePage:
    def __init__(self, driver):
        self.driver = driver
        self.wait = WebDriverWait(driver, 10)

    def find(self, locator):
        return self.wait.until(EC.presence_of_element_located(locator))

    def click(self, locator):
        self.wait.until(EC.element_to_be_clickable(locator)).click()

    def input_text(self, locator, text):
        el = self.find(locator)
        el.clear()
        el.send_keys(text)
python 复制代码
# pages/login_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage


class LoginPage(BasePage):
    USERNAME_INPUT = (By.ID, "username")
    PASSWORD_INPUT = (By.ID, "password")
    LOGIN_BUTTON = (By.CSS_SELECTOR, "button.login-btn")
    WELCOME_TEXT = (By.CLASS_NAME, "welcome")

    def open(self, url):
        self.driver.get(url + "/login")

    def login(self, username, password):
        self.input_text(self.USERNAME_INPUT, username)
        self.input_text(self.PASSWORD_INPUT, password)
        self.click(self.LOGIN_BUTTON)

    def get_welcome_text(self):
        return self.find(self.WELCOME_TEXT).text

5.2 UI 测试用例

python 复制代码
# testcases/ui/test_login_ui.py
import pytest
from selenium import webdriver
from pages.login_page import LoginPage


@pytest.fixture(scope="class")
def driver():
    d = webdriver.Chrome()
    d.maximize_window()
    yield d
    d.quit()


class TestLoginUI:
    def test_login_success(self, driver, config):
        page = LoginPage(driver)
        page.open(config["ui_url"])
        page.login(config["username"], config["password"])
        assert "欢迎" in page.get_welcome_text()

5.3 无头模式与显式等待

CI 环境没有图形界面,需要开启无头模式:

python 复制代码
from selenium.webdriver.chrome.options import Options

options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
driver = webdriver.Chrome(options=options)

同时坚决避免 time.sleep,统一使用显式等待,既稳定又快。

6. 测试报告与持续集成

6.1 Allure 报告

bash 复制代码
pytest testcases --alluredir=./allure-results
allure serve ./allure-results

在用例中补充步骤与附件,报告可读性会大幅提升:

python 复制代码
import allure

@allure.feature("登录模块")
class TestLogin:
    @allure.story("正常登录")
    @allure.title("使用正确账号密码登录成功")
    def test_login(self, client, case):
        with allure.step("调用登录接口"):
            resp = LoginApi(client).login(case["username"], case["password"])
        with allure.step("校验响应"):
            assert resp.status_code == case["expect_code"]

6.2 pytest.ini 配置

ini 复制代码
[pytest]
testpaths = testcases
addopts = -v -s --alluredir=./allure-results --clean-alluredir
markers =
    api: 接口测试
    ui: UI 测试

6.3 接入 CI

以 GitHub Actions 为例:

yaml 复制代码
name: Auto Test

on:
  push:
    branches: [ main ]
  schedule:
    - cron: "0 2 * * *"

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install -r requirements.txt
      - run: pytest testcases/api
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: allure-results
          path: allure-results

7. 常见问题与优化建议

7.1 用例不稳定(Flaky)

  • UI 层:优先排查是否用了 sleep、是否缺少显式等待、元素是否被遮挡。
  • 接口层:检查是否依赖了其他用例产生的数据,尽量让用例自造数据、自清理。

7.2 数据污染

测试数据建议使用 Faker 生成唯一值,例如:

python 复制代码
from faker import Faker

fake = Faker("zh_CN")
username = f"test_{fake.uuid4()[:8]}"

7.3 执行效率

  • 接口用例与 UI 用例分开执行,接口用例作为每次提交的快速门禁。
  • UI 用例只保留核心主流程,其余下沉到接口层验证。
  • 使用 pytest-xdist 并行执行:pytest -n 4。

7.4 分层原则小结

#mermaid-svg-VhuDHNySKQhzzHUb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VhuDHNySKQhzzHUb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VhuDHNySKQhzzHUb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VhuDHNySKQhzzHUb .error-icon{fill:#552222;}#mermaid-svg-VhuDHNySKQhzzHUb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VhuDHNySKQhzzHUb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VhuDHNySKQhzzHUb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VhuDHNySKQhzzHUb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VhuDHNySKQhzzHUb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VhuDHNySKQhzzHUb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VhuDHNySKQhzzHUb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VhuDHNySKQhzzHUb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VhuDHNySKQhzzHUb .marker.cross{stroke:#333333;}#mermaid-svg-VhuDHNySKQhzzHUb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VhuDHNySKQhzzHUb p{margin:0;}#mermaid-svg-VhuDHNySKQhzzHUb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VhuDHNySKQhzzHUb .cluster-label text{fill:#333;}#mermaid-svg-VhuDHNySKQhzzHUb .cluster-label span{color:#333;}#mermaid-svg-VhuDHNySKQhzzHUb .cluster-label span p{background-color:transparent;}#mermaid-svg-VhuDHNySKQhzzHUb .label text,#mermaid-svg-VhuDHNySKQhzzHUb span{fill:#333;color:#333;}#mermaid-svg-VhuDHNySKQhzzHUb .node rect,#mermaid-svg-VhuDHNySKQhzzHUb .node circle,#mermaid-svg-VhuDHNySKQhzzHUb .node ellipse,#mermaid-svg-VhuDHNySKQhzzHUb .node polygon,#mermaid-svg-VhuDHNySKQhzzHUb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VhuDHNySKQhzzHUb .rough-node .label text,#mermaid-svg-VhuDHNySKQhzzHUb .node .label text,#mermaid-svg-VhuDHNySKQhzzHUb .image-shape .label,#mermaid-svg-VhuDHNySKQhzzHUb .icon-shape .label{text-anchor:middle;}#mermaid-svg-VhuDHNySKQhzzHUb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VhuDHNySKQhzzHUb .rough-node .label,#mermaid-svg-VhuDHNySKQhzzHUb .node .label,#mermaid-svg-VhuDHNySKQhzzHUb .image-shape .label,#mermaid-svg-VhuDHNySKQhzzHUb .icon-shape .label{text-align:center;}#mermaid-svg-VhuDHNySKQhzzHUb .node.clickable{cursor:pointer;}#mermaid-svg-VhuDHNySKQhzzHUb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VhuDHNySKQhzzHUb .arrowheadPath{fill:#333333;}#mermaid-svg-VhuDHNySKQhzzHUb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VhuDHNySKQhzzHUb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VhuDHNySKQhzzHUb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VhuDHNySKQhzzHUb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VhuDHNySKQhzzHUb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VhuDHNySKQhzzHUb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VhuDHNySKQhzzHUb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VhuDHNySKQhzzHUb .cluster text{fill:#333;}#mermaid-svg-VhuDHNySKQhzzHUb .cluster span{color:#333;}#mermaid-svg-VhuDHNySKQhzzHUb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VhuDHNySKQhzzHUb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VhuDHNySKQhzzHUb rect.text{fill:none;stroke-width:0;}#mermaid-svg-VhuDHNySKQhzzHUb .icon-shape,#mermaid-svg-VhuDHNySKQhzzHUb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VhuDHNySKQhzzHUb .icon-shape p,#mermaid-svg-VhuDHNySKQhzzHUb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VhuDHNySKQhzzHUb .icon-shape .label rect,#mermaid-svg-VhuDHNySKQhzzHUb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VhuDHNySKQhzzHUb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VhuDHNySKQhzzHUb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VhuDHNySKQhzzHUb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 测试用例层 testcases
业务封装层 api / pages
公共能力层 common
配置与数据层 config / data
被测系统

越靠上的层越贴近业务、越容易变;越靠下的层越稳定、越应复用。遵循这个方向,项目才能长期维护。

8. 总结

一个合格的自动化测试项目,不在于用例数量多,而在于结构清晰、稳定可维护、能持续运行。本文从目录设计、接口封装、Page Object、数据驱动到 CI 集成,给出了一套可以直接落地的骨架。

建议你按下面的顺序动手实践:

  1. 先跑通一个接口用例,把请求封装和配置读取做扎实;
  2. 再补充数据驱动,让用例覆盖更多场景;
  3. 然后引入 UI 自动化,只覆盖核心流程;
  4. 最后接入 Allure 与 CI,让测试真正「跑起来」。

把这套项目完整做一遍,你对自动化测试的理解会从「会用工具」升级到「能交付工程」。

相关推荐
猴哥聊项目管理7 天前
自动化测试管理工具实战:从手工测试到自动化提效的转型路径
自动化测试·测试用例管理·测试管理工具·自动化测试管理工具·手工测试转型
蒸鱼Yuzheng14 天前
性能 CI 成本优化:设备分钟、风险分层与自适应测试选择
自动化测试·ci/cd·性能测试·测试平台·成本优化
自动化测试行业观察15 天前
AI智能测试用例生成技术解析:多源融合+LLM混合生成方案与实践
自动化测试·人工智能·科技·测试用例·智能化测试·ai测试平台·ai智能遍历
LabVIEW开发16 天前
风洞 50 米/秒,LabVIEW 采哪几路信号
自动化测试·数据采集·labview
LabVIEW开发19 天前
核聚变要的功率半导体,LabVIEW 怎么测
自动化测试·数据采集·labview·功率半导体
蒸鱼Yuzheng20 天前
自动化长任务治理:租约、心跳、孤儿进程与安全回收
自动化测试·python·任务调度·可靠性·分布式系统
LabVIEW开发22 天前
光子芯片测试仪国产突破:这正是 LabVIEW 的主场
自动化测试·数据采集·labview
龙智DevSecOps解决方案22 天前
赋能汽车/金融/游戏研发:龙智正式引入AI测试管理平台aqua cloud
自动化测试·devops·需求管理·测试管理·研发管理·aqua cloud
纳米软件23 天前
电源时序测试专题:多轨上电顺序怎么验
自动化测试·电源时序测试·多轨上电顺序·上电时序验证