Newsletter para devsEntra

12 buenas prácticas de TDD que desmontan todas las excusas

Hace unas semanas me pilló un bug en producción. De esos que te hacen sudar frío mientras miras el Slack con el ojo torcido esperando el mensaje de “oye, esto no funciona”.

El caso es que había escrito el código un viernes por la tarde. Funcionaba en local. Le di al merge con la confianza del que ha probado dos veces en el navegador. Y claro, el lunes llegó la factura.

¿Sabes qué tenían en común ese código y un castillo de naipes? Que ambos se sostienen hasta que alguien sopla. Y en mi caso, sopló un usuario con un email vacío.

No tenía tests.

Bueno, sí tenía. Unos tests que escribí después del código, mirando lo que había implementado y pensando “¿qué puedo probar aquí?”. Spoiler: no probé el caso del email vacío porque, claro, tampoco lo había contemplado al programar.

Es curioso cómo funciona el cerebro cuando escribes tests mirando tu propio código. Te conviertes en abogado defensor de tu implementación. Buscas casos que sabes que van a pasar. Evitas inconscientemente los que podrían revelar fallos. Es como hacerte un examen donde tú mismo eliges las preguntas.

Esto me llevó a replantearme cómo estaba haciendo TDD. O mejor dicho, cómo estaba fingiendo que hacía TDD mientras en realidad escribía tests después como quien pone el cinturón de seguridad al aparcar.

Así que aquí va mi compilación de 12 prácticas que he ido aprendiendo a base de golpes. Algunas las conocerás, otras te parecerán exageradas. Pero todas tienen una cosa en común: funcionan.

1. No escribas código de producción sin un test que falle primero

Esta es la regla fundamental. El mandamiento número uno. Y también la que más cuesta interiorizar porque va en contra de nuestro instinto de “déjame programar ya”.

El problema de escribir código antes que tests es que tus pruebas quedan sesgadas por lo que has construido. Testeas lo que existe, no lo que debería existir. Los casos límite que olvidaste al implementar también los olvidarás al probar.

Cuando escribes el test primero ocurre algo diferente. Tienes que pensar en el comportamiento antes que en la implementación. Es como diseñar el plano antes de poner ladrillos.

// Primero el test - ANTES de que exista validateEmail
test('rejects empty email', () => {
  expect(validateEmail('')).toBe(false);
});

// Lo ejecutas: FAIL - validateEmail is not defined
// Ahora sabes qué construir

