Python BDD 测试实战——behave 框架、Gherkin 语法、业务可读测试
Python BDD 测试实战——behave 框架、Gherkin 语法、业务可读测试
适读人群:希望让非技术人员参与测试验收的团队 / 关注业务可读性的工程师 | 阅读时长:约 15 分钟 | 核心价值:用 BDD 方式让产品经理和开发工程师共同拥有测试用例
产品经理看不懂测试报告
这是让我决定引入 BDD 的直接原因。
有一次项目复盘,我把 pytest 测试报告给产品经理小李看,她翻了翻,说:"我看不懂,test_create_order_with_invalid_coupon_code_should_return_422 是什么意思?"
我解释了半天。
然后她说了一句话让我印象很深:"你们的测试结果我需要自己去理解,那不如你直接告诉我哪些功能测了、哪些没测,哪些通过了、哪些失败了。"
这就是 BDD(行为驱动开发)想解决的问题:让测试用例用自然语言描述,所有人都能读懂,包括产品经理、业务运营、测试。
BDD 的核心:Gherkin 语法
Gherkin 是 BDD 使用的领域特定语言,用 Given / When / Then 描述测试场景:
# features/order.feature
Feature: 订单管理
作为一个注册用户
我希望能够下单购买商品
以便完成购物流程
Background:
Given 系统中存在商品 "蓝牙耳机" 价格 299 元 库存 10 件
And 用户 "buyer@test.com" 已登录系统
Scenario: 正常下单
When 用户将 "蓝牙耳机" 添加到购物车
And 用户填写收货地址为 "北京市朝阳区某某路100号"
And 用户选择 "微信支付" 作为支付方式
And 用户点击确认下单
Then 系统应该创建一个订单
And 订单状态应该是 "待付款"
And 购物车应该清空
Scenario: 库存不足时下单失败
Given 商品 "蓝牙耳机" 的库存为 0
When 用户尝试购买 "蓝牙耳机"
Then 系统应该提示 "库存不足,无法购买"
And 不应该创建订单
Scenario Outline: 优惠券折扣计算
Given 订单金额为 <original_amount> 元
And 用户持有折扣率 <discount_rate> 的优惠券
When 用户在结算时使用优惠券
Then 实际支付金额应该是 <final_amount> 元
Examples:
| original_amount | discount_rate | final_amount |
| 100 | 0.1 | 90 |
| 200 | 0.2 | 160 |
| 500 | 0.5 | 250 |这份 .feature 文件,产品经理能直接阅读和修改。
安装和配置 behave
pip install behave目录结构:
my_project/
├── features/
│ ├── order.feature # 功能描述文件(Gherkin)
│ ├── user_auth.feature
│ ├── steps/
│ │ ├── order_steps.py # 步骤实现
│ │ └── user_steps.py
│ └── environment.py # 全局 setup/teardown
└── myapp/
└── ...steps 实现
# features/steps/order_steps.py
from behave import given, when, then, step
from myapp.database import get_test_db
from myapp.services import ProductService, OrderService, CartService
from myapp.models import Product, User
@given('系统中存在商品 "{product_name}" 价格 {price:d} 元 库存 {stock:d} 件')
def step_product_exists(context, product_name, price, stock):
context.product = context.product_service.create_product(
name=product_name,
price=price,
stock=stock
)
@given('商品 "{product_name}" 的库存为 {stock:d}')
def step_set_product_stock(context, product_name, stock):
context.product_service.update_stock(context.product.id, stock)
@given('用户 "{email}" 已登录系统')
def step_user_logged_in(context, email):
context.current_user = context.user_service.get_or_create_user(email)
context.api_client.login(email, "Test@123456")
@when('用户将 "{product_name}" 添加到购物车')
def step_add_to_cart(context, product_name):
product = context.product_service.get_by_name(product_name)
context.cart_response = context.api_client.post(
"/api/cart/items",
json={"product_id": product.id, "quantity": 1}
)
assert context.cart_response.status_code == 200, \
f"添加到购物车失败: {context.cart_response.text}"
@when('用户填写收货地址为 "{address}"')
def step_fill_address(context, address):
context.checkout_data = getattr(context, "checkout_data", {})
context.checkout_data["address"] = address
@when('用户选择 "{payment_method}" 作为支付方式')
def step_select_payment(context, payment_method):
context.checkout_data["payment_method"] = payment_method
@when('用户点击确认下单')
def step_place_order(context):
context.order_response = context.api_client.post(
"/api/orders",
json=context.checkout_data
)
@then('系统应该创建一个订单')
def step_order_created(context):
assert context.order_response.status_code == 201, \
f"下单失败: {context.order_response.status_code} {context.order_response.text}"
context.created_order = context.order_response.json()
assert "id" in context.created_order, "响应中没有订单 ID"
@then('订单状态应该是 "{expected_status}"')
def step_verify_order_status(context, expected_status):
actual_status = context.created_order.get("status")
assert actual_status == expected_status, \
f"订单状态错误: 期望 {expected_status},实际 {actual_status}"
@then('购物车应该清空')
def step_cart_empty(context):
cart_response = context.api_client.get("/api/cart")
assert cart_response.status_code == 200
cart_items = cart_response.json().get("items", [])
assert len(cart_items) == 0, f"购物车未清空,还有 {len(cart_items)} 件商品"
@then('系统应该提示 "{expected_message}"')
def step_verify_error_message(context, expected_message):
response = getattr(context, "order_response", None) or \
getattr(context, "cart_response", None)
body = response.json()
message = body.get("message") or body.get("detail", "")
assert expected_message in message, \
f"提示信息不匹配: 期望包含 '{expected_message}',实际 '{message}'"
@then('不应该创建订单')
def step_no_order_created(context):
# 验证下单失败(4xx 错误)
response = getattr(context, "order_response", None)
if response:
assert response.status_code >= 400, \
f"期望下单失败,但返回了 {response.status_code}"
# Scenario Outline 步骤
@given('订单金额为 {amount:d} 元')
def step_set_order_amount(context, amount):
context.order_amount = amount
@given('用户持有折扣率 {rate:f} 的优惠券')
def step_set_coupon(context, rate):
context.coupon = context.coupon_service.create_coupon(discount_rate=rate)
@when('用户在结算时使用优惠券')
def step_apply_coupon(context):
context.checkout_result = context.order_service.calculate_checkout(
amount=context.order_amount,
coupon_id=context.coupon.id
)
@then('实际支付金额应该是 {expected:d} 元')
def step_verify_final_amount(context, expected):
actual = context.checkout_result.final_amount
assert actual == expected, f"金额计算错误: 期望 {expected},实际 {actual}"environment.py:全局配置
# features/environment.py
from myapp.database import create_test_database, drop_test_database
from myapp.services import ProductService, UserService, OrderService, CouponService
from clients.api_client import APITestClient
import os
def before_all(context):
"""整个测试套件开始前"""
context.db = create_test_database()
context.base_url = os.environ.get("TEST_BASE_URL", "http://localhost:8080")
def after_all(context):
"""整个测试套件结束后"""
drop_test_database(context.db)
def before_scenario(context, scenario):
"""每个场景开始前"""
# 为每个场景提供干净的服务实例
context.product_service = ProductService(context.db)
context.user_service = UserService(context.db)
context.order_service = OrderService(context.db)
context.coupon_service = CouponService(context.db)
context.api_client = APITestClient(base_url=context.base_url)
context.checkout_data = {}
def after_scenario(context, scenario):
"""每个场景结束后"""
# 清理场景数据
context.db.rollback_test_data()
if scenario.status == "failed":
print(f"\n场景失败: {scenario.name}")
# 可以在这里添加截图、日志等
def before_tag(context, tag):
"""处理特殊标签"""
if tag == "requires_payment_gateway":
if not os.environ.get("PAYMENT_API_KEY"):
context.scenario.skip("需要 PAYMENT_API_KEY 环境变量")运行 behave
# 运行所有 feature 文件
behave
# 运行特定 feature 文件
behave features/order.feature
# 只运行特定 tag 的场景
behave --tags=@smoke
# 生成 JSON 报告
behave --format json --outfile reports/behave-report.json
# 生成 Allure 报告
pip install behave-allure-formatter
behave --format behave_allure_formatter.formatter:AllureFormatter --outfile allure-results/踩坑实录
坑一:步骤匹配不上
现象: 运行时报 Step 'XXX' is not implemented 或 Undefined Step。
常见原因和解法:
# 错误:步骤文本不完全匹配(大小写、标点差异)
# feature 文件:用户将 "蓝牙耳机" 添加到购物车
# 步骤定义:@when('用户将"{product_name}"添加到购物车') ← 引号前后没空格
# 正确:
@when('用户将 "{product_name}" 添加到购物车') # 引号前后有空格
# 检查步骤是否被正确发现
behave --dry-run # 不执行步骤,只检查匹配坑二:context 对象在步骤间传递数据时缺失
现象: 在一个步骤中设置了 context.order_response,在下一个步骤中访问时是 None 或 AttributeError。
原因: 步骤执行顺序和 context 生命周期没有对齐,或者步骤抛出了异常但被吞掉了。
解法: 用 getattr(context, "key", None) 做容错访问,并在 before_scenario 中初始化所有 context 变量:
def before_scenario(context, scenario):
# 明确初始化所有会用到的 context 属性
context.order_response = None
context.cart_response = None
context.created_order = None
context.checkout_data = {}坑三:Scenario Outline 数据类型解析错误
现象: Gherkin 中的数字被解析为字符串,导致断言失败。
解法: 在步骤定义中用类型转换注解:
# 自动类型转换
@then('实际支付金额应该是 {expected:d} 元') # :d 表示整数
@given('商品价格为 {price:f} 元') # :f 表示浮点数
@given('用户名为 "{name}"') # 引号内的字符串
# 或者手动转换
@then('实际支付金额应该是 {expected} 元')
def step_verify(context, expected):
assert context.result.amount == float(expected)BDD 的适用场景
BDD 不是万能的,适合以下场景:
- 业务规则复杂:折扣计算、权限控制、状态机流转——Gherkin 能清晰描述规则
- 需要非技术人员参与验收:产品经理、业务运营可以直接读懂 feature 文件
- 需求变更频繁:修改 feature 文件比修改代码更直观,改动更小
不适合 BDD 的场景:
- 纯技术性测试(单元测试、性能测试)
- 底层基础设施测试(数据库、缓存)
- 频繁变化的 UI 细节测试
