Python 脚本执行 Excel 中的接口测试

51 阅读9分钟

利用 Python 脚本执行 Excel 中的接口测试(Windows & macOS + VS Code 全流程)

目标:用 Excel 维护接口用例(方法、路径、断言、提取等),用 pytest 执行,用 HTML 报告查看是否成功、是否达到预期值。
环境:Windows 10/11 或 macOSPython 3.10+VS Code(安装 Python 插件)。


目录

  1. 准备与安装
  2. 创建项目结构
  3. 创建虚拟环境并安装依赖(Windows 与 macOS)
  4. 把代码与表格放到项目里
  5. 运行测试与生成 HTML 报告
  6. VS Code 配置与一键运行
  7. 设置环境变量(Windows & macOS)
  8. 常见问题与排错
  9. 下一步:扩展断言/提取与并行执行

准备与安装

  • Python 3.10+

    • Windows:到 python.org 下载并安装,勾选 “Add Python to PATH”。
      验证:
      py --version
      python --version
      
    • macOS:推荐用 Homebrew 安装:
      brew install python     # 未安装 Homebrew: https://brew.sh
      python3 --version
      
  • VS Code + 扩展:

    • Microsoft Python 扩展(ms-python.python)
    • (可选)PylancePython Test Explorer

创建项目结构

在任意目录(Windows 例:D:\work;macOS 例:~/work)新建项目 excel-api-test

excel-api-test/
├─ conftest.py
├─ test_from_excel.py
├─ apis.xlsx          # Excel 用例表(工作表名:cases)
├─ requirements.txt
└─ pytest.ini         # 可选:固定开启 HTML 报告等

也可先只放 *.pyrequirements.txt,第 5 步前再放 apis.xlsx


创建虚拟环境并安装依赖(Windows 与 macOS)

Windows(PowerShell)

cd D:\work\excel-api-test

# 1) 创建并激活虚拟环境
py -3 -m venv .venv
.\.venv\Scripts\activate

# 2) 升级 pip(建议)
python -m pip install -U pip

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

macOS(zsh/bash)

cd ~/work/excel-api-test

# 1) 创建并激活虚拟环境
python3 -m venv .venv
source .venv/bin/activate

# 2) 升级 pip(建议)
python -m pip install -U pip

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

激活成功后,命令行前缀会显示 (.venv);退出虚拟环境:deactivate


把代码与表格放到项目里

test_from_excel.py

import json                  # 把单元格中的 JSON 字符串转成 Python 对象
import math                  # 用来判断 NaN(空单元格时 pandas 可能给出 NaN)

import requests              # 发送 HTTP 请求
import pytest                # 做参数化
import pandas as pd          # 读取 Excel
from jsonpath_ng import parse as jp  # JSONPath 解析(从 JSON 响应里取字段)


# 约定的 Excel 文件名和工作表名
EXCEL_FILE = "apis.xlsx"
SHEET_NAME = "cases"


def _parse_cell(s):
    """
    把 Excel 单元格里的内容转成“合适的 Python 类型”:
    - 空/NaN/空白 -> None
    - 否则优先按 JSON 解析(如 '{"cid":"294"}' -> dict)
    - 如果不是合法 JSON,就当作普通字符串返回(后面还能做 {变量} 替换)
    """
    # 判空:None、NaN、空字符串都当 None
    if s is None or (isinstance(s, float) and math.isnan(s)) or str(s).strip() == "":
        return None

    # 统一成字符串并去空白,尝试按 JSON 解析
    s = str(s).strip()
    try:
        return json.loads(s)         # 成功:得到 dict/list/数值/布尔 等
    except Exception:
        return s                     # 不是合法 JSON:作为普通字符串返回


def _extract_to_ctx(ctx, resp_json, extract_spec):
    """
    按 extract 规则,从响应 JSON 里提取字段 -> 存到变量背包 ctx
    - extract_spec 形如:{"aid":"$.data.datas[0].id"}
    - 用 JSONPath 找到第一个匹配值,保存为 ctx["aid"]
    """
    for var_name, jpath in extract_spec.items():
        matches = list(jp(jpath).find(resp_json))  # 在 resp_json 里执行 JSONPath 查询
        if matches:
            ctx[var_name] = matches[0].value       # 取第一个匹配结果的值存入 ctx
        # 若取不到,可按需要改为强校验:assert matches, f"{var_name} not found by {jpath}"