// Implementas lo mínimo:
function validateEmail(email: string): boolean {
  if (!email) return false;
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Ejecutas de nuevo: PASS

Fíjate en la diferencia. El test falló primero por la razón correcta: la función no existía. Luego implementé y pasó. Ese ciclo rojo-verde es el corazón del TDD.

No es magia. Es metodología. Y como toda metodología, requiere práctica hasta que se convierte en hábito. Los primeros días te sentirás raro escribiendo tests para funciones que no existen. Es normal. Tu cerebro está acostumbrado a construir primero y verificar después. Cambiar ese patrón lleva tiempo.

🎯 Un test que pasa de primeras no demuestra nada. No sabes si está probando lo correcto, si detectaría un bug, o si solo verifica que tu código existe.

Aquí hay algo que podría hacer cambiar tu futuro.

Usamos cookies de terceros para mostrar este iframe (que no es de publicidad ;).

Leer más

2. Si escribiste código antes del test, bórralo

Esta práctica duele. Lo sé. Has pasado una hora escribiendo una función elegante y ahora te digo que la tires a la basura.

Pero piénsalo así: el tiempo ya lo perdiste. Lo que ganas borrando es la oportunidad de hacerlo bien. Mantener código sin tests reales es deuda técnica disfrazada de productividad.

El truco del “lo guardo como referencia” nunca funciona. Siempre terminas copiando fragmentos mientras “escribes tests primero”. Y esos tests acaban verificando tu implementación, no el comportamiento deseado.

La solución es radical pero efectiva:

  1. Borra el archivo
  2. Escribe los tests pensando en lo que DEBERÍA hacer el código
  3. Ejecuta y observa cómo fallan
  4. Reimplementa guiado por los tests

Sí, es más lento la primera vez. Pero el código resultante tiene cobertura real y los tests prueban comportamiento, no implementación.

Hay algo liberador en borrar código. Al principio duele porque asociamos líneas escritas con progreso. Pero el progreso real no se mide en líneas, se mide en problemas resueltos. Y un problema resuelto con código no testeado es un problema a medio resolver.

Me ayuda pensar en ello como un borrador de ensayo. El primer intento te sirvió para entender el problema. Ahora que lo entiendes, puedes escribirlo bien desde el principio.

3. Verifica que el test falla por la razón correcta

No todos los fallos son iguales. Hay dos tipos:

Error: El código no puede ejecutarse. Un typo, un import que falta, un TypeError inesperado.

Failure: El código ejecuta pero el resultado no es el esperado.

Solo los failures prueban comportamiento. Los errors prueban que te equivocaste al escribir.

test('user can login', async () => {
  const result = await userServce.login('test@email.com', 'password');
  //                    ^^^^^^^^^ typo: falta la 'i' en service
  expect(result.success).toBe(true);
});

// FAIL: userServce is not defined
// Esto NO es un failure útil, es un error de escritura

Cuando ves “Expected X, got Y” estás en territorio correcto. Cuando ves “X is not defined” o “Cannot read property of undefined”, tienes que arreglar el error antes de continuar.

Parece obvio, pero la cantidad de veces que he dado por bueno un test rojo sin fijarme en el mensaje de error es para echarse las manos a la cabeza.

4. Escribe el código mínimo para pasar el test

Esta práctica va de la mano con el principio YAGNI: You Ain’t Gonna Need It. O en cristiano: no vas a necesitar eso que estás añadiendo “por si acaso”.

Cuando un test te pide que implementes un retry que reintente 3 veces, no añadas también backoff exponencial, timeout configurable y callback de notificación. Eso no lo pedía el test.

// El test pide: reintentar 3 veces
test('retries failed operations 3 times', async () => {
  let attempts = 0;
  const op = () => {
    attempts++;
    if (attempts < 3) throw new Error();
    return 'ok';
  };
  expect(await retry(op)).toBe('ok');
});

// Implementa SOLO eso:
async function retry<T>(fn: () => Promise<T>): Promise<T> {
  for (let i = 0; i < 3; i++) {
    try { return await fn(); }
    catch (e) { if (i === 2) throw e; }
  }
  throw new Error('unreachable');
}

¿Necesitas backoff después? Escribe el test primero. ¿Quieres configurar el número de reintentos? Test primero. Cada feature extra que añades sin test es código sin verificar.

💡 El código mínimo no es código chapucero. Es código que hace exactamente lo que el test requiere, ni más ni menos. La diferencia entre ambos es la intención.

5. No testees el comportamiento de los mocks

Este es un error que he cometido más veces de las que me gustaría admitir. Tienes un componente que usa un Sidebar, mockeas el Sidebar, y luego escribes:

expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();

Felicidades. Has probado que tu mock existe. Eso no te dice absolutamente nada sobre si tu componente funciona con un sidebar real.

El problema de mockear es que es adictivo. Mockeas una cosa, luego otra, y cuando quieres darte cuenta tus tests verifican que sabes configurar mocks, no que tu aplicación funciona.

La regla es sencilla: si tu assertion menciona algo con “mock” en el nombre, probablemente estás testeando el mock.

// MAL: testeas que el mock existe
vi.mock('./Sidebar', () => ({
  Sidebar: () => <div data-testid="sidebar-mock">Mock</div>
}));

test('page renders sidebar', () => {
  render(<Page />);
  expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});

// BIEN: testeas el comportamiento de Page
test('page handles sidebar navigation', () => {
  render(<Page />);
  fireEvent.click(screen.getByText('Home'));
  expect(window.location.pathname).toBe('/home');
});

Si puedes evitar el mock, evítalo. Si no puedes, al menos testea cómo tu código reacciona al mock, no que el mock esté ahí.

Una buena regla es preguntarte: “¿Este test fallaría si elimino el mock y uso el componente real?”. Si la respuesta es no, probablemente estás testeando el mock. Si la respuesta es “el test sería más lento pero funcionaría igual”, quizás no necesitas el mock en primer lugar.

Los mocks son herramientas, no muletas. Úsalos cuando aporten valor: para evitar llamadas a APIs externas, para simular errores difíciles de reproducir, para acelerar tests lentos. No los uses porque sí o porque “es lo que hace todo el mundo”.

6. No añadas métodos solo para tests en código de producción

Hay una tentación cuando los tests se complican: añadir un método helper en la clase de producción para hacer cleanup o reset del estado.

class Session {
  // Este método solo lo llaman los tests
  _resetForTesting() { /* cleanup */ }
}

El problema es que ahora _resetForTesting() es parte de tu API pública. Alguien lo llamará en producción tarde o temprano. O nadie lo llamará porque “parece interno” y terminarás con memory leaks inexplicables.

La solución es mover esa lógica a utilidades de test:

// En test-utils/database.ts
export async function resetTestDatabase(db: DatabaseConnection) {
  await db.query('TRUNCATE ALL TABLES CASCADE');
}

// En tests
afterEach(() => resetTestDatabase(db));

Tu código de producción queda limpio. Tus tests tienen lo que necesitan. Todos contentos.

7. Entiende las dependencias antes de mockear

“Mockeo todo lo externo para que sea rápido” es una frase que he dicho y que ahora me avergüenza.

El problema de mockear sin entender es que puedes eliminar algo que tu test necesita para funcionar. Y entonces el test pasa, pero por razones incorrectas.

Imagina que tienes una función addServer que internamente usa ToolCatalog.discoverAndCacheTools para escribir en disco. Si mockeas ToolCatalog “por seguridad”, addServer ya no detecta duplicados porque esa detección dependía del caché en disco.

Tu test de “detecta servidor duplicado” pasa. Pero no porque funcione, sino porque el mock eliminó la lógica que lo haría fallar.

Antes de escribir vi.mock(), hazte estas preguntas:

  • ¿Qué hace esta dependencia exactamente?
  • ¿Mi test necesita ese comportamiento?
  • ¿Qué side effects tiene?
  • ¿Puedo mockear solo la parte externa (API, filesystem) en lugar de toda la clase?

⚠️ Mockear sin entender es como tapar agujeros con cinta adhesiva. Parece que funciona hasta que alguien tira del extremo equivocado.

8. Crea mocks completos, no parciales

Este error es sutil pero devastador. Creas un mock de usuario con solo los campos que necesitas:

const mockUser = { id: '123', name: 'Alice' };

Tu test pasa. Todo parece bien. Pero en algún lugar downstream, un componente hace user.permissions.canEdit y revienta con un TypeError que no viste venir.

La solución es usar factories que devuelvan objetos completos:

function createMockUser(overrides?: Partial<User>): User {
  return {
    id: '123',
    name: 'Test User',
    email: 'test@example.com',
    avatar: null,
    permissions: {
      canEdit: false,
      canDelete: false,
      canInvite: false
    },
    createdAt: new Date('2024-01-01'),
    ...overrides
  };
}

test('displays user profile', () => {
  const user = createMockUser({ name: 'Alice' });
  render(<Profile user={user} />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

Ahora cualquier acceso a cualquier campo del usuario funciona. Si añades un campo nuevo al tipo User, lo añades a la factory y todos los tests lo tienen.

9. Un test, un comportamiento

“Es más eficiente probar varias cosas juntas” es la excusa que usamos para escribir tests que hacen demasiado.

El problema aparece cuando el test falla. ¿Cuál de los cinco comportamientos que probabas está roto? No lo sabes. Tienes que investigar.

Además, si el nombre del test tiene “and” probablemente son dos tests disfrazados de uno:

// MAL: múltiples comportamientos en un test
test('validates email and password and username', () => {
  expect(form.validate({ email: '' })).toContain('Email required');
  expect(form.validate({ email: 'invalid' })).toContain('Invalid email');
  expect(form.validate({ password: '123' })).toContain('Too short');
  expect(form.validate({ password: 'nodigits' })).toContain('Needs number');
  expect(form.validate({ username: 'ab' })).toContain('Min 3 chars');
});

Cinco assertions sobre tres campos diferentes. Si falla el tercero, los dos últimos ni se ejecutan. Pierdes información.

// BIEN: cada test prueba una cosa
describe('RegistrationForm validation', () => {
  describe('email', () => {
    test('requires email', () => {
      expect(validate({ email: '' })).toContain('Email required');
    });

    test('rejects invalid email format', () => {
      expect(validate({ email: 'invalid' })).toContain('Invalid email');
    });
  });

  describe('password', () => {
    test('requires minimum 8 characters', () => {
      expect(validate({ password: '1234567' })).toContain('Too short');
    });
  });
});

Más tests, más claridad. Cuando uno falla, sabes exactamente qué está roto.

10. Testear después NO es lo mismo que TDD

Esta es quizás la distinción más importante de toda la lista. Y también la más malinterpretada.

“Al final tengo tests, ¿qué más da cuándo los escribí?” Pues da mucho.

Tests-después responden la pregunta “¿qué hace este código?”. Tests-primero responden “¿qué debería hacer este código?”. Son preguntas distintas con respuestas distintas.

Cuando escribes tests después:

  • Testeas lo que construiste, no lo que necesitabas
  • Tu mente está sesgada por la implementación
  • Los edge cases que olvidaste al programar, los olvidarás al testear
  • Nunca viste fallar el test, no sabes si detectaría un bug real
// Escribiste primero:
function divide(a: number, b: number): number {
  return a / b;
}

// Luego añadiste tests:
test('divides two numbers', () => {
  expect(divide(10, 2)).toBe(5);
  expect(divide(9, 3)).toBe(3);
});
// Tests pasan de primera
// ¿Qué pasa con divide(1, 0)? No lo pensaste.

Con TDD real, el test de división por cero aparece porque piensas en comportamiento antes de implementar:

test('throws when dividing by zero', () => {
  expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
// FAIL: no lanza error
// Ahora sí lo implementas

🔑 La diferencia entre TDD y tests-después no es cuándo escribes los tests. Es qué preguntas te haces al escribirlos.

11. Si el test es difícil de escribir, tu diseño es difícil de usar

Esta práctica me costó años entender. Cuando un test requiere 30 líneas de setup, el problema no es el test. El problema es el código que intentas probar.

El test es el primer usuario de tu código. Si necesitas inyectar 8 dependencias para probar una función, imagina usarla en producción. Si no sabes qué assertar, tu API es confusa.

// Setup enorme = tu código tiene demasiadas dependencias
test('processes order', async () => {
  const db = new MockDatabase();
  const cache = new MockCache();
  const logger = new MockLogger();
  const metrics = new MockMetrics();
  const emailService = new MockEmailService();
  const inventoryService = new MockInventoryService();
  const paymentGateway = new MockPaymentGateway();
  const shippingCalculator = new MockShippingCalculator();

  const processor = new OrderProcessor(
    db, cache, logger, metrics,
    emailService, inventoryService,
    paymentGateway, shippingCalculator
  );

  await processor.process(order);
  expect(db.save).toHaveBeenCalled();
});

Ocho mocks para probar una operación. El test te está gritando que OrderProcessor hace demasiadas cosas.

La solución no es mejorar el test. Es refactorizar el código:

// Diseño mejorado
class OrderProcessor {
  constructor(
    private orderRepository: OrderRepository,
    private paymentService: PaymentService
  ) {}
}

// Test limpio
test('saves order after successful payment', async () => {
  const repository = new InMemoryOrderRepository();
  const payment = new FakePaymentService();
  const processor = new OrderProcessor(repository, payment);

  await processor.process(order);
  expect(repository.find(order.id)).toBeDefined();
});

Dos dependencias. Setup claro. Assertion obvia. El código mejoró porque el test reveló sus problemas.

12. Los bugs se corrigen con tests, no con hotfixes

Esta es la práctica que más ROI tiene a largo plazo. Y también la que más cuesta aplicar cuando tienes a alguien respirándote en la nuca pidiendo “arréglalo ya”.

El flujo típico es:

  1. Bug reportado → Hotfix rápido → “Ya añadiré test”
  2. 3 meses después → Alguien refactoriza → Bug reaparece
  3. “Pero si ya lo habíamos arreglado…”

Sin test, el bug volverá. Quizás en una semana, quizás en seis meses. Pero volverá.

El flujo correcto es:

  1. Bug reportado
  2. Escribe un test que reproduce el bug
  3. Ejecuta y confirma que falla
  4. Arregla el código
  5. Ejecuta y confirma que pasa
// Bug: usuarios pueden registrarse con email vacío

// Paso 1: test que reproduce el bug
test('rejects registration with empty email', async () => {
  await expect(register({ email: '' }))
    .rejects.toThrow('Email required');
});

// Paso 2: ejecutar - FALLA
// Expected to throw, but didn't

// Paso 3: arreglar
function register(userData: UserData) {
  if (!userData.email?.trim()) {
    throw new Error('Email required');
  }
}

// Paso 4: ejecutar - PASA

Ahora el bug está documentado y protegido. Si alguien lo reintroduce por error, el test fallará antes de que llegue a producción.

🛡️ Un test que reproduce un bug es como una vacuna. No solo cura la enfermedad actual, previene futuras infecciones.

La excusa final: TDD me hace más lento

Esta es la madre de todas las racionalizaciones. Y la entiendo, porque yo también la usé durante años.

“Escribir tests primero duplica el tiempo de desarrollo”. Suena lógico. Dos tareas en lugar de una. Más líneas de código. Más tiempo.

Pero hagamos las cuentas reales:

Sin TDD:

  • 2 horas de código
  • 4 horas de debugging cuando algo falla
  • 2 horas arreglando bugs en producción
  • Total: 8 horas

Con TDD:

  • 3 horas de código + tests
  • 30 minutos de bugs menores
  • Total: 3.5 horas

TDD parece más lento porque el esfuerzo está concentrado al principio. Pero el tiempo total es menor porque encuentras bugs antes de hacer commit, los tests documentan el comportamiento, puedes refactorizar con confianza, y el diseño emerge más limpio.

La sensación de “lentitud” es una ilusión óptica. Estás invirtiendo tiempo ahora para ahorrarlo después. Como quien tarda más en preparar la maleta pero no olvida el cargador del móvil.

Resumen para guardar en favoritos

Práctica Cuándo aplicarla
Test primero Siempre en features nuevas y bugs
Borrar código sin tests Cuando escribiste antes de testear
Verificar razón del fallo Cada vez que ves rojo
Código mínimo Al implementar cualquier test
No testear mocks Si tienes mocks en tus tests
Sin métodos test-only Nunca en código de producción
Entender antes de mockear Antes de cada vi.mock()
Mocks completos Siempre que crees datos de prueba
Un test = un comportamiento En cada test que escribas
TDD ≠ tests después Es el principio fundamental
Test difícil = diseño difícil Cuando el setup es enorme
Bugs con tests En cada bug reportado

Por dónde empezar

Si llevas tiempo escribiendo tests después o sin ningún proceso formal, cambiar a TDD de golpe es como intentar correr un maratón sin haber trotado antes.

Mi recomendación: elige UNA feature pequeña esta semana. Aplica TDD estricto. Observa cómo cambia tu forma de pensar el código.

Cuando te tientes a saltarte alguna práctica, pregúntate cuál excusa estás usando. Probablemente esté en esta lista.

Y cuando un test sea difícil de escribir, no maldigas al framework. Agradécele que te está mostrando un problema de diseño antes de que llegue a producción.

TDD no es dogma. Es pragmatismo disfrazado de disciplina. Y como todo lo que vale la pena, cuesta al principio pero compensa al final.

Lo que más me gusta de TDD es que te obliga a pensar antes de teclear. En un mundo donde todo va rápido y la presión por entregar es constante, pararse un momento a definir qué quieres construir antes de construirlo es casi revolucionario.

No te voy a mentir: habrá días en que te saltarás los tests. Habrá features “urgentes” que saldrán sin cobertura. Habrá momentos de debilidad. Es humano. Lo importante es volver al camino cuando puedas, sin flagelarte por las desviaciones.

El objetivo no es la perfección. Es mejorar. Cada test que escribes primero es un bug que no tendrás que debuggear después. Cada práctica que incorporas es una excusa menos que podrás usar.

¿Te ha pasado algo parecido con los tests? ¿Tienes tu propia lista de excusas favoritas? Me encantaría leerlas.

Escrito por:

Imagen de Daniel Primo

Daniel Primo

CEO en pantuflas de Web Reactiva. Programador y formador en tecnologías que cambian el mundo y a las personas. Activo en linkedin, en substack y canal @webreactiva en telegram

12 recursos para developers cada domingo en tu bandeja de entrada

Además de una skill práctica bien explicada, trucos para mejorar tu futuro profesional y una pizquita de humor útil para el resto de la semana. Gratis.