El día que escribir tests me obligó a entender mi propio código

Este artículo puede incluir enlaces de afiliado. Si compras a través de ellos, me llevo una pequeña comisión sin coste extra para ti. Más información.

Hay una mentira que nos contamos los programadores: «esto lo entiendo perfectamente, lo escribí yo». Yo me la creía. Hasta que me senté a escribir los tests de una clase mía y me di cuenta de que no sabía explicarle al test qué demonios hacía ese código.

No fue un bug. Fue peor. Fue silencio. Esa sensación de quedarte mirando la pantalla pensando «vale, ¿y ahora qué assert pongo aquí?». Y no tener respuesta.

Lo que descubrí ese día es algo que ya intuía pero no había vivido con tanta claridad: el test no era el problema. El test era la señal. Y la señal decía que mi código no se entendía ni yo, que lo había parido.

La clase que creía dominar

Era una clase de unas doscientas líneas. Se encargaba de calcular el estado de una reserva según un montón de condiciones: fechas, disponibilidad, pagos parciales, cancelaciones. Cosas de negocio normales y aburridas.

La había escrito yo unos meses antes. Funcionaba. En producción no había dado guerra. Así que cuando me tocó añadir tests porque el equipo quería subir la cobertura, pensé que sería un rato.

Spoiler: no fue un rato.

El problema apareció en cuanto intenté nombrar el primer test. Quería escribir algo tipo deberiaMarcarReservaComoConfirmadaCuandoElPagoEstaCompleto. Pero al ir a montar el escenario, me encontré con que para que el pago estuviera «completo» tenía que tocar tres campos distintos. Y uno de ellos dependía de otro objeto que la clase pedía por dentro.

Ahí ya algo olía raro. Si para probar una sola cosa necesito preparar medio mundo, es que esa cosa no está tan aislada como yo creía.

El método que hacía demasiado

El núcleo del desastre era un método llamado procesarReserva. Y ese nombre, visto con perspectiva, ya era una confesión. «Procesar» no significa nada. Es el verbo que usas cuando no sabes qué hace de verdad tu código.

Ese método calculaba el estado, pero también actualizaba la fecha de modificación, también disparaba un evento de notificación, y de paso decidía si había que devolver dinero. Todo junto. Todo revuelto.

Yo lo había escrito así porque en su momento «tenía sentido». Iba añadiendo cosas según me las pedían. Y cada añadido, por separado, parecía razonable. El conjunto era un monstruo.

Aquí está, simplificado, lo que me encontré. Mira cuántas responsabilidades caben en un solo método:

public Reserva procesarReserva(Reserva reserva, Pago pago) {
    if (pago.getImporte() >= reserva.getTotal()) {
        reserva.setEstado(Estado.CONFIRMADA);
    } else if (pago.getImporte() > 0) {
        reserva.setEstado(Estado.PARCIAL);
    } else {
        reserva.setEstado(Estado.PENDIENTE);
    }
    reserva.setFechaModificacion(LocalDateTime.now());
    if (reserva.getEstado() == Estado.CONFIRMADA) {
        notificador.enviar(reserva.getCliente());
    }
    if (pago.getImporte() > reserva.getTotal()) {
        contabilidad.registrarDevolucion(reserva, pago);
    }
    return reserva;
}

¿Ves el LocalDateTime.now() ahí en medio? Eso solo ya hace el test casi imposible de verificar con precisión. ¿Y el notificador y la contabilidad? Dependencias escondidas que tengo que mockear para probar algo que, en teoría, era un simple cálculo de estado.

Cuando escribir tests te obliga a entender tu propio código

Aquí es donde la cosa se puso interesante. Porque lo fácil habría sido pelearme con el test. Montar un mock gigante, inyectar un reloj falso, simular la contabilidad, y conseguir que la barra de cobertura se pusiera verde.

Lo hice, de hecho. Durante media hora. Y el test que me salió era horrible. Veinte líneas de preparación para tres líneas de comprobación. Frágil. Imposible de leer dentro de seis meses.

Y entonces caí en algo que tardé demasiados años en interiorizar: cuando un test es difícil de escribir, casi nunca es culpa del test. Es el diseño el que está gritando.

«Legacy code is code without tests.»

— Michael Feathers

Feathers lo dice en Working Effectively with Legacy Code, y durante años lo entendí al revés. Creía que significaba «ponle tests al código viejo». Lo que de verdad significa es que código sin tests es código que no controlas, aunque lo hayas escrito tú esta misma mañana. Mi clase de doscientas líneas era legacy a las dos semanas de nacer.

El test no estaba siendo difícil porque yo fuera mal testeador. Era difícil porque el método mezclaba decisiones, efectos secundarios y dependencias en el mismo sitio. El dolor de testear era la forma que tenía el código de avisarme.

