ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

behave框架实战:用Python BDD让测试聚焦业务行为

behave框架实战:用Python BDD让测试聚焦业务行为

很多团队在测试这块越做越重,但越重越说不清楚业务到底要什么。如果你也有这种感觉,behave 这个 Python BDD 测试框架值得花 10 分钟试一下。它把测试从“验证代码对不对”提升到“验证业务行为对不对”的层面,用接近自然语言的 Gherkin 语法描述需求,再通过 step 文件把描述和代码串起来。这篇文章我不会只贴官方文档,而是从安装、目录结构、Gherkin 写法、配置、踩坑点到完整案例,一条线讲清楚,适合刚接触 BDD 或者想在团队里推行为驱动开发的测试工程师和 Python 后端开发。

1. BDD 思路与框架选型:为什么偏偏是 behave

1.1 从“测试代码”到“描述行为”

传统的单元测试或者接口测试,本质上还是在写代码:断言函数返回值、检查状态码、比对数据库记录。这套东西对开发来说是顺手的,但对产品、测试、开发三方坐在一起评审需求的时候,就尴尬了——产品看不懂代码,开发觉得用例琐碎,测试夹在中间反复翻译需求。BDD 的出发点就是打破这个局面,核心是把需求描述成“行为”,用固定的 Given-When-Then 三段式来表达:

  • Given 描述前置条件
  • When 描述用户操作或者触发事件
  • Then 描述预期结果

比如一个登录场景可以这样写:

场景:正确的账号密码可以登录成功 假如 我访问登录页面 当 我输入用户名“admin”和密码“123456” 且 我点击登录按钮 那么 我应该看到欢迎信息

这段文字产品看得懂,开发看得懂,测试也能照着写自动化。这就是 BDD 最有价值的地方:它让用例变成团队之间沟通的“共同语言”,而不是测试同学自嗨的一堆断言。behave 在 Python 生态里就是干这件事的:把上面的 Gherkin 文本解析成可执行的步骤,然后匹配到 Python 函数上去跑。

1.2 为什么选 behave 而不是 pytest-bdd 或 lettuce

Python 社区做 BDD 的框架其实不止 behave 一个,我简单对比一下当时的选型考量。

框架底层Gherkin 支持维护活跃度适合场景
behave纯 Python完整支持高独立 BDD 框架,结构清晰,团队推广友好
pytest-bdd基于 pytest支持,但写法偏 pytest较高已有 pytest 体系、想少引入新概念的场景
lettuce纯 Python老牌,但多年停滞低基本不推荐,生态老化

behave 的优势在于它的设计跟 Gherkin 语言贴合得最紧密,目录结构也是约定式的,天生就是为“业务可读”服务的。pytest-bdd 虽然能复用 pytest 大量插件,但写起来总有一种“在 pytest 里硬塞 Gherkin”的感觉,对非技术背景的同事不够友好。behave 的 step 文件写法更接近定义业务动词,配合 behave.ini 和 environment.py 的钩子机制,能非常干净地组织测试前置、后置和数据清理逻辑。所以如果你是要正儿八经推 BDD 流程,我觉得 behave 是首选。

2. 十分钟上手第一关:环境准备与安装

2.1 创建虚拟环境,避免污染全局 Python

这一步看起来基础,但很多人图省事直接在全局环境里装,后面依赖冲突了才后悔。Python 项目一般都要建独立虚拟环境,behave 也不例外。Python 3.3 以上可以用内置的 venv 模块:

mkdir behave_demo && cd behave_demo python3 -m venv venv

然后激活环境:

# Linux/macOS source venv/bin/activate # Windows venv\Scripts\activate

激活后命令行提示符前面会出现(venv),说明已经进入独立环境。这时再装任何包都不会污染系统 Python。对于团队协作,还可以在项目根目录放一个requirements.txt或pyproject.toml,把依赖锁住,确保大家环境一致。

2.2 安装 behave 与验证

激活虚拟环境后直接 pip 安装:

pip install behave

装完后验证一下版本:

behave --version

正常情况下会输出版本号。如果提示找不到命令,大概率是虚拟环境没激活或者 PATH 配置有问题,检查一下当前用的是不是venv/bin/python和venv/bin/behave。另外我想提醒一句,behave 对 Python 版本要求比较友好,3.8 以上基本都能跑,但建议用 3.10 或更新的版本,旧版本在某些依赖解析上会踩到莫名其妙的坑。

