跳转至

接口自动化数据管理与环境隔离

"本地全过,CI 一半红"的根因基本集中在三件事:数据被别人动了、环境配置不一样、并行时抢同一个资源。这篇按这三个问题给方案,代码可以直接落地。

一、先把环境差异变成配置

1.1 配置矩阵

环境 用途 数据策略 允许写操作
local 本地调试 容器内独立库 是
test 团队联调 共享库,按租户隔离 是
staging 发布前验证 接近生产,只读为主 少量
prod 只做巡检 只读 否

配置从环境变量注入,代码里不允许出现域名:

# config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    base_url: str
    db_dsn: str
    api_user: str
    api_password: str
    tenant_prefix: str = "qa"
    timeout: float = 10.0

    class Config:
        env_prefix = "QALAB_"
        env_file = ".env"

settings = Settings()
QALAB_BASE_URL=https://staging.example.com \
QALAB_DB_DSN=postgresql://qa:pass@db:5432/app \
QALAB_API_USER=qa_bot \
QALAB_API_PASSWORD="$SECRET" \
pytest -m regression

好处:切换环境只改环境变量;CI 里用 Secret 注入;本地用 .env 且 .env 进 .gitignore。

1.2 不要在用例里写 if env ==

# 错误
if settings.base_url.endswith("staging.com"):
    payload["cardNo"] = "6222020000000000000"
else:
    payload["cardNo"] = "4111111111111111"

# 正确:把差异放进配置
payload["cardNo"] = settings.test_card_no

用例里出现环境分支,等于把环境差异固化进代码,后续每加一个环境就要改所有用例。

二、造数据:优先走接口,兜底走 SQL

2.1 四种方式的适用边界

方式 速度 真实性 适用
接口构造 慢(走业务) 高 绝大多数用例
DB 直插 快 低(绕过校验) 造"接口到不了"的状态(冻结、逾期)
SQL 脚本/快照 最快 中 批量基础数据、环境初始化
固定夹具 快 只读 字典、SPU、SKU

原则:被测接口用接口造,前置状态用 SQL 造。用 DB 直插造被测对象,会掩盖接口层校验缺陷。

2.2 数据工厂

import time, uuid, itertools

class UserFactory:
    _seq = itertools.count(1)

    @classmethod
    def create(cls, client, role="user"):
        name = f"qa_{int(time.time())}_{next(cls._seq)}_{uuid.uuid4().hex[:6]}"
        resp = client.post("/api/users", json={
            "username": name, "role": role, "password": "Passw0rd!",
        })
        assert resp.status_code in (200, 201), resp.text
        data = resp.json()["data"]
        assert data["username"] == name
        return data

唯一性三段式:时间戳 + 自增 + 随机后缀。只用时间戳在并行下会撞(同一秒多个 worker);只用自增在多进程下会撞。

2.3 用 SQL 造状态(明确限定范围)

def freeze_account(db, user_id):
    with db.cursor() as cur:
        cur.execute(
            "UPDATE accounts SET status = %s, updated_at = now() WHERE id = %s",
            ("FROZEN", user_id))
        assert cur.rowcount == 1, f"user {user_id} not found"
    db.commit()

一定要断言 rowcount,否则 SQL 静默失败会让用例在错误的前提下继续跑。

三、清理:三步兜底

3.1 fixture teardown

@pytest.fixture
def temp_user(client):
    user = UserFactory.create(client)
    yield user
    client.delete(f"/api/users/{user['id']}")

teardown 里的删除不要 assert,用日志记录:

def cleanup(client, user):
    resp = client.delete(f"/api/users/{user['id']}")
    if resp.status_code not in (200, 204, 404):
        logging.warning("cleanup failed: %s %s", resp.status_code, resp.text)

清理失败让用例变红,会掩盖真正的失败原因。

3.2 批次标记 + 批量清理

所有测试数据带上批次号,事后统一按批次清:

{"username": "qa_20261022_a1b2_0001", "ext": {"batch": "20261022_a1b2"}}
-- 兜底清理(每天跑一次)
DELETE FROM accounts WHERE username LIKE 'qa\_2026%' AND created_at < now() - interval '1 day';