A programmer staring at messy code on screen, warm desk lamp, late night, cat watching nearby, cinematic
Imagen generada con IA

La señal que llevaba meses ignorando

Lo más incómodo no fue el método feo. Fue darme cuenta de que cada vez que había tocado esa clase en los meses anteriores, había sentido un pellizco. Una duda pequeña. «¿Esto debería ir aquí?». Y la había ignorado siempre.

El test solo hizo explícito lo que mi intuición ya sabía. Ya hablé de esa sensación de inseguridad cuando conté por qué a veces dudo más cuanto más programo, y esto era exactamente lo mismo, pero esta vez con una herramienta que no me dejaba mirar para otro lado.

Un test no opina. Un test no te deja decir «bueno, ya lo arreglaré». O pasa o no pasa. Y si para que pase tienes que hacer contorsiones, la contorsión es el diagnóstico.

Separar lo que decide de lo que actúa

Lo que hice fue parar de testear y empezar a entender. Cogí papel, sí, papel de verdad, y escribí qué cosas hacía ese método. Salieron cuatro: decidir el estado, marcar la fecha, notificar, y registrar devoluciones.

De esas cuatro, solo una era una decisión pura: calcular el estado a partir del pago y el total. Las otras tres eran efectos. Cosas que pasan en el mundo. Y mezclar decisiones con efectos es la receta del código intestable.

Así que extraje la decisión a su propio método, sin dependencias, sin relojes, sin notificadores. Una función que recibe datos y devuelve un estado. Nada más:

public Estado calcularEstado(BigDecimal importePagado, BigDecimal total) {
    if (importePagado.compareTo(total) >= 0) {
        return Estado.CONFIRMADA;
    }
    if (importePagado.compareTo(BigDecimal.ZERO) > 0) {
        return Estado.PARCIAL;
    }
    return Estado.PENDIENTE;
}

Esto sí se testea. Sin mocks, sin trampas. Le metes números, compruebas el estado que sale. Tres líneas de test por caso y se acabó. La lógica de negocio, que era lo importante, quedó al descubierto y verificada.

Los efectos, las notificaciones y las devoluciones, los dejé fuera, en una capa que orquesta. Esa capa sí necesita sus propios tests, pero distintos, y ya no contaminan la parte que de verdad decide.

El cambio de chip que cuesta más de lo que parece

Separar decisiones de efectos suena obvio cuando lo lees. Funciones puras por un lado, efectos por otro. Es casi un eslogan. Pero hacerlo con tu propio código, ese que escribiste convencido de que estaba bien, duele un poco en el ego.

Porque implica admitir que lo hiciste regular. Que el «yo de hace tres meses» no era tan listo. Y eso, aunque parezca una tontería, cuesta.

A mí me ayudó dejar de verlo como un fallo y empezar a verlo como información. El código viejo no es estúpido, es código que se escribió con la información que había entonces. El test de hoy tiene más contexto. Normal que vea cosas que ayer no veía.

Tengo a Bella, la veterana de casa, casi ocho años ya, durmiendo en la silla de al lado mientras escribo esto. Y pienso que con el código pasa un poco como con ella: lo que hace meses parecía un comportamiento raro, con el tiempo entiendes que tenía toda su lógica. Solo te faltaban datos.

La herramienta importa menos que la pregunta

Por aquellas fechas andaba leyendo sobre frameworks de testing, porque en el trabajo discutíamos si el equipo de front debía moverse a algo más moderno. Cayó en mis manos un artículo de Stack Overflow comparando Selenium, Cypress y Playwright para automatización de tests.

Y aunque hablaba de tests end-to-end, que es otro mundo distinto al unitario que yo tenía entre manos, me quedé con una idea de fondo. Todos esos frameworks resuelven el «cómo». Ninguno resuelve el «qué».

Puedes elegir Playwright porque es rápido, o Cypress porque tiene buena experiencia de desarrollo, o Selenium porque ya lo conoces. Bien. Pero si no sabes qué estás intentando verificar, ningún framework te salva.

Mi problema de aquel día no se habría arreglado con una herramienta mejor. JUnit estaba perfecto. El problema era que yo no tenía clara la pregunta. No sabía qué quería afirmar sobre mi código porque no entendía qué hacía mi código.


Foto: turned on gray laptop computer por Luca Bravo en Unsplash.

¿Quieres más?

Recibe los nuevos artículos sobre gatos, escritura y código. Sin frecuencia fija, sin paja de IA, sin spam.

Suscribirme

1 comentario en “El día que escribir tests me obligó a entender mi propio código”

  1. Luis Vilas Buendia

    Muchas veces la respuesta aparece en forma de pregunta. Una buena pregunta, y más si es escrita a mano (ya sé que los que escribís codigo no van por ahí los tiros) ayuda a reconectar puntos y puede dar respuestas muy clarificadoras o que permiten clarificar el problema

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio