8 min read

Mejora tus pruebas unitarias 10x

Las pruebas unitarias son una herramienta esencial para cualquier desarrollador que quiera escribir código mantenible, seguro y confiable. Sin embargo, no basta con tener una suite que cubra un alto porcentaje del código; lo verdaderamente importante es la calidad de esas pruebas.
Mejora tus pruebas unitarias 10x
Cómo mejoré mis pruebas unitarias

Hola, bienvenido a un nuevo post!

Hoy quiero hablar de un tema muy importante, que no puede faltar en un desarrollo serio de software: las pruebas unitarias. Las pruebas unitarias son código que escribimos para validar la lógica de nuestro programa. Estas pruebas se caracterizan por evaluar partes pequeñas del código, como una función o una clase.

Además, las pruebas unitarias son muy útiles para realizar refactorizaciones a futuro sin temor a que, durante este proceso, deje de funcionar lo que ya teníamos correctamente. Y es que, en realidad, creo que todos hemos trabajado en algún proyecto donde el equipo tiene miedo de modificar ciertas partes del código, porque no sabemos si lo que vamos a cambiar afectará reglas de negocio ya implementadas, provocando comportamientos extraños y los temidos bugs.

El objetivo de estas pruebas es que tengas confianza en lo que escribes y que entregues la menor cantidad posible de errores. Sin embargo, a veces los proyectos ya cuentan con pruebas unitarias, e incluso con una suite extensa, pero el simple hecho de tenerlas no garantiza que estés cubierto. Esto sucede porque las pruebas están desactualizadas o solo se han ido “parcheando” con cada cambio, buscando que pasen sin fallar y que el proceso de CI no se queje, lo que permite seguir con el flujo de trabajo sin más.

Esto es un claro síntoma de que tus pruebas unitarias se han convertido en una carga, y todo el equipo las percibe de esa manera. Hacer pruebas se ve como una tarea ardua sin recompensa. Y en estos casos, es cierto: las pruebas solo ralentizan el desarrollo. Podemos evitar esta situación cambiando nuestra perspectiva: las pruebas unitarias son una parte más del sistema y deben recibir el mismo cuidado que el código principal.

El hecho de que no se ejecuten en producción no significa que deban quedar olvidadas después de haberlas escrito una vez. Deben mantenerse con atención, actualizarse de forma consciente y no solo para que el reporte salga “en verde”. Por ejemplo, si eliminamos un método o clase, hay que eliminar las pruebas relacionadas; si cambia una regla de negocio, es normal que las pruebas fallen al principio, pero debemos corregirlas según la nueva lógica. No debemos ocultar el problema real con trucos para que las pruebas pasen, o peor aún, borrarlas o comentarlas.

Y como las pruebas unitarias son importantes, aquí tengo algunos consejos que he aprendido y que pueden ayudarte a mejorarlas y a sentirte más seguro con tu código.

⚠️
Disclaimer: Las pruebas unitarias por sí solas no prueban todo el sistema. Es importante complementar con otros métodos de prueba, como pruebas E2E, pruebas manuales con el equipo de QA (si cuentas con uno), entre otros. Sin embargo, las pruebas unitarias siempre serán nuestra primera red de protección ante los cambios.

Puntos

  1. Herramientas
  2. ¿Cómo nombrar nuestro sujeto de prueba?
  3. Escribe un buen mensaje para tu prueba
  4. ¿Cómo evitar la fragilidad en nuestros tests?
  5. ¿Qué son los mocks y cuando usarlos?
  6. ¿Que porcentaje de cobertura debe tener mi código?

Herramientas

En estos ejemplos voy a utilizar JavaScript, pero los conceptos son agnósticos, ya que JavaScript es solo una herramienta para comunicar las ideas.

En JavaScript existen librerías populares con las que puedes crear pruebas para tu código. Sin embargo, es importante hacer una distinción entre un Test Runner y una Assertion Library.

Un Test Runner proporciona la infraestructura necesaria para ejecutar las pruebas de manera aislada, algunas incluso en paralelo. También incluye utilidades para hacer debugging, entre otras características. Algunos ejemplos son:

  • Jest
  • Mocha

Por otro lado, las librerías de assertions pueden venir integradas dentro del test runner (como ocurre con Jest) o ser externas, en cuyo caso deberás instalarlas por separado. Algunos ejemplos de estas son:

  • Chai
  • Assert Module (incluido en Node.js ≥ v18)

Estas herramientas te ofrecen un conjunto de funciones para validar lo que necesites: el contenido de un objeto, una fecha, una cadena de texto, etc.

Te dejo un ejemplo de código:

const assert = require('node:assert/strict');