def _check_jsonpath(resp_json, path_expr, expected, ctx):
    """
    做 JSON 字段断言:
    - path_expr:JSONPath 表达式(例如 $.errorCode)
    - expected :期望值;可以是具体值/字符串(支持 {变量} 占位)/特殊标记
      * "!null"         -> 断言“能取到且不为 null/None”
      * "!exists?false" -> 断言“允许不存在,或存在但值为 False”
    """
    matches = list(jp(path_expr).find(resp_json))  # 执行 JSONPath,拿到匹配项列表

    if expected == "!null":
        # 要求:有匹配项,且值不是 None
        assert matches and matches[0].value is not None, f"{path_expr} is null or missing"
        return

    if expected == "!exists?false":
        # 允许:没有这个字段;或有这个字段但为 False(空/0/False)
        assert (matches and bool(matches[0].value) is False) or not matches, f"{path_expr} exists and truthy"
        return

    # 如果期望是字符串,允许里面写 {变量}(例如 "{aid}");用 ctx.apply 替换
    if isinstance(expected, str):
        expected = ctx.apply(expected)

    actual = matches[0].value if matches else None  # 实际取到的值(取不到就当 None)
    assert actual == expected, f"jsonpath {path_expr}: expected {expected}, got {actual}"


def load_cases():
    """
    从 Excel 读取所有用例 -> 生成“用例字典列表”
    同时把 'times'(执行次数)展开:例如某行 times=3 -> 复制成 3 条用例
    """
    df = pd.read_excel(EXCEL_FILE, sheet_name=SHEET_NAME)  # 读 Excel 的 cases 工作表

    cases = []
    for _, row in df.iterrows():  # 遍历每一行
        case = {
            "case": row.get("case"),                                  # 用例名
            "method": str(row.get("method", "GET")).upper(),          # 方法(默认 GET,转大写)
            "path": row.get("path"),                                  # 路径(与 base_url 拼接)
            "headers": _parse_cell(row.get("headers")),               # 请求头(JSON 或空)
            "query": _parse_cell(row.get("query")),                   # 查询参数(JSON 或空)
            "body": _parse_cell(row.get("body")),                     # 请求体(JSON 或空)
            "expected_status": int(row.get("expected_status", 200)),  # 期望 HTTP 状态码(默认 200)
            "expect_jsonpath": row.get("expect_jsonpath"),            # JSONPath 断言字段
            "expect_value": _parse_cell(row.get("expect_value")),     # 断言的期望值
            "extract": _parse_cell(row.get("extract")) or {},         # 需要从响应里提取的变量
            "times": int(row.get("times", 1)),                        # 重复执行次数(默认 1)
        }
        cases.append(case)  # 收集

    # 把 times>1 的用例展开成多条(名字后加 #1/#2/... 方便在报告里区分)
    expanded = []
    for c in cases:
        for i in range(c["times"]):
            tag = f'{c["case"]}#{i+1}' if c["times"] > 1 else c["case"]
            expanded.append({**c, "case": tag})
    return expanded


# 模块加载时就把所有用例读出来,供参数化使用
_ALL_CASES = load_cases()