这里有个小技巧:如果你在团队里用的是requirements.txt管理依赖,建议直接写入behave>=1.2.6而不是写死版本,这样既能拿到安全补丁,又不会像==1.2.6那样锁定太死,导致升级其他依赖时冲突。

2.3 behave 命令基础用法

behave 的 CLI 命令其实很简单,最常用的就这几个:

behave # 运行全部测试 behave features/login.feature # 只运行某个 feature 文件 behave -t @smoke # 运行带 smoke 标签的场景 behave -n "登录成功" # 按场景名称模糊匹配运行 behave -f plain # 输出格式改为纯文本

-t标签机制特别实用,后面我会单独讲。先记住behave不加参数就是跑当前目录下所有 feature,默认的测试发现规则是找features/目录。

3. 项目结构与 Gherkin 语法拆解

3.1 behave 的标准目录结构

behave 对目录结构有约定,新手照着搭就行:

behave_demo/ ├── features/ │ ├── login.feature │ ├── steps/ │ │ └── login_steps.py │ └── environment.py ├── venv/ └── behave.ini
  • features/:放.feature文件,也就是业务行为描述。
  • features/steps/:放 step 实现代码,一个文件可以对应多个 feature,也可以按模块拆分。
  • features/environment.py:钩子文件,定义测试前、后置动作。

environment.py不是必须的,但实际项目基本都会用到,比如在跑所有用例之前初始化数据库连接、造测试数据,跑完再清理,都可以放到这里。

3.2 Gherkin 核心语法:Feature、Scenario、Given/When/Then

我直接用一个登录例子把语法串起来。

# features/login.feature 功能:用户登录 作为一个注册用户 我希望能够安全地登录系统 以便使用我的个人信息 背景: 假如 测试环境中存在用户“admin” 场景: 正确的账号密码可以登录成功 假如 我访问登录页面 当 我输入用户名“admin”和密码“123456” 且 我点击登录按钮 那么 我应该看到欢迎信息 场景: 错误的密码会提示失败 假如 我访问登录页面 当 我输入用户名“admin”和密码“wrong_pass” 且 我点击登录按钮 那么 我应该看到“用户名或密码错误”提示
  • 功能(Feature):最高层级,描述测试的业务模块。
  • 背景(Background):每个场景执行前都会先跑一遍,适合放公共前置条件。
  • 场景(Scenario):一条完整的行为路径。
  • 假如(Given)/当(When)/那么(Then):对应前置、操作、断言。
  • 且(And)/但是(But):在上一条步骤基础上追加条件。

Gherkin 对中英文关键词都支持,这也是为什么我们团队写脚本直接统一用中文,产品评审的时候也能直接看 feature 文件,不用技术的人单独翻译一遍。behave 默认就能识别假如/当/那么/且/但是这些中文关键词,不用额外配 locale。

3.3 场景大纲(Scenario Outline)与数据表格

很多时候同一个操作要验证多组数据,如果每个场景都复制粘贴就太蠢了。Gherkin 提供了场景大纲:

场景大纲: 多组账号密码登录验证 假如 我访问登录页面 当 我输入用户名“<username>”和密码“<password>” 且 我点击登录按钮 那么 我应该看到“<expected>”提示 例子: | username | password | expected | | admin | 123456 | 欢迎信息 | | test | wrong_pass | 用户名或密码错误提示 | | locked | 123456 | 账号已被锁定提示 |

场景大纲配合例子(Examples)表格,可以一次性生成多个场景来执行,这在接口参数化的场景中特别合适。上面三条例子数据,实际运行时会当作三个独立的场景跑,每一条失败都能单独看到报告。

4. Step 定义写法与匹配机制

4.1 如何把 Gherkin 步骤映射到 Python 函数

feature 文件只是“期望”,真正干活的是 step 文件里的 Python 函数。behave 通过正则匹配把每一步关联到对应函数。继续以登录为例,对应的steps/login_steps.py大概是这样的:

from behave import given, when, then @given('我访问登录页面') def step_visit_login_page(context): context.page = "login_page" print("访问登录页面") @given('测试环境中存在用户"{username}"') def step_create_user(context, username): context.users = {username: "123456"} print(f"准备用户数据: {username}") @when('我输入用户名"{username}"和密码"{password}"') def step_input_credentials(context, username, password): context.input_username = username context.input_password = password print(f"输入账号密码: {username}/{password}") @when('我点击登录按钮') def step_click_login(context): context.login_success = (context.input_username == "admin" and context.input_password == "123456") print("点击登录按钮") @then('我应该看到欢迎信息') def step_assert_welcome(context): assert context.login_success, "未看到欢迎信息,登录失败" print("断言:看到欢迎信息") @then('我应该看到"{message}"提示') def step_assert_message(context, message): if message == "用户名或密码错误提示": assert not context.login_success, "登录居然成功了,不符合预期" print(f"断言:看到提示 {message}")

