侧边栏壁纸
博主头像
一朵云的博客博主等级

拥抱生活,向阳而生。

  • 累计撰写 107 篇文章
  • 累计创建 28 个标签
  • 累计收到 7 条评论

目 录CONTENT

文章目录

Python -- 基于 Jinja2 让测试用例“动”起来

一朵云
2024-02-14 / 0 评论 / 0 点赞 / 115 阅读 / 12103 字

Python -- 基于 Jinja2 让测试用例“动”起来

简介:

Jinja2 是一个功能强大且灵活的 Python 模板引擎,用于动态生成文本内容,广泛应用于 Web 开发、配置文件生成、邮件模板、自动化报告等场景。

它允许开发者将 代码逻辑内容展示 分离,通过在模板中嵌入变量和控制结构,将数据动态渲染为最终文本。
​ 

目的:

Jinja2(使用 ${} 作为变量分隔符)集成到 pytest 中,实现测试用例中的动态变量渲染。

​ 

准备:

安装依赖库

pip install pytest jinja2 jsonpath-ng

​ 

逻辑实现:

1、创建 Jinja2 自定义环境

config文件中新建 templates.py 文件,修改变量标识符。(用习惯了jmeter的变量符)

from jinja2 import Environment, BaseLoader


class DollarBraceEnvironment(Environment):
    """
    Jinja2 环境,使用 ${var} 而非 {{ var }}
    """
    def __init__(self, **options):
        super().__init__(
            variable_start_string="${",
            variable_end_string="}",
            autoescape=False,
            loader=BaseLoader(),  # 允许直接 from_string
            **options
        )


# 全局实例
jinja_env = DollarBraceEnvironment()

​ 

2、创建渲染器

config文件夹下新建 renderer.py 文件,递归处理yaml中的变量。

from .templates import jinja_env
from typing import Any, Dict, List, Union


def render(value: Any, context: Dict[str, Any]) -> Any:
    """
    递归渲染任意结构中的 ${} 变量
    支持 str, dict, list, nested
    """
    if isinstance(value, str):
        try:
            template = jinja_env.from_string(value)
            return template.render(**context)
        except Exception as e:
            print(f"[Render Error] '{value}' -> {e}")
            return value

    elif isinstance(value, dict):
        return {k: render(v, context) for k, v in value.items()}
    elif isinstance(value, list):
        return [render(item, context) for item in value]
    else:
        return value

​ 

3、在 conftest.py 中管理上下文

根目录下新建 conftest.py 文件,使用 fixture 装饰器来注入。

import pytest

from config.renderer import render


@pytest.fixture(scope="session")
def context():
    return {
        "base_url": "http://127.0.0.1:8080"
    }


@pytest.fixture
def render_context(context):
    """
    返回一个渲染函数,支持传入额外上下文(如 base_url)
    """
    def _render(value, extra_context=None):
        full_context = {**context}
        if extra_context:
            full_context.update(extra_context)
        return render(value, full_context)
    return _render

​ 

4、核心逻辑

从yaml中读取用例;先将data用例参数和request下的字段拿去渲染(如果存在 ${} 声明的变量);基于httpx发起请求;将用例的response下的字段拿去渲染(如果存在 ${} 声明的变量);对比实际响应结果,判断用例是否通过;提取用例中的response下的extract,并通过jsonpath_ng提取value,存到local_context这个上下文字典变量中,下个步骤的用例就可以调用它了。


from pathlib import Path

import httpx
import pytest
import yaml
from jsonpath_ng import parse


local_context = {}


def load_test_cases():
    """
    读取 YAML 测试数据
    :return:
    """
    # 获取当前文件所在目录
    current_dir = Path(__file__).parent.parent.parent
    yml_path = current_dir / "data/login.yml"
    with open(yml_path, 'r', encoding='utf-8') as file:
        data = yaml.safe_load(file)
        return data['test_cases']


def extract_by_jsonpath(data: dict, jsonpath: str):
    """
    从 data 这个字典中,根据 jsonpath 表达式提取值
    :param data:
    :param jsonpath:
    :return:
    """
    try:
        expr = parse(jsonpath)
        matches = [match.value for match in expr.find(data)]
        return matches[0] if matches else None
    except:
        return None


def is_equal(a, b):
    """增强比较:处理 str vs int 的数字"""
    if isinstance(a, str) and isinstance(b, int):
        return a.isdigit() and int(a) == b
    if isinstance(a, int) and isinstance(b, str):
        return b.isdigit() and a == int(b)
    return a == b


