Step Definitions en TypeScript

Por: Artiko
gherkincucumber-jstypescriptstep-definitions

Step Definitions en TypeScript

Cucumber-JS es el runner BDD para Node.js. Esta sección cubre las step definitions con TypeScript en profundidad.

Setup completo

npm install --save-dev @cucumber/cucumber typescript ts-node @types/node

tsconfig.json mínimo:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true
  }
}

cucumber.cjs:

module.exports = {
  default: {
    requireModule: ['ts-node/register'],
    require: ['features/step_definitions/**/*.ts', 'features/support/**/*.ts'],
    paths: ['features/**/*.feature'],
    format: ['progress-bar', '@cucumber/pretty-formatter', 'html:reports/cucumber.html'],
    formatOptions: { snippetInterface: 'async-await' }
  }
}

Estructura recomendada

features/
├── auth/
│   ├── login.feature
│   └── signup.feature
├── cart/
│   ├── add-product.feature
│   └── checkout.feature
├── step_definitions/
│   ├── auth.steps.ts
│   └── cart.steps.ts
└── support/
    ├── world.ts
    ├── hooks.ts
    └── helpers/
        ├── api-client.ts
        └── db.ts

Las anotaciones: Given, When, Then

import { Given, When, Then } from '@cucumber/cucumber'

Given('una calculadora vacía', function () {
  this.calc = new Calculator()
})

When('sumo {int} y {int}', function (a: number, b: number) {
  this.calc.add(a, b)
})

Then('el resultado es {int}', function (expected: number) {
  expect(this.calc.getResult()).toBe(expected)
})

Given, When y Then son funcionalmente intercambiables — la palabra clave que usás determina la categoría del step, pero Cucumber no hace distinciones técnicas. Por convención usá la palabra correcta.

Cucumber Expressions

El sistema de matching default. Soporta tipos built-in:

PlaceholderCapturaTipo TS
{int}Enteronumber
{float}Floatnumber
{word}Palabra sin espaciosstring
{string}Cadena entre "..." o '...'string
{}Cualquier cosa (no recomendado)string
When('agrego {int} unidades de {string} al carrito', function (qty: number, sku: string) {
  this.cart.add(sku, qty)
})

El paso:

When agrego 3 unidades de "P-100" al carrito

Matchea con qty = 3 y sku = 'P-100'.

Parameter types custom

Definí tipos custom para hacer las step definitions más expresivas y type-safe.

// features/support/parameters.ts
import { defineParameterType } from '@cucumber/cucumber'

defineParameterType({
  name: 'product',
  regexp: /[A-Z]-\d{3}/,
  transformer: (sku) => ({ sku }),
})

defineParameterType({
  name: 'currency',
  regexp: /\$\d+(\.\d{2})?/,
  transformer: (s) => Number(s.replace('$', '')),
})

defineParameterType({
  name: 'plan',
  regexp: /free|pro|premium/,
  transformer: (name) => name as 'free' | 'pro' | 'premium',
})

Uso:

When('agrego {product} al carrito', function (product: { sku: string }) {
  this.cart.add(product.sku)
})

Then('el subtotal es {currency}', function (expected: number) {
  expect(this.cart.subtotal).toBe(expected)
})

Given('un usuario con plan {plan}', function (plan: 'free' | 'pro' | 'premium') {
  this.user = { plan }
})

Los .feature quedan limpios:

Given un usuario con plan pro
When agrego P-100 al carrito
Then el subtotal es $25.00

RegExp en lugar de Cucumber Expressions

Si necesitás más control:

import { When } from '@cucumber/cucumber'

When(/^agrego (\d+) unidades de "([^"]+)" al carrito$/, function (qty: string, sku: string) {
  this.cart.add(sku, parseInt(qty, 10))
})

Más poderoso pero menos legible. Usá Cucumber Expressions por default.

El World — estado compartido entre steps

this dentro de un step es el World, instancia que se crea fresh por scenario. Es el lugar para compartir estado.

// features/support/world.ts
import { setWorldConstructor, World, IWorldOptions } from '@cucumber/cucumber'
import { ApiClient } from './helpers/api-client'

export interface CartItem {
  sku: string
  qty: number
}

export class CustomWorld extends World {
  api: ApiClient
  cart: CartItem[] = []
  response?: { status: number; body: any }
  user?: { id: string; plan: 'free' | 'pro' | 'premium' }

  constructor(options: IWorldOptions) {
    super(options)
    this.api = new ApiClient(process.env.API_URL ?? 'http://localhost:3000')
  }
}

