desarrollo de software16 min de lectura

BDD (Behavior Driven Development). Guía de implementación

Por Sergio Perea #bdd#testing#agile#software development#testing automation#gherkin
BDD (Behavior Driven Development). Guía de implementación

Esta guía responde a una intención informacional: entender Behavior-Driven Development (BDD) y ver cómo aplicarlo en Python con ejemplos que se puedan llevar a un repositorio real.

El problema que suelo ver en industria no es “faltan tests”. Es este:

  • Negocio pide X.
  • El equipo implementa Y.
  • En producción aparece el “no era esto”, que se paga con retrabajo, deuda técnica, bugs caros y pérdida de ritmo.

BDD recorta esa distancia usando una idea simple: acordar ejemplos concretos antes de escribir código y, cuando compensa, automatizarlos como tests de aceptación BDD que actúan como documentación viva BDD.


Qué es la metodología BDD (explicado para humanos)

BDD es una metodología dentro de la programación ágil que describe el sistema por su comportamiento observable: lo que hace ante un contexto y una acción.

En vez de discutir sobre “cómo lo vamos a programar”, BDD fuerza otra pregunta:

“Ponme ejemplos. ¿Qué casos concretos deben funcionar?”

Eso aterriza el lenguaje de negocio y reduce ambigüedades.

Los 3 pasos de BDD

Si vienes de TDD, BDD no te resultará extraño. De hecho, una buena forma de entenderlo es verlo como una evolución natural del mismo flujo mental, pero desplazando el foco.

En TDD solemos pensar así:

  1. Red → escribo un test que falla
  2. Green → escribo el código mínimo para que pase
  3. Refactor → limpio y mejoro el diseño

Ese ciclo nos ayuda a diseñar bien el código.

BDD propone un ciclo muy parecido, pero sube un nivel de abstracción. En lugar de empezar por el test técnico, empieza por el comportamiento que el negocio espera.

El paralelismo es bastante directo:

TDD pregunta:
¿Qué función debería existir y cómo debería comportarse internamente?

BDD pregunta primero:
¿Qué debería pasar desde el punto de vista del usuario o del negocio?

A partir de ahí, los 3 pasos de BDD encajan casi como una versión “outside-in” del mismo enfoque:

  • Donde TDD empieza escribiendo un test,
    BDD empieza descubriendo el comportamiento.
  • Donde TDD concreta expectativas técnicas,
    BDD formula ejemplos de negocio.
  • Donde TDD automatiza tests unitarios,
    BDD automatiza escenarios de aceptación cuando aporta valor.

Si TDD te ayuda a construir bien la solución,
BDD te ayuda a construir la solución correcta.

Con esa idea en mente, vamos paso a paso por los 3 pasos de BDD:

  1. Discovery: descubrir reglas con negocio, QA y dev (los “3 amigos”).
  2. Formulation: convertir reglas en escenarios de prueba BDD (Given/When/Then) usando un lenguaje específico del dominio BDD.
  3. Automation: automatizar escenarios clave (si aporta valor).

El verdadero valor de BDD: menos trabajo tirado

Si has vivido una entrega donde “funciona pero no sirve”, ya sabes el coste:

  • semanas perdidas
  • reuniones tensas
  • hotfixes
  • tickets “quick win” que acaban siendo eternos

BDD baja ese riesgo por un motivo: define “hecho” con ejemplos verificables.


Capa estratégica: trade-offs y visión de negocio

Impacto técnico vs impacto de negocio

BDD suele mejorar:

  • Velocidad real: menos vueltas de ida y vuelta en validación.
  • Coste de cambio: hay menos sorpresas tarde.
  • Onboarding: escenarios claros explican el sistema mejor que docs estáticas.
  • Riesgo: reglas críticas quedan protegidas con tests.

Y también trae riesgos:

  • si el negocio no participa, se vuelve ritual vacío
  • si automatizas escenarios frágiles, el CI se convierte en un generador de ruido
  • si describís UI en escenarios, se rompe con cada cambio visual

BDD funciona cuando protege valor real. Si no, estorba.


Capa técnica: implementación de BDD en Python con ejemplos

A partir de aquí, vamos a bajar al barro con Python. Voy a usar behave (muy habitual en ecosistema Python) y también te enseño el patrón correcto para que BDD no acabe metiendo lógica en los steps.

Estructura mínima del proyecto

Una estructura típica:

. ├── app/ │ ├── domain/ │ │ ├── order.py │ │ └── errors.py │ └── use_cases/ │ └── cancel_order.py └── features/ ├── cancel_order.feature ├── environment.py └── steps/ └── cancel_order_steps.py

La idea: el comportamiento vive en el dominio y casos de uso. Los steps solo conectan.


1) User story y ejemplos (antes del Gherkin)

Ejemplo (pedido con reembolso):

  • Como cliente
  • Quiero cancelar un pedido pagado que aún no se ha enviado
  • Para recuperar el dinero

Ejemplos que quitan dudas:

  • pagado + no enviado → cancelado + reembolso total
  • pagado + enviado → no se puede cancelar
  • no pagado → se puede cancelar sin reembolso

Con esto ya tienes “casos de uso BDD” reales.


2) Escenario en Gherkin (claro y con datos)

features/cancel_order.feature

Feature: Cancelación de pedido Scenario: cancelar un pedido pagado antes de ser enviado Given existe un pedido "P-1001" con estado "PAID" y total 120.00 And el pedido "P-1001" no ha sido enviado When el usuario cancela el pedido "P-1001" Then el estado del pedido "P-1001" pasa a "CANCELLED" And se crea un reembolso por 120.00 Scenario: no permitir cancelar un pedido ya enviado Given existe un pedido "P-2000" con estado "PAID" y total 50.00 And el pedido "P-2000" ya ha sido enviado When el usuario cancela el pedido "P-2000" Then se rechaza la cancelación con error "ORDER_ALREADY_SHIPPED"

Fíjate en lo que NO aparece: rutas, endpoints, botones. Esto describe comportamiento del dominio.


3) Dominio y caso de uso (Python limpio)

app/domain/errors.py

class DomainError(Exception): code: str = "DOMAIN_ERROR" class OrderAlreadyShipped(DomainError): code = "ORDER_ALREADY_SHIPPED"

app/domain/order.py

from __future__ import annotations from dataclasses import dataclass from datetime import datetime from decimal import Decimal from typing import Optional from .errors import OrderAlreadyShipped @dataclass(frozen=True) class Order: id: str status: str # e.g., "PAID", "CANCELLED" total: Decimal shipped_at: Optional[datetime] = None def cancel(self) -> "Order": if self.shipped_at is not None: raise OrderAlreadyShipped() # Regla simple: si está pagado o pendiente, cancelamos return Order( id=self.id, status="CANCELLED", total=self.total, shipped_at=self.shipped_at, )

app/use_cases/cancel_order.py

from __future__ import annotations from dataclasses import dataclass from decimal import Decimal from app.domain.order import Order @dataclass class CancelOrderResult: order: Order refund_amount: Decimal class OrderRepository: def get_by_id(self, order_id: str) -> Order: ... def save(self, order: Order) -> None: ... class PaymentsGateway: def refund(self, order_id: str, amount: Decimal) -> None: ... def cancel_order(order_id: str, *, repo: OrderRepository, payments: PaymentsGateway) -> CancelOrderResult: order = repo.get_by_id(order_id) cancelled = order.cancel() repo.save(cancelled) refund_amount = order.total if order.status == "PAID" else Decimal("0.00") if refund_amount > 0: payments.refund(order_id=order.id, amount=refund_amount) return CancelOrderResult(order=cancelled, refund_amount=refund_amount)

Decisión clave: el caso de uso orquesta. El dominio decide reglas.


4) Doubles en memoria para tests rápidos

features/environment.py

from decimal import Decimal from behave import fixture, use_fixture from datetime import datetime from typing import Dict, List, Optional from app.domain.order import Order class InMemoryOrderRepo: def __init__(self) -> None: self._data: Dict[str, Order] = {} def get_by_id(self, order_id: str) -> Order: return self._data[order_id] def save(self, order: Order) -> None: self._data[order.id] = order def seed(self, order_id: str, status: str, total: Decimal, shipped_at: Optional[datetime]) -> None: self._data[order_id] = Order(id=order_id, status=status, total=total, shipped_at=shipped_at) class InMemoryPayments: def __init__(self) -> None: self.refunds: List[tuple[str, Decimal]] = [] def refund(self, order_id: str, amount: Decimal) -> None: self.refunds.append((order_id, amount)) def refunds_count(self) -> int: return len(self.refunds) @fixture def app_context(context, *args, **kwargs): context.repo = InMemoryOrderRepo() context.payments = InMemoryPayments() context.last_error = None context.last_result = None yield context def before_scenario(context, scenario): use_fixture(app_context, context)