注意 LIKE 里的 _ 是通配符,要转义,否则会命中更多数据。

3.3 清理钩子挂在 pytest 上

def pytest_sessionfinish(session, exitstatus):
    if exitstatus in (0, 1):        # 无论通过与否都清理
        cleanup_by_batch(session.config.getoption("--batch-id"))

四、并行隔离:四种方案

4.1 账号/数据按 worker 隔离

@pytest.fixture(scope="session")
def worker_prefix(worker_id):
    return "master" if worker_id == "master" else f"w{worker_id}"

@pytest.fixture(scope="session")
def client(worker_prefix):
    return ApiClient(user=f"qa_{worker_prefix}", password=settings.api_password)

4.2 数据库按 worker 分 schema(最彻底)

@pytest.fixture(scope="session", autouse=True)
def schema(worker_id, db):
    name = f"test_{worker_id}".replace("-", "_")
    db.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")
    db.execute(f"CREATE SCHEMA {name}")
    db.execute(f"SET search_path TO {name}")
    yield name
    db.execute(f"DROP SCHEMA IF EXISTS {name} CASCADE")

worker_id 可能返回 gw0、gw1,也可能是 master(单 worker),必须处理两种值。

4.3 资源池(库存、优惠券)

抢同一库存是并行下最典型的偶发失败。方案:

  • 每个 worker 预分配独立库存(stock_w0、stock_w1)
  • 或用"创建即独占"的资源,避免共享
@pytest.fixture(scope="function")
def exclusive_sku(client, worker_prefix):
    sku = client.post("/api/skus", json={
        "name": f"sku_{worker_prefix}_{uuid.uuid4().hex[:8]}",
        "stock": 1,
    }).json()["data"]
    yield sku

4.4 时间与随机数

  • 依赖"今天"的用例,用 freezegun 固定时间,别用系统时钟。
  • 依赖随机数的断言,注入固定 seed。
  • 时区问题:CI 容器默认 UTC,本地是 Asia/Shanghai,跨天边界用例必挂。
from freezegun import freeze_time

@freeze_time("2026-10-22 23:59:00")
def test_cross_day_settlement(client):
    ...

五、"本地过、CI 挂"逐项排查

按命中率排序:

现象 根因 验证方法 修复
偶发 409/库存不足 共享数据被并行消费 pytest -n 4 本地复现 资源池/独立 SKU
时区相关断言失败 容器 UTC date 对比 固定 TZ 或 freeze_time
连接超时 CI 网络策略、DNS curl -v 目标域名 显式代理/重试
401 全挂 token 过期时间按本地时钟算 打印 token exp 每次 session 重新登录
金额差 0.01 浮点在不同平台舍入 打印原始响应 用 Decimal 比较
上传失败 CI 无对应文件/权限 ls 路径 用 tmp_path 造文件
顺序相关 用例间共享状态 pytest -p no:randomly 对照 用例独立
慢 10 倍 CI CPU 少、无缓存 看 --durations 减少 sleep,改显式等待

一个很实用的命令,直接定位慢与不稳定:

pytest -n 4 --durations=20 -q --tb=short

六、敏感数据

  • 测试账号密码走 CI Secret,不进仓库。
  • 日志里对 password、token、idCard 打码:
SENSITIVE = ("password", "token", "authorization", "idCard", "phone")

def mask(payload: dict) -> dict:
    return {k: ("***" if k.lower() in SENSITIVE else v) for k, v in payload.items()}
  • Allure 附件里不要附完整请求体,附打码后的版本。
  • 生产环境只跑只读用例,用独立只读账号,并在代码层加开关:
if settings.base_url == settings.prod_url and request.method != "GET":
    pytest.skip("prod 环境禁止写操作")

七、检查清单

  • 所有环境相关值来自配置,用例里无硬编码域名
  • 数据唯一性 = 时间戳 + 自增 + 随机
  • 造数据优先接口,SQL 仅用于状态
  • teardown 不 assert,失败只告警
  • 有按批次的兜底清理脚本
  • pytest -n 4 本地能稳定跑通
  • 依赖时间的用例已 freeze
  • 日志与 Allure 附件已脱敏
  • 生产环境有写操作拦截

相关阅读