StackPractices
beginner Por Mathias Paulenko

Desarrollo Guiado por Pruebas (TDD)

Aprende TDD paso a paso: escribe un test que falle, hazlo pasar, refactoriza. Red-Green-Refactor con ejemplos reales en Python, JavaScript y Java.

Introducción

Un endpoint de checkout cobra dos veces a un cliente porque alguien editó applyDiscount un viernes por la tarde y nadie volvió a ejecutar los casos límite. Si un test hubiera fallado en el momento del cambio, ese bug nunca habría llegado a producción. Ese es el problema concreto que TDD resuelve.

El Desarrollo Guiado por Pruebas es un flujo de trabajo en el que escribes el test antes que el código de producción. El ciclo es corto y repetitivo: escribe un test que falle, escribe el código mínimo para que pase, luego refactoriza manteniendo la suite en verde. Es más lento de escribir y más rápido de depurar — el intercambio es deliberado.

Esta guía recorre el ciclo de forma práctica: un ejemplo completo en Python, herramientas por lenguaje, dobles de test, cómo convive TDD con BDD y — igual de importante — cuándo saltárselo.

El Ciclo Red-Green-Refactor

flowchart diagram: Red[

1. Red — Escribe un Test que Falle

Comienza con un test que describa el comportamiento deseado. Ejecútalo y míralo fallar.

# test_calculator.py
def test_add_two_numbers():
    calc = Calculator()
    result = calc.add(2, 3)
    assert result == 5
$ pytest test_calculator.py
FAILED: Calculator not defined

¿Por qué red primero? Un test que pasa sin código no prueba nada. Verlo fallar confirma que el test realmente ejercita algo.

2. Green — Escribe el Código Mínimo

Escribe el código más simple que haga pasar el test. La elegancia todavía no importa.

# calculator.py
class Calculator:
    def add(self, a, b):
        return a + b  # implementación más simple posible
$ pytest test_calculator.py
PASSED

¿Por qué mínimo? Quieres el camino más corto a verde. La abstracción prematura oscurece si el test verifica lo correcto. Sí, return 5 también pasaría — está bien, el siguiente test fuerza la lógica real.

3. Refactor — Mejora el Diseño

Ahora que el test pasa, limpia: renombra variables, extrae métodos, elimina duplicación. Ejecuta los tests después de cada cambio.

# Refactorizado: sigue pasando, pero más limpio
class Calculator:
    def add(self, augend, addend):
        return augend + addend

¿Por qué refactorizar en verde? Los tests actúan como red de seguridad. Si un refactor rompe algo, lo sabes en segundos, no después del deploy.

Leer la Barra Roja

Un buen fallo te dice exactamente qué falta. FAILED: Calculator not defined dice “la clase aún no existe” — una acción siguiente clara. Una pila de errores que tarda cinco minutos en descifrarse significa que el test era demasiado grande: intentó verificar varios comportamientos a la vez. Cuando una barra roja te sorprende, las causas habituales son un typo en el arrange, una expectativa equivocada (el test afirma 5 pero la spec dice 6) o estado compartido que gotea de un test anterior. Arregla el test, no la salida — nunca ajustes una afirmación solo para que la barra se ponga verde.

Un Ejemplo Completo

Construyamos un ShoppingCart con TDD — cada paso es un ciclo red-green-refactor completo.

Paso 1: Carrito Vacío

def test_empty_cart_total_is_zero():
    cart = ShoppingCart()
    assert cart.total() == 0
class ShoppingCart:
    def total(self):
        return 0

Paso 2: Agregar Items

def test_add_item_increases_total():
    cart = ShoppingCart()
    cart.add(Item("manzana", 1.50))
    assert cart.total() == 1.50
class ShoppingCart:
    def __init__(self):
        self.items = []

    def add(self, item):
        self.items.append(item)

    def total(self):
        return sum(item.price for item in self.items)

Paso 3: Aplicar Descuento

def test_apply_discount():
    cart = ShoppingCart()
    cart.add(Item("laptop", 1000))
    cart.apply_discount("SAVE10")
    assert cart.total() == 900
class ShoppingCart:
    ...
    def apply_discount(self, code):
        self.discount = 0.10  # hardcodeado por ahora — el siguiente refactor lo arregla

    def total(self):
        subtotal = sum(item.price for item in self.items)
        if hasattr(self, 'discount'):
            return subtotal * (1 - self.discount)
        return subtotal

Paso 4: Refactorizar — Extraer la Lógica de Descuento

class Discount:
    def __init__(self, code, percentage):
        self.code = code
        self.percentage = percentage

    def apply(self, amount):
        return amount * (1 - self.percentage)

class ShoppingCart:
    def __init__(self):
        self.items = []
        self.discount = None

    def add(self, item):
        self.items.append(item)

    def apply_discount(self, code):
        discounts = {"SAVE10": 0.10, "SAVE20": 0.20}
        self.discount = Discount(code, discounts.get(code, 0))

    def total(self):
        subtotal = sum(item.price for item in self.items)
        if self.discount:
            return self.discount.apply(subtotal)
        return subtotal

Paso 5: Caso Límite — Código de Descuento Inválido

def test_invalid_discount_code_is_ignored():
    cart = ShoppingCart()
    cart.add(Item("mouse", 25))
    cart.apply_discount("NOTACODE")
    assert cart.total() == 25

Discount(code, 0) ya lo gestiona — el test fija el comportamiento. Cada caso límite que imagines cuesta un test barato de bloquear; esa acumulación es lo que hace valiosa a la suite un año después.

Observa lo que el ciclo te dio: la clase Discount emergió porque los tests hicieron barato soportar un segundo código, no porque se diseñó por adelantado. Esa es la presión de diseño que TDD aplica — los tests extraen la estructura de ti. El carrito terminado y su suite de cinco tests son ejecutables en el repositorio companion.

Las Tres Leyes de TDD

Robert C. Martin destiló la disciplina en tres reglas:

  1. No puedes escribir código de producción hasta tener un test unitario que falle.
  2. No puedes escribir más de un test unitario que lo suficiente para fallar. (Los errores de compilación cuentan como fallos.)
  3. No puedes escribir más código de producción que el suficiente para pasar el test que falla ahora.

En la práctica nadie aplica las leyes con rigor absoluto — úsalas como función forzadora que mantiene el ciclo corto, no como ceremonia.

Anatomía de un Buen Test

Todo test unitario tiene el mismo esqueleto — Arrange, Act, Assert:

def test_total_includes_each_item_once():
    # Arrange: construye el mundo que el test necesita
    cart = ShoppingCart()
    cart.add(Item("manzana", 1.50))
    cart.add(Item("manzana", 1.50))

    # Act: exactamente una llamada
    total = cart.total()

    # Assert: verifica el comportamiento observable
    assert total == 3.00
  • Arrange construye objetos, fixtures y stubs. Si el arrange es más largo que el act y el assert juntos, la API bajo test probablemente es demasiado parlanchina — el test acaba de decirte algo sobre tu diseño.
  • Act es una llamada, idealmente una línea. Dos acciones en un test suelen significar dos tests.
  • Assert verifica comportamiento observable: un valor de retorno, un cambio de estado, una llamada saliente. Nunca sobre internos — así acabas reescribiendo la suite en cada refactor.

Para nombrar, test_<escenario>_<esperado> y should_<esperado>_when_<condición> funcionan igual de bien; la consistencia importa más que la convención. Cuando un test falla a las 2 a.m., su nombre es lo primero que lees — hazlo una frase sobre comportamiento, no test1.

Herramientas para TDD

El ciclo es agnóstico de la herramienta, pero el feedback rápido lo cambia todo. Si pytest tarda 30 segundos saltarás el paso red; si tarda 1,5 s no lo harás. Activa un modo watch y mantén el ciclo bajo ~3 segundos.

LenguajeRunnerModo watch / rápidoNotas
Pythonpytest 8.xpytest-watch o pytest -x -q-x corta en el primer fallo; los fixtures gestionan el estado compartido
JavaScriptJest 29.xjest --watch--watchAll re-ejecuta al cambiar archivos; Vitest 2.x es la alternativa rápida en proyectos Vite
JavaJUnit 5 (5.10+)Maven mvn test en el IDE o Gradle -tEl modo continuo de Gradle re-ejecuta al guardar
PHPPest / PHPUnit 11pest --watchSintaxis describe/it tomada de JS
# Ciclo TDD típico en un proyecto Python
$ pytest tests/test_cart.py -x -q
........                                                    [100%]
8 passed in 0.42s

Los dos ajustes que más importan: ejecuta solo el archivo de test en el que trabajas (pytest tests/test_cart.py, jest cart.test.js) y activa el corte en el primer fallo para que la barra roja aparezca al instante.

TDD y los Test Doubles

TDD asume que tu test aísla la unidad. Cuando la unidad habla con una base de datos, una cola o una API de pagos, necesitas dobles de test — si no, los tests “unitarios” se convierten en tests de integración lentos y el ciclo muere.

  • Dummy: se pasa para rellenar un parámetro, nunca se usa.
  • Stub: devuelve respuestas prefabricadas (gateway.charge() siempre devuelve ok).
  • Fake: implementación funcional en memoria (un repo in-memory en lugar de Postgres).
  • Mock: un stub que además registra llamadas, para afirmar “¿se invocó sendEmail una vez con el id del pedido?”.

Usa stubs para valores que entran, mocks con moderación para comandos que salen. Sobre-mockear es el modo de fallo clásico de TDD — si cada test afirma mock.method.calledWith(...), estás testeando el mock, no el comportamiento. La receta de mocking de APIs cubre los patrones prácticos para fronteras HTTP.

Aislamiento de Tests y Estado Compartido

Nada mata una práctica de TDD más rápido que una suite dependiente del orden — tests que pasan solos pero fallan juntos. La causa es casi siempre estado mutable compartido: un fixture a nivel de módulo, una fila de base de datos reutilizada, un singleton que modificó el test anterior.

  • Cada test construye su propio mundo. Objetos frescos por test ganan a singletons en setUp — los milisegundos extra cuestan menos que depurar dependencia de orden.
  • Helpers factoría antes que archivos de fixtures. Un make_user(plan="pro") sigue siendo legible cuando un JSON de 200 líneas se vuelve write-only.
  • Vigila el estado global. Cachés, variables de entorno y la hora del sistema son los escapes habituales; inyecta un reloj en lugar de llamar a now() directamente.
  • Si los tests solo fallan en secuencia, haz bisección ejecutando pares hasta que aparezca el test que gotea — luego arregla la fuga, no marques la víctima como skip.

TDD Outside-In vs Inside-Out

Dos escuelas discrepan sobre dónde va el primer test:

  • Inside-out (clasicista / escuela de Detroit): empieza por la unidad más interna — la calculadora, el carrito — y construye hacia fuera. Rápido y mantiene los tests cerca del código, pero arriesgas construir unidades elegantes que nadie llama.
  • Outside-in (mockista / escuela de Londres): empieza con un test de aceptación fallido en la frontera — un endpoint HTTP, un comando CLI — y luego haz TDD hacia dentro. Cada capa necesita un colaborador que aún no existe, así que lo stubeas hasta que un ciclo más profundo lo construye. El artículo Mocks Aren’t Stubs de Fowler (enlazado abajo) es la referencia clásica.

En la práctica la mayoría de equipos los combinan: un test de aceptación outside-in a nivel de funcionalidad para mantener el trabajo honesto, y ciclos inside-out dentro de cada clase. El test de aceptación te da el rojo; los ciclos unitarios guían la implementación por debajo.

TDD vs BDD

TDDBDD
El test se escribe comoassert cart.total() == 900Dado un carrito, cuando aplico SAVE10, entonces el total es 900
Objetivo principalDiseño + seguridad contra regresionesEspecificación compartida entre devs y no devs
Nivel de testUnitarioAceptación / integración (a menudo)
Herramientaspytest, Jest, JUnitCucumber, Behave, SpecFlow, describe/it de Jest

El mismo comportamiento del descuento en BDD tiene pinta de spec que un product owner puede leer:

Scenario: Código de descuento aplicado al carrito
  Given un carrito con un laptop de $1,000
  When aplico el código "SAVE10"
  Then el total es $900

Se combinan: guía el flujo a nivel de servicio con escenarios BDD y los internos con ciclos TDD. No eliges uno — los apilas. El error que cometen los equipos es tratar describe/it o la sintaxis Given-When-Then como BDD por sí misma: renombrar un test unitario con vocabulario de negocio no lo convierte en una spec de comportamiento. BDD se gana su nombre cuando el escenario se acordó con alguien que no escribe código — un PM, un QA, un cliente.

Beneficios de TDD

BeneficioCómo TDD te lo da
ConfianzaCada funcionalidad está respaldada por un test que prueba que funciona
Presión de diseñoEl código debe ser testeable, lo que tiende a diseños desacoplados y modulares
DocumentaciónLos tests son ejemplos ejecutables de cómo se usa el código
Seguridad de regresiónLos cambios son seguros porque los tests existentes detectan roturas
Tiempo de depuraciónLos bugs se cazan al instante, no días después

Los estudios de caso de Microsoft e IBM (Nagappan et al., 2008) reportaron reducciones de densidad de defectos de entre ~40 % y 90 % en equipos TDD — a costa de un 15-35 % más de tiempo de desarrollo. Tu kilometraje variará, pero la dirección del intercambio es consistente.

Errores Comunes de TDD

  • Testear la implementación, no el comportamiento — afirma sobre valores de retorno, no sobre estado interno. Consulta unit testing.
  • Escribir demasiados tests antes del código — mantén el ciclo corto (minutos, no horas).
  • Saltarse el paso de refactor — el tercer paso es donde mejora el código limpio.
  • Testear getters/setters triviales — céntrate en lógica y decisiones.
  • No ejecutar los tests con frecuencia — si escribes 50 líneas sin ejecutarlos, no estás haciendo TDD.
  • Una suite en la que nadie confía — los tests flaky se ignoran, y un test ignorado es peor que ninguno. Cuarentena el flake y arréglalo; no dejes que xfail/skip se acumulen.
  • Perseguir 100 % de cobertura como meta — la cobertura mide lo que ejecutaste, no lo que verificaste. Una suite al 70 % que cubre la lógica arriesgada gana a un 95 % inflado.
  • Un test gigante que hace de todo — cuando falla no sabes por qué. Un concepto por test.

TDD vs Escribir Tests Después

Tests Unitarios TradicionalesTDD
Cuándo se escriben los testsDespués del códigoAntes del código
Cobertura de testsA menudo incompletaExhaustiva por diseño
Influencia en el diseñoMínimaImportante (la testabilidad guía el diseño)
Esfuerzo de depuraciónMayorMenor

Escribir tests después no es inútil — simplemente elimina la presión de diseño. Acabas verificando la implementación que escribiste en lugar del comportamiento que querías especificar. La peor versión es el retrofit de tests a fin de sprint: la cobertura cayó en un dashboard y alguien escribe afirmaciones en masa contra código ya mergeado. Esos tests tienden a fijar lo que el código hace hoy, bugs incluidos — miden el pasado en lugar de dar forma al siguiente cambio.

Un caso legítimo de test-after: corrección de bugs en código sin tests. Escribe primero la reproducción (debería fallar — ese es tu rojo), corrígela y guarda el test como guardia de regresión. Eso es TDD aplicado a un alcance estrecho, y funciona.

Cuándo TDD Funciona Mejor

Usa TDD para:

  • Lógica de negocio con entradas y salidas claras
  • Código algorítmico
  • APIs y fronteras de servicio
  • Código que esperas cambiar con frecuencia
  • Corrección de bugs — escribe primero el test que reproduce el fallo

Usa con cautela en:

  • Componentes de UI (los tests de componente o visuales encajan mejor)
  • Prototipos exploratorios y spikes donde el resultado esperado aún se desconoce
  • Código legacy muy acoplado (refactoriza hacia testabilidad primero)
  • Migraciones de datos — verifica la salida una vez, no hagas TDD de un script de un solo uso

TDD en el Frontend

La lógica a nivel de componente se deja hacer TDD sin problemas — validación, formateo, reducers y transiciones de estado tienen entradas y salidas claras. Lo que se resiste es el detalle de renderizado: posiciones en píxeles, timing de animaciones, layout. Para eso, los tests de componente (Testing Library) o los checks E2E verifican lo que el usuario realmente ve. El reparto práctico: TDD para la lógica dentro del componente, tests separados para el renderizado.

TDD sobre Código Legacy

Rara vez tienes un greenfield. Para módulos legacy, el enfoque de Michael Feathers es el práctico: primero escribe tests de caracterización que fijen el comportamiento actual (aunque sea feo) y luego aplica TDD a la funcionalidad nueva sobre esa red de seguridad. No intentes cubrir todo el módulo — cubre solo la costura que toca tu cambio.

Feathers llama al punto donde rompes una dependencia una costura (seam) — un lugar donde el comportamiento puede cambiar sin editar el sitio de la llamada. La inyección por constructor es la costura más barata que existe: pasa el colaborador como parámetro en lugar de hacer new dentro de la clase. En cuanto puedes sustituir la dependencia, la superficie testeable crece rápido.

Llevar TDD a un Equipo

Adoptar TDD es un problema social más que técnico. Lo que suele funcionar:

  • Empieza donde duele. Elige el módulo que más se rompe y haz TDD en el siguiente cambio que le toque. Una victoria visible en un archivo doloroso convierte escépticos más rápido que cualquier presentación.
  • Haz los primeros ciclos en pair. La incomodidad del test-first se desvanece más rápido junto a alguien que ya lo ha hecho que solo con un tutorial.
  • Mantén el ciclo rápido. El feedback lento mata la adopción antes de que se forme el hábito — si la suite tarda diez minutos, la gente dejará de ejecutarla antes de dejar de creer en ella.
  • Revisa los tests como código de producción. Un test sin afirmaciones, o uno que re-implementa el código que verifica, no debería pasar revisión.
  • Mide escapes de defectos, no cobertura. Un mandato de cobertura se juega; el conteo de bugs que llegan a producción te dice si TDD funciona de verdad.
  • Acepta la curva de arranque. La velocidad baja unas semanas mientras se forma el hábito; planifícalo en lugar de tratar el bache como prueba de que no funciona.

TDD en Producción: Una API de Pagos

En un servicio de pagos con quince endpoints, el equipo adoptó red-green-refactor estricto para el endpoint de transferencias. Un ciclo fue así:

  1. RED — test: transferir $100 entre dos cuentas del mismo usuario → falla, la ruta no existe.
  2. GREEN — un POST /api/transfers mínimo que ejecuta la transferencia → pasa.
  3. REFACTOR — extraer la validación de saldo a su propia función → pasa, sin cambio de comportamiento.

La suite que salió de ese ciclo tiene esta pinta:

// RED: Test de transferencia (falla antes de que exista el endpoint)
describe("POST /api/transfers", () => {
  it("debe transferir entre cuentas del mismo usuario", async () => {
    const res = await request(app)
      .post("/api/transfers")
      .set("Authorization", `Bearer ${userToken}`)
      .send({
        sourceAccountId: accountA.id,
        destinationAccountId: accountB.id,
        amount: 100,
        currency: "USD"
      });
    expect(res.status).toBe(201);
    expect(res.body.status).toBe("completed");
    // Verificar saldos actualizados
    const src = await getAccount(accountA.id);
    const dst = await getAccount(accountB.id);
    expect(src.balance).toBe(originalA - 100);
    expect(dst.balance).toBe(originalB + 100);
  });

  it("debe rechazar transferencia a cuenta ajena", async () => {
    const res = await request(app)
      .post("/api/transfers")
      .set("Authorization", `Bearer ${userToken}`)
      .send({
        sourceAccountId: accountA.id,
        destinationAccountId: otherUserAccount.id,
        amount: 100,
        currency: "USD"
      });
    expect(res.status).toBe(403);
  });

  it("debe rechazar un monto negativo", async () => {
    const res = await request(app)
      .post("/api/transfers")
      .set("Authorization", `Bearer ${userToken}`)
      .send({
        sourceAccountId: accountA.id,
        destinationAccountId: accountB.id,
        amount: -50,
        currency: "USD"
      });
    expect(res.status).toBe(400);
  });
});

Las lecciones que quedaron: RED primero — el test define el comportamiento esperado. GREEN mínimo — no añadas lógica extra hasta que un test la pida. REFACTOR seguro — los tests protegen el cambio. Un test por comportamiento, no por método: debe rechazar transferencia a cuenta ajena describe comportamiento, no un detalle de implementación. Y cero flakes — cada test es determinista y no necesita servicio externo, así que la suite sigue siendo confiable.

Tres meses después la suite rondaba los 400 tests ejecutándose en menos de dos minutos. El endpoint de transferencias cambió de manos dos veces en ese periodo y ningún traspaso envió una regresión. La regla que los salvó: nunca commitear en rojo. Si la suite se ponía roja en main, la primera respuesta era un revert y las preguntas venían después. Solo esa regla mantuvo la confianza en la suite — una suite en la que todos creen vale más que cualquier métrica.

El Flujo de Trabajo TDD, Condensado

  1. Elige el comportamiento más pequeño que falte.
  2. Escribe un test que falle por él — y confirma que falla por la razón correcta.
  3. Escribe el código mínimo para pasar. Resiste pulirlo.
  4. Refactoriza libremente mientras la barra siga verde.
  5. Ejecuta los tests vecinos, no solo el que acabas de escribir.
  6. Commitea solo en verde.

Esa es toda la práctica — decenas de estos micro-ciclos por hora de trabajo. Todo lo demás de esta guía es la disciplina que mantiene honesto cada paso.

Lo que Funciona

  • Mantén los tests rápidos — una suite lenta desanima; bajo ~3 s por archivo mantiene el ciclo vivo.
  • Un concepto por test — un fallo debe señalar exactamente un problema.
  • Usa nombres descriptivos — el nombre debe decir el escenario y el resultado esperado.
  • Evita interdependencia — cada test crea su propio estado.
  • Refactoriza también los tests — la preparación duplicada es un smell; usa fixtures y helpers.
  • Empieza donde duele — aplica TDD primero al módulo que más se rompe, no a un archivo de mascota en greenfield.
  • Haz el primer ciclo en pair — la incomodidad del test-first desaparece más rápido con un compañero que con un tutorial.

Lecturas Recomendadas

Preguntas frecuentes

¿TDD ralentiza el desarrollo?

Al principio sí — cuenta con un 15-35 % más de tiempo escribiendo la funcionalidad. Lo recupera en depuración reducida y refactor más seguro; los estudios de Microsoft/IBM citados arriba midieron grandes reducciones de defectos contra ese coste inicial.

¿Qué pasa si aún no sé cómo debería ser la API?

TDD es una herramienta de diseño — escribir el test primero te ayuda a descubrir la forma de la API. Si de verdad estás explorando, haz un spike rápido sin tests y reescribe con TDD cuando entiendas el problema.

¿Debería usar TDD en cada función?

No. Céntrate en código con comportamiento que valga verificar. Objetos de datos simples, configuración y funciones de paso raramente necesitan tests dedicados.

¿Cuándo NO usar TDD?

Sáltalo en exploración (spikes, POCs) donde el resultado esperado es desconocido, en scripts de un solo uso y migraciones de datos, y en prototipos tempranos de UI. Para bugs, haz lo contrario — escribe primero el test que reproduce el fallo, luego corrige.

¿TDD significa que cada línea necesita un test unitario?

No. TDD significa que el test guía el diseño de lo que escribes; la cobertura es un efecto lateral, no el objetivo. Las rutas de integración, el wiring y la configuración se cubren mejor con tests de integración o de contrato — la guía de estrategia de testing mapea la pirámide.