assert.deepEqual([[[1, 2, 3]], 4, 5], [[[1, 2, '3']], 4, 5]);
// AssertionError: Expected inputs to be strictly deep-equal:
// + actual - expected ... Lines skipped
//
//   [
//     [
// ...
//       2,
// +     3
// -     '3'
//     ],
// ...
//     5
//   ]

Código tomado de la documentación de Nodejs https://nodejs.org/docs/latest-v22.x/api/assert.html#assert

En este ejemplo esperamos que el array de la izquierda tenga los mismos elementos que el de la derecha.

Y te preguntarás, realmente necesito estas librerías si puedo realizarlo manualmente. Y la respuesta es que si y no, podrías hacer tus propios métodos para validar inputs, etc. O ahorrar tiempo y tener algo que funciona con una librería.

Entonces, para iniciar necesitas conocer cómo configurar estas herramientas. No es difícil, con la documentación es más que suficiente para guiarte. Yo utilizo Jest en los proyectos que realizo porque es el que viene por defecto en NestJS y ya no tienes que instalar librerías de assertion.

¿Cómo nombrar nuestro sujeto de prueba?

Al sujeto de prueba también podemos llamarlo SUT (System Under Test). Es una forma de referirse a él que me gustó cuando leí el libro Unit Testing, y desde entonces la adopté. Me parece que así queda claro quién está siendo probado y se evita confusión con otros nombres que puedan surgir en el test.

Claro, esto es solo una preferencia personal. Tú eres libre de nombrarlo como prefieras, pero siempre trata de que sea evidente qué parte del sistema estás probando.

// calculator.js
class Calculator {
	sum(x,y) {
		return x + y;
	}
}

// calculator.spec.js
describe('Calculator', () => {
	let SUT // System under test
	
	beforeEach(() => {
		SUT = new Calculator()
	})
	
	afterEach(() => {})
})

Escribe un buen mensaje para tu prueba

class EmailSender {
	send(to, content) {
		// validations
		this.provider.send(to, content)
	}
}

describe('EmailSender', () => {
	let SUT // System under test
	
	beforeEach(() => {
		SUT = new EmailSender()
	})
	
	afterEach(() => {})
	
	// ❌ No descriptivo.
	it('test method send()', () => {})
	
	// ✅ Buena descripción
	it('send(): falla por email invalido', () => {})
	it('send(): falla por contenido invalido', () => {})
})

En el ejemplo anterior nos damos cuenta de que podemos nombrar nuestros tests de cualquier manera, pero al final, quienes terminamos afectados por mensajes con descripciones pobres somos nosotros o nuestros compañeros.

Imagina que alguien externo llega a trabajar en el proyecto y, al modificar la clase EmailSender, algo falla. Entonces aparece en la terminal un test con un mensaje como: test method send(). Yo me arrancaría el cabello tratando de entender, primero, para qué sirve ese test y, segundo, cómo arreglarlo.

En cambio, si los tests tienen una buena descripción —como en los ejemplos que siguen— te das una idea rápida de qué pudo haber fallado, y haces que la experiencia de debugging sea mucho más agradable.

¿Cómo evitar la fragilidad en nuestros tests?

Existe un enfoque que te ayudará a mejorar mucho tus pruebas y a evitar que estas sean frágiles, que aparezcan falsos positivos o que sea difícil hacer refactorizaciones.

Muchas veces, al escribir tests, nos damos cuenta de qué tan bien hemos aplicado buenas prácticas. Esto sucede porque, al probar nuestro código, debemos tratar de aislar lo más posible esa unidad de código del resto del sistema.

Con esto me refiero a que, si nuestra función o clase utiliza recursos externos como APIs u otras clases, y no dependemos de abstracciones sino de clases concretas, estaremos agregando fragilidad a las pruebas. En cualquier momento en que se actualicen esas dependencias, los tests pueden fallar por razones que no corresponden al alcance de la prueba unitaria. Esto introduce falsos positivos que, si no se abordan a tiempo, hacen que las pruebas se vuelvan insostenibles.

Cuando dependemos de abstracciones, escribir una prueba para un componente con dependencias se vuelve mucho más sencillo: podemos reemplazarlas fácilmente y enfocarnos en probar lo que realmente importa, como las reglas de negocio, la lógica o el algoritmo.

// EJEMPLO: Depende de clase concreta
class EmailSender {
	constructor() {
		this.provider = new SNSAWSService() // depende de clase concreta
	}
	
	send(to, content) {
		// validations
		this.SNSServiceAws.send(to, content)
	}
}

// EJEMPLO: Utiliza Principio SOLID, facil testear
class EmailSender {
	constructor(private provider: EmailProviderInterface) {}
	
	send(to, content) {
		// validations
		this.provider.send(to, content)
	}
}

Este detalle lo podemos resolver mediante inyección de dependencias y aplicando el principio SOLID de Inversión de Dependencias, el cual nos indica que debemos depender de abstracciones. En este caso, eso se traduce en utilizar una interfaz.