# 参数化:_ALL_CASES 有多少条,就会生成多少个独立的测试用例
# ids 指定每个用例在报告里显示的名字(这里用 Excel 里的 case 字段)
@pytest.mark.parametrize("c", _ALL_CASES, ids=lambda c: c["case"])
def test_by_excel(c, base_url, ctx):
    """
    核心测试函数:
    - 拼 URL(base_url + path)
    - 在发请求前,对 headers/query/body 做 {变量} 替换
    - 发送请求并断言 HTTP 状态码
    - 若配置了 JSON 断言或提取,则解析 JSON 并执行对应逻辑
    """
    # 拼接完整 URL:base_url(来自 conftest.py 的 fixture) + path(来自 Excel)
    url = base_url + c["path"]

    # 在真正发请求前,先把 headers/query/body 里的 {变量} 都替换成真实值
    headers = ctx.apply(c["headers"]) if isinstance(c["headers"], (dict, list, str)) else c["headers"]
    query   = ctx.apply(c["query"])   if isinstance(c["query"],   (dict, list, str)) else c["query"]
    body    = ctx.apply(c["body"])    if isinstance(c["body"],    (dict, list, str)) else c["body"]

    # 发送 HTTP 请求:
    # - 只有当 headers/query/body 是 dict 时才传入(避免把字符串误传)
    # - 这里把 body 当 JSON 发送(入门版这样就够了)
    resp = requests.request(
        method=c["method"],
        url=url,
        headers=headers if isinstance(headers, dict) else None,
        params=query if isinstance(query, dict) else None,
        json=body if isinstance(body, dict) else None,
        timeout=15
    )

    # 断言 HTTP 状态码(例如 200);失败时把响应前 300 字符打印出来便于定位
    assert resp.status_code == c["expected_status"], (
        f"{c['case']} -> {resp.status_code}\n{resp.text[:300]}"
    )

    # 只有在需要 JSON 断言或需要提取变量时,才把响应当 JSON 解析(避免无意义解析)
    if c["expect_jsonpath"] or c["extract"]:
        data = resp.json()

        # 如果配置了 JSONPath 断言,就检查取到的值是否等于期望值(或满足 !null 等规则)
        if c["expect_jsonpath"]:
            _check_jsonpath(data, c["expect_jsonpath"], c["expect_value"], ctx)

        # 如果配置了 extract,就把响应里的值提取到 ctx 里供后续用例使用
        if c["extract"]:
            _extract_to_ctx(ctx, data, c["extract"])

conftest.py

import os                     # 读取环境变量用(例如 BASE_URL、TEST_username 等)
import pytest                 # pytest 的夹具(fixture)机制要用到
from collections import UserDict  # 可扩展的字典基类,和 dict 类似但更方便自定义


# 从环境变量里拿 BASE_URL;如果没有,就用默认的 WanAndroid 域名(官方已全面 https)
BASE_URL = os.getenv("BASE_URL", "https://www.wanandroid.com")


class Ctx(UserDict):
    """
    变量上下文(像一个“变量背包”):
    - 本质上就是一个字典:可以存 {"token": "...", "dev": "..."} 等变量
    - 提供 apply() 方法:把对象里出现的 {变量名} 替换成背包里同名变量的值
      例如:字符串 "Bearer {token}" -> 如果 self["token"]="abc",则替换为 "Bearer abc"
    """

    def apply(self, obj):
        # 如果传进来的是 dict,就把 key、value 都递归替换(因为 key 里也可能出现 {变量})
        if isinstance(obj, dict):
            return {self.apply(k): self.apply(v) for k, v in obj.items()}

        # 如果是 list,就对列表里的每一个元素递归替换
        if isinstance(obj, list):
            return [self.apply(x) for x in obj]

        # 如果是字符串,就用 Python 的 str.format(**self) 做占位符替换
        #   比如:obj="Hello {name}",而 self={"name":"Edy"} -> "Hello Edy"
        if isinstance(obj, str):
            return obj.format(**self)

        # 其它类型(int/bool/None…)保持原样返回
        return obj


@pytest.fixture(scope="session")
def ctx():
    """
    提供一个“会话级”的上下文对象(变量背包):
    - scope=session 表示整个测试会话(一次 pytest 运行)只创建一次
    - 支持把环境变量 TEST_xxx 注入为 ctx['xxx']
      例如:$env:TEST_dev="abc123" -> Excel 可用 {dev}
    """
    c = Ctx()
    for k, v in os.environ.items():
        if k.startswith("TEST_"):
            # 去掉 TEST_ 前缀,统一存小写
            c[k[5:].lower()] = v
    return c


@pytest.fixture(scope="session")
def base_url():
    """把统一的接口前缀暴露给测试使用(可被 env 覆盖)"""
    return BASE_URL

requirements.txt

pytest
requests
pandas
openpyxl
jsonpath-ng
pytest-html
pytest-xdist

pytest.ini (可选)

固定每次都输出 HTML 报告:

[pytest]
addopts = -v --html=report.html --self-contained-html

apis.xlsx 表头与示例

工作表名:cases。常用列:

casemethodpathexpected_statusexpect_jsonpathexpect_valueheadersquerybodyextracttimes