setWorldConstructor(CustomWorld)

Y en las step definitions:

import { Given, When, Then } from '@cucumber/cucumber'
import { CustomWorld } from '../support/world'

Given('un usuario con plan {plan}', function (this: CustomWorld, plan) {
  this.user = { id: 'U-001', plan }
})

When('agrego {product} al carrito', function (this: CustomWorld, product) {
  this.cart.push({ sku: product.sku, qty: 1 })
})

Then('el carrito tiene {int} líneas', function (this: CustomWorld, count: number) {
  expect(this.cart.length).toBe(count)
})

Tipar this te da autocompletado y type-checking.

Hooks

import { Before, After, BeforeAll, AfterAll, BeforeStep, AfterStep } from '@cucumber/cucumber'

BeforeAll(async () => {
  await startTestDatabase()
})

AfterAll(async () => {
  await stopTestDatabase()
})

Before(async function (this: CustomWorld) {
  await truncateTables()
})

After(async function (this: CustomWorld, scenario) {
  if (scenario.result?.status === 'FAILED') {
    console.log('Scenario falló:', scenario.pickle.name)
    // ej. capturar screenshot, dump de logs
  }
})

Before({ tags: '@authenticated' }, async function (this: CustomWorld) {
  const { token } = await this.api.post('/auth/login', { email: '[email protected]', password: 'test' })
  this.api.setAuthToken(token)
})

Orden de ejecución:

sequenceDiagram
    BeforeAll->>Before: Inicio
    Before->>BeforeStep: Antes de cada step
    BeforeStep->>Step: ejecutar
    Step->>AfterStep: Después de cada step
    AfterStep->>After: Fin del scenario
    After->>AfterAll: Fin de la suite

Async/await

Los steps async se manejan con async function:

When('envío POST {string} con body:', async function (this: CustomWorld, path: string, body: string) {
  this.response = await this.api.post(path, JSON.parse(body))
})

Then('la respuesta tiene status {int}', function (this: CustomWorld, expected: number) {
  expect(this.response?.status).toBe(expected)
})

Cucumber espera a que la Promise se resuelva antes de pasar al siguiente step.

Manejo de errores

Si un step lanza, el scenario falla con ese error.

Then('la respuesta tiene status {int}', function (this: CustomWorld, expected: number) {
  if (this.response?.status !== expected) {
    throw new Error(`Esperaba status ${expected}, fue ${this.response?.status}`)
  }
})

Usá una librería de aserciones: node:assert, chai, vitest/expect, jest. La mayoría tienen mensajes informativos out of the box.

Steps que devuelven ‘pending’

Útil cuando un step todavía no está implementado:

When('hago algo todavía no implementado', function () {
  return 'pending'
})

El scenario aparece como pending en el reporte, no como failed. Útil durante TDD outside-in.

Steps que se saltean

When('un step que no debería ejecutarse', function () {
  return 'skipped'
})

Anti-patrones en step definitions

1. Steps demasiado específicos

- Given('un usuario con email [email protected] y password Secret123 y rol admin', () => { ... })
+ Given('un usuario con email {string}, password {string} y rol {string}', (email, password, role) => { ... })

Generalizá con parámetros.

2. Pegado de strings frágil

- When(/^hago clic en "(.+)" y espero (\d+) segundos$/, ...)   // matchea cualquier cosa
+ When('hago clic en {string} y espero {int} segundos', ...)

Cucumber Expressions son menos frágiles que regex.

3. Step definitions con lógica de negocio

When('proceso el pago', function () {
- // lógica completa del pago acá
- if (this.cart.total > 100) ...
- if (this.user.plan === 'premium') ...
+ this.response = await this.api.post('/payments', { cart: this.cart, user: this.user })
})

Las step definitions son wrappers thin. La lógica vive en la aplicación, no en los tests.

4. Estado en variables module-scope

- let currentUser   // ⚠ módulo-scope, contamina entre scenarios
+ // Usar this.user en el World

Snippets autogenerados

Cuando ejecutás un scenario con steps no implementados, Cucumber imprime el código snippet:

? When agrego "P-100" al carrito

   Implement with the following snippet:

      When('agrego {string} al carrito', function (string) {
        // Write code here that turns the phrase above into concrete actions
        return 'pending';
      });

Copialo, completalo, y el step queda listo.

Resumen

En el siguiente capítulo cubrimos el equivalente en Python con behave.