Hooks y world/context
Hooks y world/context
Esta sección profundiza en cómo gestionar el ciclo de vida de un test BDD y el estado que se comparte entre steps.
Niveles de hooks
| Nivel | Cucumber-JS | behave | Cuándo corre |
|---|---|---|---|
| Suite (una vez) | BeforeAll/AfterAll | before_all/after_all | Inicio/fin de la corrida |
| Feature | (no nativo) | before_feature/after_feature | Cada feature |
| Scenario | Before/After | before_scenario/after_scenario | Cada scenario |
| Step | BeforeStep/AfterStep | before_step/after_step | Cada step |
| Tag | Before({ tags }) | check en hook body, o before_tag | Antes/después de tag |
Cucumber-JS — hooks completos
// features/support/hooks.ts
import { BeforeAll, AfterAll, Before, After, BeforeStep, AfterStep } from '@cucumber/cucumber'
import { Pool } from 'pg'
import { CustomWorld } from './world'
let pool: Pool
BeforeAll(async () => {
pool = new Pool({ connectionString: process.env.DATABASE_URL })
await pool.query('CREATE SCHEMA IF NOT EXISTS test')
})
AfterAll(async () => {
await pool.end()
})
Before(async function (this: CustomWorld, scenario) {
// Inicializar estado limpio
this.cart = []
this.user = undefined
this.response = undefined
// Limpiar DB
await pool.query('TRUNCATE users, carts, orders CASCADE')
})
After(async function (this: CustomWorld, scenario) {
// Capturar evidencias si falla
if (scenario.result?.status === 'FAILED') {
if (this.browser) {
const screenshot = await this.browser.screenshot()
this.attach(screenshot, 'image/png')
}
this.attach(JSON.stringify(this.response, null, 2), 'application/json')
}
})
Before({ tags: '@authenticated' }, async function (this: CustomWorld) {
const { body } = await this.api.post('/auth/login', { email: '[email protected]', password: 'test' })
this.api.setAuthToken(body.token)
})
Before({ tags: '@admin' }, async function (this: CustomWorld) {
this.user = await this.db.createUser({ role: 'admin' })
})
behave — hooks completos
# features/environment.py
import requests
from behave import use_step_matcher
def before_all(context):
"""Una vez al inicio"""
context.api_url = context.config.userdata.get('api_url', 'http://localhost:3000')
context.session = requests.Session()
def after_all(context):
"""Una vez al final"""
context.session.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"""
# Estado limpio
context.cart = []
context.user = None
context.response = None
# Limpiar DB
truncate_tables()
# Hooks por tag
if 'authenticated' in scenario.tags:
resp = context.session.post(f'{context.api_url}/auth/login', json={'email': '[email protected]', 'password': 'test'})
token = resp.json()['token']
context.session.headers.update({'Authorization': f'Bearer {token}'})
if 'admin' in scenario.tags:
context.user = create_user(role='admin')
def after_scenario(context, scenario):
"""Después de cada scenario"""
if scenario.status == 'failed':
if hasattr(context, 'browser'):
screenshot = context.browser.get_screenshot_as_png()
attach_to_report(scenario, screenshot, 'image/png')
if context.response is not None:
attach_to_report(scenario, context.response.text, 'text/plain')
def before_step(context, step):
pass
def after_step(context, step):
if step.status == 'failed':
print(f'Step falló: {step.name}')
El World en Cucumber-JS
El World es la instancia donde this apunta dentro de cada step. Se crea fresh por scenario.
// features/support/world.ts
import { setWorldConstructor, World, IWorldOptions } from '@cucumber/cucumber'
import { ApiClient } from './api-client'
import { TestDatabase } from './test-database'
export interface AppUser {
id: string
email: string
role: 'user' | 'admin'
plan: 'free' | 'pro' | 'premium'
}
export class CustomWorld extends World {
api: ApiClient
db: TestDatabase
// Estado del scenario
cart: { sku: string; qty: number }[] = []
user?: AppUser
response?: { status: number; body: any; headers: Record<string, string> }
constructor(options: IWorldOptions) {
super(options)
this.api = new ApiClient(process.env.API_URL ?? 'http://localhost:3000')
this.db = new TestDatabase(process.env.DATABASE_URL!)
}
}
setWorldConstructor(CustomWorld)
En las steps, tipá this:
import { Given } from '@cucumber/cucumber'
import { CustomWorld } from '../support/world'
Given('un usuario con plan {string}', function (this: CustomWorld, plan: string) {
this.user = {
id: 'U-001',
email: '[email protected]',
role: 'user',
plan: plan as AppUser['plan']
}
})
El context en behave
context es equivalente, pero es un objeto dinámico (Context de behave):
@given('un usuario con plan "{plan}"')
def step_user_with_plan(context, plan):
context.user = {
'id': 'U-001',
'email': '[email protected]',
'role': 'user',
'plan': plan,
}
Para tener tipado, usá dataclass:
# features/steps/types.py
from dataclasses import dataclass, field
@dataclass
class AppUser:
id: str
email: str
role: str # 'user' | 'admin'
plan: str # 'free' | 'pro' | 'premium'
@dataclass
class CartItem:
sku: str
qty: int
@dataclass
class ScenarioState:
cart: list[CartItem] = field(default_factory=list)
user: AppUser | None = None
response: dict | None = None
# environment.py
from features.steps.types import ScenarioState
def before_scenario(context, scenario):
context.state = ScenarioState()
Aislamiento entre scenarios
Crítico: cada scenario debe correr en aislamiento. No asumir estado de scenarios previos.
Reglas de aislamiento
- DB: truncar tablas en
Before/before_scenario - Caches: invalidar en
Before/before_scenario - Mocks: reset en
Before/before_scenario - Variables module-scope: no usarlas para estado del scenario
- Browser: nueva instancia por scenario (o navegación a blank)
Si los tests no son independientes, no podés:
- Correrlos en paralelo
- Reordenarlos para isolating un test que falla
- Confiar en los reportes
Setup compartido caro (BeforeAll)
Algunas inicializaciones son caras (start de DB, browser, servicio). Hacerlas en Before (cada scenario) es lento. Hacerlas en BeforeAll (una vez) requiere limpieza explícita entre scenarios.
// caro: levantar DB
BeforeAll(async () => {
await startTestDatabase()
})
// barato: limpiar tablas
Before(async () => {
await truncateTables()
})
AfterAll(async () => {
await stopTestDatabase()
})
Mismo patrón para browsers en Selenium/Playwright:
let browser: Browser
BeforeAll(async () => {
browser = await playwright.chromium.launch()
})
Before(async function (this: CustomWorld) {
this.context = await browser.newContext()
this.page = await this.context.newPage()
})
After(async function (this: CustomWorld) {
await this.context.close()
})
AfterAll(async () => {
await browser.close()
})
Capturar evidencias en falla
Cucumber-JS
After(async function (this: CustomWorld, scenario) {
if (scenario.result?.status !== 'FAILED') return
// Screenshot
if (this.page) {
const screenshot = await this.page.screenshot()
this.attach(screenshot, 'image/png')
}
// Logs
if (this.consoleLogs) {
this.attach(this.consoleLogs.join('\n'), 'text/plain')
}
// Last response
if (this.response) {
this.attach(JSON.stringify(this.response, null, 2), 'application/json')
}
})
this.attach(content, mediaType) agrega el evidence al reporte HTML.
behave
def after_scenario(context, scenario):
if scenario.status != 'failed':
return
if hasattr(context, 'page'):
screenshot = context.page.screenshot()
with open(f'reports/{scenario.name}.png', 'wb') as f:
f.write(screenshot)
if context.response is not None:
with open(f'reports/{scenario.name}-response.json', 'w') as f:
f.write(context.response.text)
Hooks composables
Para hooks complejos, separá en módulos:
// features/support/hooks/db.ts
import { BeforeAll, AfterAll, Before } from '@cucumber/cucumber'
BeforeAll(async () => { /* start db */ })
AfterAll(async () => { /* stop db */ })
Before(async () => { /* truncate */ })
// features/support/hooks/api.ts
import { BeforeAll, AfterAll } from '@cucumber/cucumber'
BeforeAll(async () => { /* start api */ })
AfterAll(async () => { /* stop api */ })
// features/support/hooks/browser.ts
import { BeforeAll, AfterAll, Before, After } from '@cucumber/cucumber'
BeforeAll(async () => { /* launch browser */ })
AfterAll(async () => { /* close browser */ })
Before(async function () { /* new context */ })
After(async function () { /* close context */ })
Cada módulo se carga con la config require: ['features/support/hooks/**/*.ts'].
Anti-patrones de hooks
1. Hooks que dependen del orden de scenarios
- Before(async function () {
- if (this.lastScenarioCreatedUser) { // ⚠ asume estado anterior
- this.user = this.lastScenarioCreatedUser
- }
- })
2. Hooks que hacen demasiado
- Before(async function () {
- // 200 líneas de setup
- })
+ Before(async function () {
+ await setupDb()
+ await seedTestData()
+ await mockExternalServices()
+ })
+ // mover detalles a funciones nombradas
3. Hooks que mezclan responsabilidades
- Before(async function () {
- await truncateDb()
- await startBrowser() // ⚠ no todos los tests necesitan browser
- })
+ Before({ tags: '@browser' }, async function () {
+ await startBrowser()
+ })
+ Before(async function () {
+ await truncateDb()
+ })
Diagrama: ciclo completo
sequenceDiagram
participant All as BeforeAll
participant Feat as before_feature
participant Sc as before_scenario
participant Step as Steps
participant ASc as after_scenario
participant AFeat as after_feature
participant AAll as AfterAll
All->>Feat: inicio feature
Feat->>Sc: inicio scenario 1
Sc->>Step: ejecuta steps
Step->>ASc: fin scenario 1
ASc->>Sc: inicio scenario 2
Sc->>Step: ...
Step->>ASc: fin scenario N
ASc->>AFeat: fin feature
AFeat->>AAll: fin suite
Resumen
- Hooks: BeforeAll/AfterAll (suite), Before/After (scenario), BeforeStep/AfterStep (step)
- World (TS) / context (Python) para estado entre steps
- Aislamiento: cada scenario corre fresh
- Setup caro en BeforeAll; limpieza barata en Before
- Hooks por tag para setup específico
- Adjuntar evidencias en falla
En el siguiente capítulo cubrimos antipatrones específicos de Gherkin y cómo evitarlos.