Además, algo que me ha servido mucho es cambiar la perspectiva de cómo escribo mis pruebas. Ahora me enfoco en probar lo que quiero que haga, en el resultado, y ya no me preocupo tanto por cómo tiene que hacerlo.

Existe una gran diferencia en este enfoque: al cambiar la perspectiva, dejamos de lado el detalle de la implementación —los pasos exactos del algoritmo— y nos centramos en que el resultado deseado sea correcto. Al reflejar esto en nuestras pruebas, logramos una mayor robustez y resistencia ante cambios en el código.

💡
No pierdas tiempo probando detalles de implementación que podrían cambiar con el tiempo. Tu objetivo debe ser asegurarte de que el sistema haga lo que se espera que haga desde el punto de vista funcional. Esto te permitirá mantener pruebas más estables y significativas a largo plazo.

¿Qué son los mocks y cuando usarlos?

Los mocks son, de forma coloquial, funciones o clases que reemplazan las dependencias reales en nuestras pruebas. Su propósito es ayudar a aislar el código y evitar que se utilicen los servicios reales.

Imagina que necesitas enviar un correo electrónico, un SMS, etc. Una buena práctica sería no usar los servicios reales por varios motivos: costos, usuarios notificados erróneamente, y porque es mucho más sencillo reemplazarlos para tener control sobre lo que sucede con nuestro código.

Sin mocks, los tests serían demasiado impredecibles, ya que estarían dependiendo de componentes externos.

Jest cuenta con sus propios métodos para crear mocks y spies.

La diferencia entre ellos es que, con los spies, puedes hacer un seguimiento de las llamadas al mock: ver con qué datos se llamó, cuántas veces, en qué orden, etc.

Un detalle importante antes de pasar al ejemplo:

No siempre necesitas hacer asserts sobre los mocks.

Esto se debe a que, en muchos casos, no es relevante para la lógica que estás probando. Además, hacer asserts innecesarios puede introducir fragilidad en tus pruebas, ya que los mocks simulan dependencias externas que pueden cambiar con el tiempo.

Sin embargo, sí tiene sentido hacer asserts sobre los mocks cuando son relevantes para las reglas de negocio.

Por ejemplo: si tu aplicación debe enviar un correo electrónico cuando se crea un usuario, es importante verificar que el servicio de email se haya llamado, y con los datos correctos.

Esto te ayuda a confirmar que tu código realmente ejecuta las acciones clave para tu negocio.

Ahora sí, vamos con el ejemplo.

// notification.test.js
import * as emailService from './emailService.js';
import { notifyNewUser } from './notification.js';

const SUT = notifyNewUser

beforeEach(() => {
  // Restauramos el comportamiento original
  spy.mockRestore();
})

it('notifyNewUser: envia un correo de bienvenida', async () => {
  // Espiamos sendEmail y evitamos la llamada real
  const spy = jest
    .spyOn(emailService, 'sendEmail')
    .mockResolvedValue('spy-ok');

  const user   = { email: '[email protected]', name: 'Axel' };
  const result = await SUT(user);

  // Verificamos parámetros y valor de retorno
  expect(spy).toHaveBeenCalledWith(
    '[email protected]',
    '¡Bienvenido!',
    'Hola Axel, gracias por unirte.'
  );
  
  expect(result).toBe('spy-ok');
});

Cobertura de código

Por último, en cuanto a temas de cobertura de código, considero que tener una suite que cubra alrededor del 80 % del código es algo muy bueno.

Pero hay que tener en cuenta que, al imponer un número, no debemos olvidar lo más importante: cuidar la calidad de nuestros tests.

No se trata de apuntar a un porcentaje específico o de superarlo, sino de enfocarnos en tener buenas pruebas. Y como consecuencia de eso, de forma casi accidental, terminaremos alcanzando ese porcentaje o quedando muy cerca.

Siempre es importante escuchar al equipo, conocer su opinión y buscar una forma en la que todos se sientan cómodos. Porque puede suceder que, al imponer un porcentaje mínimo, el equipo empiece a enfocarse únicamente en cumplirlo, escribiendo pruebas que no aportan valor real.

Conclusión

Espero que, si llegaste hasta aquí, te hayas llevado algo útil que puedas poner en práctica de inmediato.

Crear buenas pruebas unitarias requiere práctica y constancia.

Yo mismo puedo decir que todavía sigo aprendiendo a mejorar las mías, pero aplicar estos puntos sin duda me ha ayudado a mejorar muchísimo la calidad de mis tests y a evitar muchos falsos positivos.

Si tienes algún comentario, con gusto podemos conectar en LinkedIn o en X (Twitter) como @im_not_ajscoder.