注意三点:

第一,装饰器里的字符串就是 Gherkin 步骤去掉关键词后的部分,@when('我输入用户名"{username}"和密码"{password}"')表示只要 feature 里以当开头并且文本匹配这个模式,就会调用这个函数。

第二,context是 behave 的上下文对象,数据可以在步骤之间通过 context 传递,比如上面把login_success结果存进去,后面的 Then 步骤再去取。但要注意 context 的作用域默认是场景级的,每个 scenario 开始时会重置 context,所以在 Given 里设置的数据不能跨场景共享。

第三,加{username}这种占位符的位置,函数必须接收对应参数。behave 内部使用parse库做模板匹配,不是纯正则,但体验上比.*友好很多。如果你需要更复杂的匹配,装饰器里也可以直接写正则表达式,behave 会优先尝试 parse 模板,失败再当普通正则处理。

4.2 一个步骤对多个函数:别踩重复匹配的坑

比较隐蔽的一个问题是:如果两个 step 函数的装饰器文本都匹配同一个 Gherkin 步骤,behave 会报警告并默认使用第一个匹配到的函数。比如:

@when('我输入用户名"{username}"和密码"{password}"') def step_input_credentials(context, username, password): ... @when('我输入用户名"admin"和密码"{password}"') def step_admin_credentials(context, password): ...

第一个函数能匹配admin的用例,第二个也能匹配,按顺序先注册谁就先用谁。这种模糊匹配很容易造成隐藏 bug,所以写 step 定义的时候一定要让模式之间明确区分,要么用更具体的场景描述,要么靠标签或不同 feature 文件隔离。我实际遇到过因为两个步骤描述太相似,导致某个用例一直跑的是旧逻辑,排查了半个多小时才发现是匹配错乱。

4.3 正则匹配进阶与参数类型

有的场景里参数不只是字符串,还可能是数字或列表。behave 的 parse 模板支持类型转换,比如:

@given('我有{count:d}个商品在购物车中') def step_items_in_cart(context, count): context.cart_count = count

装饰器里{count:d}会把文本自动转成整数,函数里拿到的count就是 int,不用自己 int() 一下。支持的类型包括d(十进制整数)、f(浮点数)、s(字符串)等,跟 Python 的格式说明差不多。这在边界值测试里特别实用,写出来直观很多。

5. 配置管理与 environment.py 钩子

5.1 behave.ini 配置文件

behave 支持通过behave.ini或setup.cfg里的[behave]区块做配置。我们项目里常用的一份配置长这样:

[behave] paths = features format = pretty color = true tags = ~@skip show_skipped = false log_level = INFO
  • paths:指定默认搜索路径,不写默认是features。
  • format:输出格式,pretty是最友好的带颜色版本,CI 里则建议用plain或junit。
  • tags:默认过滤标签,比如上面表示默认跳过带@skip的用例。
  • show_skipped:是否显示跳过的用例,一般本地调试可以打开,CI 里关掉减少噪音。

配置文件的好处是让团队所有成员用同一套行为运行规则,而不是靠各自的命令行参数传一堆标志。我见过有人每个命令都敲一长串参数,换个人就漏了某一个,统一放进 behave.ini 之后就再没出现过这种问题。

5.2 标签机制:灵活控制测试范围

标签是 feature 或 scenario 上面的形如@smoke、@slow、@login的注解,运行时通过-t来控制。可以这样标记:

@smoke 场景: 正确的账号密码可以登录成功

然后运行:

behave -t @smoke # 只跑冒烟用例 behave -t ~@slow # 不跑标注为 slow 的用例 behave -t @login -t @smoke # 同时带两个标签的用例(AND 关系) behave -t @login,@smoke # 带任一标签即可(OR 关系)

这个机制尤其在大型项目里非常重要。全量回归跑一小时,但冒烟用例可能只要两分钟。CI 里不同环节跑不同标签组合,本地开发也可以只跑被改动功能相关的用例,效率差别很大。

