Step Definitions en Python (behave)

Por: Artiko
gherkinbehavepythonstep-definitions

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:

Parsers de parámetros

behave soporta tres parsers, configurables con behave.use_step_matcher:

ParserSintaxisEjemplo
parseDefault, format-style{name:type}
cfparseCucumber-style{name:Type} con custom types
reRegex puror'^...(\d+)...$'

parse (default)

SpecCaptura
{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:

AtributoContenido
context.tableDataTable del step (si hay)
context.textDoc String del step (si hay)
context.scenarioMetadata del scenario actual
context.featureMetadata de la feature
context.tagsSet de tags activos
context.configConfig 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

AspectoCucumber-JSbehave
AnotacionesGiven(...), When(...)@given(...), @when(...)
Parser defaultCucumber Expressionsparse (format-style)
Estado compartidothis (World)context
HooksFunctional (Before, After)environment.py (before_scenario)
Tag matching en hooksBefore({ tags: '...' })Check en hook body
AsyncNative (async function)Manual (asyncio loop o pytest-bdd)
Reporteshtml, json, prettypretty, json, junit

Conceptualmente idénticos. Decidí según el stack que usás en producción.

Resumen

En el siguiente capítulo profundizamos en hooks y world/context.