Step Definitions en Python (behave)
Step Definitions en Python (behave)
behave es el runner BDD más usado en Python. Sintaxis distinta a Cucumber-JS pero conceptos idénticos.
Setup
pip install behave
# Recomendado adicionales
pip install requests jsonschema
features/environment.py (hooks globales — explicado debajo):
import requests
def before_all(context):
context.api_url = 'http://localhost:3000'
context.session = requests.Session()
def before_scenario(context, scenario):
context.cart = []
context.user = None
context.response = None
def after_scenario(context, scenario):
if scenario.status == 'failed':
print(f"Scenario falló: {scenario.name}")
Estructura recomendada
features/
├── auth/
│ ├── login.feature
│ └── signup.feature
├── cart/
│ ├── add-product.feature
│ └── checkout.feature
├── steps/
│ ├── auth.py
│ └── cart.py
└── environment.py
Los decorators: given/when/then
from behave import given, when, then
@given('una calculadora vacía')
def step_empty_calculator(context):
context.calc = Calculator()
@when('sumo {a:d} y {b:d}')
def step_add(context, a, b):
context.calc.add(a, b)
@then('el resultado es {expected:d}')
def step_result(context, expected):
assert context.calc.result == expected
Reglas:
- Todas las funciones reciben
contextcomo primer parámetro @given,@when,@thenson decorators- behave también tiene
@stepque matchea cualquiera de las tres (úsalo con cuidado)
Parsers de parámetros
behave soporta tres parsers, configurables con behave.use_step_matcher:
| Parser | Sintaxis | Ejemplo |
|---|---|---|
parse | Default, format-style | {name:type} |
cfparse | Cucumber-style | {name:Type} con custom types |
re | Regex puro | r'^...(\d+)...$' |
parse (default)
| Spec | Captura |
|---|---|
{name} | String hasta espacio o final |
{name:d} | Entero |
{name:f} | Float |
{name:s} | String |
{name:S} | String (greedy) |
{name:w} | Word |
@when('agrego {qty:d} unidades de "{sku}" al carrito')
def step_add(context, qty, sku):
context.cart.add(sku, qty)
Matchea:
When agrego 3 unidades de "P-100" al carrito
Con qty=3 (int), sku="P-100" (str).
cfparse — custom types
from behave import use_step_matcher, register_type
import parse
use_step_matcher('cfparse')
@parse.with_pattern(r'free|pro|premium')
def parse_plan(text):
return text # podés convertir si querés
register_type(Plan=parse_plan)
@given('un usuario con plan {plan:Plan}')
def step_user_with_plan(context, plan):
context.user = {'plan': plan}
regex
from behave import use_step_matcher
use_step_matcher('re')
@when(r'^agrego (?P<qty>\d+) unidades de "(?P<sku>[^"]+)" al carrito$')
def step_add(context, qty, sku):
context.cart.add(sku, int(qty))
Más poderoso, menos legible. Usalo solo cuando parse no alcance.
El context
Es el equivalente del World en Cucumber-JS. Es un objeto por scenario, fresh, donde guardás estado compartido entre steps.
@given('un cliente con id "{customer_id}"')
def step_set_customer(context, customer_id):
context.customer = create_customer(id=customer_id)
@when('agrego {sku} al carrito')
def step_add_to_cart(context, sku):
context.cart_response = api.post('/carts', customer=context.customer, sku=sku)
@then('el carrito tiene {count:d} líneas')
def step_cart_lines(context, count):
assert len(context.cart_response.body['lines']) == count
context también tiene atributos especiales puestos por behave:
| Atributo | Contenido |
|---|---|
context.table | DataTable del step (si hay) |
context.text | Doc String del step (si hay) |
context.scenario | Metadata del scenario actual |
context.feature | Metadata de la feature |
context.tags | Set de tags activos |
context.config | Config de behave |
Hooks (environment.py)
Los hooks de behave viven en features/environment.py:
def before_all(context):
"""Una vez al inicio de la suite"""
context.api = ApiClient(base_url='http://localhost:3000')
def after_all(context):
"""Una vez al final"""
context.api.close()
def before_feature(context, feature):
"""Antes de cada feature"""
pass
def after_feature(context, feature):
"""Después de cada feature"""
pass
def before_scenario(context, scenario):
"""Antes de cada scenario"""
context.cart = []
context.user = None
def after_scenario(context, scenario):
"""Después de cada scenario"""
if scenario.status == 'failed':
dump_logs(scenario)
def before_step(context, step):
"""Antes de cada step (raramente necesario)"""
pass
def after_step(context, step):
"""Después de cada step"""
if step.status == 'failed':
capture_screenshot(context, step)
def before_tag(context, tag):
"""Antes de cualquier scenario/feature con este tag"""
if tag == 'db':
truncate_tables()
def after_tag(context, tag):
"""Después"""
pass
Hooks por tag
def before_scenario(context, scenario):
if 'authenticated' in scenario.tags:
token = login_test_user()
context.api.set_auth_token(token)
if 'browser' in scenario.tags:
from selenium import webdriver
context.browser = webdriver.Chrome()
def after_scenario(context, scenario):
if 'browser' in scenario.tags:
context.browser.quit()
Equivalente al Before({ tags: '@authenticated' }) de Cucumber-JS.
Async en Python
behave es síncrono por default. Para tests async hay tres caminos:
1. asyncio.run() en cada step
import asyncio
@when('envío POST "{path}"')
def step_post(context, path):
context.response = asyncio.run(context.api.post(path))
Simple pero crea un event loop nuevo cada vez.
2. Un event loop persistente
# environment.py
import asyncio
def before_all(context):
context.loop = asyncio.new_event_loop()
asyncio.set_event_loop(context.loop)
def after_all(context):
context.loop.close()
# steps
@when('envío POST "{path}"')
def step_post(context, path):
context.response = context.loop.run_until_complete(context.api.post(path))
3. behave-asyncio o pytest-bdd
Si tu base de tests es asíncrona en su mayoría, considerá pytest-bdd con pytest-asyncio.
Manejo de errores
Las aserciones de Python (assert) funcionan out of the box:
@then('la respuesta tiene status {expected:d}')
def step_status(context, expected):
assert context.response.status_code == expected, (
f'esperaba {expected}, obtuve {context.response.status_code}: '
f'{context.response.text[:200]}'
)
Para aserciones más expresivas: pytest’s assert con --assert=rewrite, o librerías como assertpy.
DataTables en behave
@given('los siguientes productos en el catálogo')
def step_seed_products(context):
for row in context.table:
context.catalog.add(
sku=row['sku'],
name=row['name'],
price=float(row['price']),
stock=int(row['stock']),
)
context.table es iterable, y cada row se accede como dict.
Para tablas verticales (field | value):
@when('envío la solicitud de cliente con')
def step_post_client(context):
data = {row['field']: row['value'] for row in context.table}
context.response = context.api.post('/clients', data)
Doc Strings en behave
@when('envío POST "{path}" con body')
def step_post_with_body(context, path):
import json
body = json.loads(context.text)
context.response = context.api.post(path, json=body)
context.text contiene el Doc String.
Steps que devuelven undefined/skip
from behave import then
@then('algo todavía no implementado')
def step_pending(context):
context.scenario.skip(reason='Not yet implemented')
O simplemente lanzar NotImplementedError:
@then('algo todavía no implementado')
def step_pending(context):
raise NotImplementedError("se implementa en sprint 2")
Ejecutar tests
# Todos
behave
# Solo una feature
behave features/auth/login.feature
# Con tags
behave --tags=@smoke
# Excluir tags
behave --tags=~@wip
# Formato JSON para reportes
behave --format=json --outfile=results.json
# Pretty + JUnit para CI
behave --format=pretty --format=junit --junit-directory=reports
Configuración: behave.ini o setup.cfg
[behave]
default_format = pretty
default_tags = ~@wip ~@manual
junit = true
junit_directory = reports
show_skipped = false
show_timings = true
[behave.userdata]
api_url = http://localhost:3000
Accedés a userdata en steps:
api_url = context.config.userdata.get('api_url', 'http://localhost:3000')
Comparación TS vs Python
| Aspecto | Cucumber-JS | behave |
|---|---|---|
| Anotaciones | Given(...), When(...) | @given(...), @when(...) |
| Parser default | Cucumber Expressions | parse (format-style) |
| Estado compartido | this (World) | context |
| Hooks | Functional (Before, After) | environment.py (before_scenario) |
| Tag matching en hooks | Before({ tags: '...' }) | Check en hook body |
| Async | Native (async function) | Manual (asyncio loop o pytest-bdd) |
| Reportes | html, json, pretty | pretty, json, junit |
Conceptualmente idénticos. Decidí según el stack que usás en producción.
Resumen
@given,@when,@thenconcontextcomo primer arg- Parsers:
parse(default),cfparse(con custom types),re(regex) contextes el World, fresh por scenario- Hooks viven en
features/environment.py - Tags se filtran con
--tags=@smokeo--tags=~@wip - DataTable:
context.table. Doc String:context.text
En el siguiente capítulo profundizamos en hooks y world/context.