5.3 environment.py 的钩子函数

environment.py是 behave 的“生命周期控制器”,支持多个钩子。最常用的几个:

def before_all(context): # 全部用例开始前执行一次,比如初始化测试数据库连接 context.db = create_test_db() def before_feature(context, feature): # 每个 feature 前执行,可以按 feature 名称做数据准备 pass def before_scenario(context, scenario): # 每个 scenario 前执行,重置测试环境 context.user_data = {} def after_scenario(context, scenario): # 每个 scenario 后执行,清理测试数据 cleanup_orders(context) def after_all(context): # 全部用例结束后执行,关闭连接,清理进程 context.db.close()

这里的context在不同钩子阶段作用域不一样。before_all里设置的context.db理论上在场景里也能访问,但注意场景级别的 context 在场景开始时会重置,你需要在before_scenario里把需要的对象重新挂上去,比如context.db = context.db这种多此一举的写法其实是不需要的,因为上下文对象本身会保留全局级属性?这里容易混淆,我实际测试下来,behave 的 context 在场景开始时保留before_all设置的属性,而before_scenario可以在场景前额外添加。

再说一个实用经验:如果用例里涉及中间表数据、文件输出、外部接口 mock,顺序一定要设计好,before_all 做重活,before_scenario 做轻量重置,after_scenario 做数据清理。这样即使一条用例失败,也不会污染下一条。

6. 完整实操:从 feature 到 step 再到运行

6.1 需求场景设计

我用一个“购物车结算”的行为来做完整演示,比登录场景更有业务感。需求是:用户往购物车加商品,进入结算页后能看到商品清单和总价,再检查优惠券逻辑。这个案例能涵盖背景、场景大纲、数据表格、数值参数、上下文传递等多个 BDD 特性。

6.2 编写购物车 feature 文件

# features/checkout.feature 功能: 购物车结算 作为一个顾客 我希望结算购物车时能看到正确的商品和总价 以便确认订单信息 背景: 假如 我的购物车已经清空 场景: 添加一件商品到购物车 当 我添加商品"苹果" 价格为 10 元 且 我添加商品"香蕉" 价格为 15 元 那么 购物车中应该有 2 件商品 且 商品总价应该是 25 元 场景大纲: 多件商品组合结算 当 我添加商品"<item1>" 价格为 <price1> 元 且 我添加商品"<item2>" 价格为 <price2> 元 那么 购物车中应该有 <count> 件商品 且 商品总价应该是 <total> 元 例子: | item1 | price1 | item2 | price2 | count | total | | 苹果 | 10 | 香蕉 | 15 | 2 | 25 | | 键盘 | 99 | 鼠标 | 30 | 2 | 129 |

6.3 编写 step 定义与 context 数据传递

对应的steps/checkout_steps.py:

from behave import given, when, then @given('我的购物车已经清空') def step_empty_cart(context): context.cart = [] # 每个场景重新初始化 context.total = 0 @when('我添加商品"{item}" 价格为 {price:d} 元') def step_add_item(context, item, price): context.cart.append((item, price)) context.total += price print(f"添加商品 {item},价格 {price},当前总价 {context.total}") @then('购物车中应该有 {count:d} 件商品') def step_check_count(context, count): assert len(context.cart) == count, f"购物车数量是 {len(context.cart)},预期 {count}" @then('商品总价应该是 {total:d} 元') def step_check_total(context, total): assert context.total == total, f"总价是 {context.total},预期 {total}"

这里有几个关键点值得展开:

第一,@given('我的购物车已经清空')写在背景里,每个场景执行前都会自动先跑,所以购物车状态不会残留。

第二,{price:d}这种带类型转换的写法让 step 函数直接拿到 int,不用费劲做类型转换,代码干净得多。

第三,context.total在when步骤里累加,然后在then断言里读取,这个传递链非常直观,体现的就是 BDD 的叙事式风格。

6.4 运行结果与三种常见输出

在项目根目录执行:

behave

默认pretty格式输出大概长这样:

功能: 购物车结算 # features/checkout.feature:1 作为一个顾客 我希望结算购物车时能看到正确的商品和总价 以便确认订单信息 背景: 我的购物车已经清空 # features/checkout.feature:6 假如 我的购物车已经清空 ... ok 场景: 添加一件商品到购物车 # features/checkout.feature:8 当 我添加商品"苹果" 价格为 10 元 ... ok 且 我添加商品"香蕉" 价格为 15 元 ... ok 那么 购物车中应该有 2 件商品 ... ok 且 商品总价应该是 25 元 ... ok 1 个场景通过,0 个场景失败,0 个步骤未实现

