Hooks y world/context

Por: Artiko
gherkinhooksworldcontextaislamiento

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

NivelCucumber-JSbehaveCuándo corre
Suite (una vez)BeforeAll/AfterAllbefore_all/after_allInicio/fin de la corrida
Feature(no nativo)before_feature/after_featureCada feature
ScenarioBefore/Afterbefore_scenario/after_scenarioCada scenario
StepBeforeStep/AfterStepbefore_step/after_stepCada step
TagBefore({ tags })check en hook body, o before_tagAntes/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

  1. DB: truncar tablas en Before / before_scenario
  2. Caches: invalidar en Before / before_scenario
  3. Mocks: reset en Before / before_scenario
  4. Variables module-scope: no usarlas para estado del scenario
  5. Browser: nueva instancia por scenario (o navegación a blank)

Si los tests no son independientes, no podés:

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

En el siguiente capítulo cubrimos antipatrones específicos de Gherkin y cómo evitarlos.