@pytest.mark.parametrize("test_case", load_test_cases())
def test_login_flow(test_case, render_context, context):
    case_id = test_case["id"]
    description = test_case["description"]
    data_list = test_case.get("data", [{}])
    steps = test_case["steps"]

    # 遍历yaml中的data测试数据组
    for idx, data_item in enumerate(data_list):
        print(f"\n [{case_id}] {description} | 数据 #{idx+1}")

        # 拷贝每组数据作为独立上下文,包含 username, password, expected_code
        global local_context
        local_context.update(data_item)

        for step in steps:
            step_id = step["id"]
            step_name = step["name"]
            req = step["request"]
            expected_resp = step["response"]  # 期望的响应字段

            print(f"  步骤:{step_id}_{step_name}")

            # --- 1. 渲染请求 ---
            rendered_req = render_context(req, extra_context=local_context)

            # --- 2. 发送请求 ---
            url = rendered_req["url"]
            method = rendered_req["method"].upper()
            json_body = rendered_req.get("json")
            headers = rendered_req.get("headers")

            base_url = context["base_url"]  # 或从 context 获取
            full_url = f"{base_url}{url}"

            # 在发送请求前加这几行
            print("发送请求前的上下文:", local_context)
            print("即将使用的 headers:", headers)
            try:
                resp = httpx.request(method, full_url, json=json_body, headers=headers, timeout=10)
                actual_json = resp.json()
            except Exception as e:
                pytest.fail(f"请求失败: {e}")

            # 构建实际响应数据
            actual_response = {
                "status": resp.status_code,
                "code": actual_json.get("code"),
                "msg": actual_json.get("msg"),
                "data": actual_json.get("data")
            }

            # --- 3. 渲染期望值(支持 ${expected_code})---
            rendered_expected = render_context(expected_resp, extra_context=local_context)

            # 移除 extract 字段,只保留用于对比的字段
            compare_fields = {k: v for k, v in rendered_expected.items() if k != "extract"}

            # --- 4. 自动对比每个字段 ---
            for field, expected_value in compare_fields.items():
                actual_value = actual_response.get(field)
                if not is_equal(actual_value, expected_value):
                    pytest.fail(
                        f"\n"
                        f"字段 '{field}' 不匹配\n"
                        f"期望: {expected_value}\n"
                        f"实际: {actual_value}"
                    )
                print(f"  {field} = {expected_value}")

            # --- 5. 提取变量 ---
            extract_config = rendered_expected.get("extract", {})
            for var_name, jsonpath in extract_config.items():
                value = extract_by_jsonpath(actual_json, jsonpath)
                if value is None:
                    pytest.fail(f"提取失败: {var_name} <- {jsonpath}")
                local_context[var_name] = value  # 写入上下文,后续步骤可用
                print(f"  提取 {var_name} = {value}")

            print(f"  步骤通过: {step_name}")


​ 

​ 

示例:

1、实现“1个用例N组参数”

test_cases:
  - id: "login_smoke"
    description: "账密登录验证"
    data:
      - username: "xiaoming"
        password: "123456"
        expected_code: 200
      - username: "xiaohong"
        password: "123456"
        expected_code: 200

    steps:
      - id: "step1"
        name: "执行登录"
        request:
          url: "/login2"
          method: POST
          json:
            username: "${username}"
            password: "${password}"
        response:
          status: 200
          code: "${expected_code}"
          extract:
            token: $.data.token
            userId: $.data.userId

  - id: "login_password_error"
    description: "异常验证 -- 密码错误登录失败"
    data:
      - username: "xiaoming"
        password: "wrong_password"
        expected_code: 401

    steps:
      - id: "step1"
        name: "执行登录"
        request:
          url: "/login2"
          method: POST
          json:
            username: "${username}"
            password: "${password}"
        response:
          status: 200
          code: "${expected_code}"

正常读取动态参数并请求,显示两个用例均通过测试。

image-cvrx.png

2、实现“登录+获取用户信息”接口联动。

两个用例之间进行token参数关联

test_cases:
  - id: "login_smoke"
    description: "账密登录验证"

    steps:
      - id: "step1"
        name: "执行登录"
        request:
          url: "/login2"
          method: POST
          json:
            username: "xiaoming"
            password: "123456"
        response:
          status: 200
          code: 200
          extract:
            token: $.data.token
            userId: $.data.userId

  - id: "getUserInfo_smoke"
    description: "获取刚才登录的用户信息"

    steps:
      - id: "step1"
        name: "获取用户登录信息"
        request:
          url: "/getUserInfo"
          method: GET
          headers:
            Authorization: "Bearer ${token}"
        response:
          status: 200
          code: 200

或者,一个用例的不同步骤中token参数关联

test_cases:
  - id: "login_smoke"
    description: "账密登录验证"

    steps:
      - id: "step1"
        name: "执行登录"
        request:
          url: "/login2"
          method: POST
          json:
            username: "xiaoming"
            password: "123456"
        response:
          status: 200
          code: 200
          extract:
            token: $.data.token
            userId: $.data.userId

      - id: "step2"
        name: "获取用户登录信息"
        request:
          url: "/getUserInfo"
          method: GET
          headers:
            Authorization: "Bearer ${token}"
        response:
          status: 200
          code: 200
0

评论区