如果断言失败,会显示失败的步骤、期望值和实际值,并且打印 traceback。如果步骤没找到,会出现:

? 当 我点击结账按钮 ... undefined

注意undefined状态,behave 会在底部提示“You can implement step definitions for undefined steps with these snippets”,同时给出一段自动生成的装饰器和函数模板。这个功能很贴心,你可以直接把提示里的模板复制到 step 文件里改,不用记住匹配格式。

7. 常见问题与避坑清单

7.1 步骤未定义或匹配不到

这是新手遇到最多的问题。如果运行时看到大量undefined,第一步看装饰器文本和 feature 里的步骤文本是否一致,注意中英文标点、空格、引号都要完全一致,比如中文引号和英文引号就很容易看走眼。第二步看目录结构,step 文件必须放在features/steps/下,文件名随意,但目录不能错。第三步看是否有重复匹配,behave 会有 warning,但不告诉你哪个被覆盖了,所以要自己排查。

我提供一个小技巧:如果 feature 步骤是当 我输入用户名"admin"和密码"123456",那直接在 step 文件里写:

@when('我输入用户名"admin"和密码"123456"') def step_login(context): ...

先跑通最死板的写法,再一步步加参数化,不要一上来就写一堆占位符。

7.2 context 数据在不同的作用域丢失

context的行为有几个容易混淆的点:before_all里设置的属性在场景里可以读,但场景本身不会自动清空所有属性,而是通过before_scenario前后的新旧 context 切换来实现隔离。实际建议是:所有跟业务相关的临时数据都在before_scenario里初始化,这样可以避免场景间数据污染。像商品数量、用户状态这类每次都要干净的数据,绝不要依赖before_all再来清理。

7.3 中文场景名称与-n参数模糊匹配

-n按场景名称匹配时,中文完全没有问题,但要注意-n是模糊匹配还是精确匹配?实测是包含关系,如果两个场景名有包含关系,可能匹配到多个。想要精确控制还是用标签-t更稳妥,标签的匹配逻辑更明确,而且支持排除。

7.4 并发执行与 CI 集成

behave 默认是串行执行,这是为了可复现性。如果用例多、耗时长,有两个思路:一是引入 pytest-xdist 类似的并行机制,但 behave 官方不直接支持,需要搭配behave-parallel这类项目,配置略重;二是从用例设计层面减少不必要的前后置等待,比如通过 mock 替代真实网络调用。我们团队在 CI 里的做法是把冒烟层单独用-t @smoke跑在合并前,全量回归跑在 nightly,既满足了反馈速度又保证了覆盖度。

CI 集成时建议把输出格式改成junit,方便对接常见的测试报告平台:

behave -f junit -o reports/

这样每个 feature、每个 scenario 的结果都是结构化数据,平台可以给出趋势图和失败归因。

8. 我的几个实测心得

behave 这个框架入门确实快,但真正落地到团队有几个坎要过。第一个坎是“业务语言标准化”,Feature 文件写得像散文还是像严谨的规格,直接影响后续维护成本。我建议团队里先定一套公共话术模板,比如把常用动词固定下来:登录、添加、创建、更新、删除、校验、跳转、展示、提示,不要让不同人写不同说法,否则最终就是一堆风格各异的场景文本,匹配、维护都麻烦。

第二个坎是 step 文件的组织。简单项目一个 steps 目录就完了,但项目一大,几百个 step 函数堆在一起,找起来痛苦。建议按业务模块拆分文件,比如login_steps.py、cart_steps.py、payment_steps.py,再在文件内按 Given / When / Then 分组,保持统一顺序,这样新人接手也不会懵。

最后想分享一个习惯:每写完一个 feature,先跑一遍确认“绿”,再改动代码,然后故意改一个地方让它“红”,确认断言真的能抓出问题。这一步看着多余,但实际很多人的断言写得太宽,全程都是绿的,代码改了也测不出来。只有验证过“能红”的用例才是真正有价值的用例。behave 的 pretty 输出会把每一步都打出来,我在本地调试时会开着颜色,看悬停的时候是哪一步先挂,几乎不用额外打日志就能定位问题。你先按这个流程跑通一个最小案例,再往项目里铺开,会觉得 BDD 这件事比想象中要自然得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进