示例(WanAndroid 常用 GET 接口):

casemethodpathexpected_statusexpect_jsonpathexpect_value
首页文章列表-第0页GET/article/list/0/json200$.errorCode0
首页bannerGET/banner/json200$.errorCode0
常用网站GET/friend/json200$.errorCode0

如果需要多次运行同一条,把 times 填 3 就会展开成 3 条用例。


运行测试与生成 HTML 报告

Windows(PowerShell)

# 基本运行(若已在 pytest.ini 中配置 HTML)
pytest -v

# 显式生成 HTML 报告
pytest -v --html=report.html --self-contained-html

# 想在报告中也看到 print 输出:
pytest -v -s --capture=tee-sys --html=report.html --self-contained-html

# 跑完直接用默认浏览器打开
start report.html

macOS(zsh/bash)

# 基本运行(若已在 pytest.ini 中配置 HTML)
pytest -v

# 显式生成 HTML 报告
pytest -v --html=report.html --self-contained-html

# 想在报告中也看到 print 输出:
pytest -v -s --capture=tee-sys --html=report.html --self-contained-html

# 打开报告
open report.html

只跑某些用例:

pytest -v -k banner

并行跑(更快):

pytest -v -n auto

VS Code 配置与一键运行

  1. 选择解释器:Ctrl+Shift+P → “Python: Select Interpreter”
    • Windows 选:.\.venv\Scripts\python.exe
    • macOS 选:./.venv/bin/python
  2. 打开 Testing 面板(左侧小烧杯),VS Code 会自动发现 pytest,点 ▶️ 运行即可。
  3. (可选).vscode/settings.json
{
  "python.testing.pytestEnabled": true,
  "python.testing.unittestEnabled": false,
  "python.testing.pytestArgs": [
    "-v",
    "--html=report.html",
    "--self-contained-html",
    "--capture=tee-sys",
    "-s"
  ]
}

设置环境变量(Windows & macOS)

如果 Excel 用到了占位符(在 headers/query/body/expect_value 里写了 {username} 之类),就需要给 ctx 提供变量。

Windows(PowerShell)—临时:

$env:BASE_URL = "https://www.wanandroid.com"
$env:TEST_username = "your_user"
$env:TEST_password = "your_pass"

pytest -v

Windows(永久,用户变量):

setx BASE_URL "https://www.wanandroid.com"
setx TEST_username "your_user"
setx TEST_password "your_pass"
# 重新打开一个新终端生效

macOS(zsh)—临时:

export BASE_URL="https://www.wanandroid.com"
export TEST_username="your_user"
export TEST_password="your_pass"

pytest -v

macOS(永久): 将上述 export 写进 ~/.zshrc,然后 source ~/.zshrc


常见问题与排错

  • ModuleNotFoundError: ...
    没在虚拟环境里:先激活 .venv 再安装依赖。

  • ValueError: Excel file format cannot be determined
    确认文件是 .xlsx,且工作表名是 cases。Apple Numbers 请导出为 Excel

  • 状态码通过,但业务失败
    在 Excel 填上:expect_jsonpath=$.errorCodeexpect_value=0,就能断业务成功。

  • 看不到 print 输出
    -s--capture=tee-sys
    pytest -v -s --capture=tee-sys --html=report.html --self-contained-html

  • 需要严格:提取不到也判失败
    _extract_to_ctx 里加 assert matches, ...(上面代码注释已给出)。

  • 代理/证书问题
    Windows:setx HTTPS_PROXY "http://proxy:port";macOS:export HTTPS_PROXY="http://proxy:port"


下一步:扩展断言/提取与并行执行

  • 更多断言:当前支持 1 条 JSONPath 断言;若需多断言,可扩展表头与代码。
  • 链路依赖:用 extract 从上一条响应取值(如 aid),下一条在 path/body/expect_value{aid}
  • 并行pytest -n auto;大量用例时显著提速。
  • CI 定时跑:把同一套命令放进 GitHub Actions、Jenkins 等。

到这里,你就能在 Windows 或 macOS + VS Code 下,从 0 到 1 跑起“Excel 数据驱动的接口自动化”,并把结果产出为一个可追溯的 HTML 报告