5) Step definitions (sin meter lógica de negocio dentro)

features/steps/cancel_order_steps.py

from behave import given, when, then from datetime import datetime from decimal import Decimal from app.use_cases.cancel_order import cancel_order from app.domain.errors import DomainError @given('existe un pedido "{order_id}" con estado "{status}" y total {total:f}') def step_seed_order(context, order_id, status, total): context.repo.seed( order_id=order_id, status=status, total=Decimal(str(total)), shipped_at=None, ) @given('el pedido "{order_id}" no ha sido enviado') def step_not_shipped(context, order_id): order = context.repo.get_by_id(order_id) context.repo.seed(order_id, order.status, order.total, shipped_at=None) @given('el pedido "{order_id}" ya ha sido enviado') def step_shipped(context, order_id): order = context.repo.get_by_id(order_id) context.repo.seed(order_id, order.status, order.total, shipped_at=datetime.utcnow()) @when('el usuario cancela el pedido "{order_id}"') def step_cancel(context, order_id): try: context.last_result = cancel_order(order_id, repo=context.repo, payments=context.payments) context.last_error = None except DomainError as e: context.last_error = e context.last_result = None @then('el estado del pedido "{order_id}" pasa a "{status}"') def step_assert_status(context, order_id, status): order = context.repo.get_by_id(order_id) assert order.status == status @then('se crea un reembolso por {amount:f}') def step_assert_refund(context, amount): expected = Decimal(str(amount)) assert context.payments.refunds_count() == 1 _, refunded = context.payments.refunds[0] assert refunded == expected @then('se rechaza la cancelación con error "{code}"') def step_assert_error(context, code): assert context.last_error is not None assert getattr(context.last_error, "code", None) == code

Este patrón escala bien: los steps llaman a casos de uso, los casos de uso llaman al dominio.


“BDD vs TDD” en Python: cómo los mezclo sin liarla

  • BDD: valida comportamiento de negocio (aceptación) con escenarios.
  • TDD: valida detalles internos (funciones, invariantes, edge cases) con tests rápidos (pytest).

Una práctica que me ha dado buen resultado:

  • 5–15 escenarios BDD para flujos críticos.
  • 80–90% del resto en pytest, cerca del dominio.

Errores comunes que he visto en producción

  • Escenarios eternos: 20 pasos por escenario. Nadie mantiene eso.
  • Escenarios pegados a UI: cambias un texto y rompes “documentación”.
  • Automatizar e2e por defecto: tests lentos y frágiles.
  • Steps con lógica: reglas repartidas en steps = caos.

Cuándo NO usar BDD

No lo metería si:

  • el producto está en fase “todavía no sabemos qué vendemos”
  • no hay gente de dominio disponible para acordar ejemplos
  • el equipo no puede mantener una suite mínima estable

En esos contextos, prefiero contratos + pytest y volver a BDD cuando el dominio se estabiliza.


Lecciones aprendidas (en primera persona)

He visto BDD convertido en burocracia por intentar automatizarlo todo desde el día 1. Lo típico: features por doc, escenarios por presión, y una semana después el CI falla por ruido. La gente deja de confiar. Y cuando no confías en tus tests, ya has perdido.

Lo que sí me ha funcionado en producción: pocos escenarios, los que protegen dinero, permisos y flujos críticos. Y el resto, tests rápidos cerca del dominio.


FAQ (para fragmentos destacados)

¿Qué es BDD en Python?

Es aplicar BDD (definir comportamiento con ejemplos) en proyectos Python, normalmente escribiendo escenarios Given/When/Then y automatizándolos con herramientas como behave cuando compensa.

¿Behave es obligatorio para hacer BDD?

No. Behave es una herramienta. BDD es el proceso de acordar ejemplos y reglas antes de programar.

¿Qué diferencia hay entre tests de aceptación BDD y tests unitarios?

Los tests de aceptación validan comportamiento de negocio completo. Los unitarios validan piezas pequeñas del código. Ambos se complementan.

¿Cómo evito que BDD se vuelva lento y frágil?

Automatiza pocos escenarios críticos, evita describir UI, y mantén la lógica en dominio/casos de uso, no en los steps.