Los tests no son cosa de QA: lo que tu backend no está probando
Casi todo equipo backend responde la misma pregunta cuando escribe un test: ¿esto funciona? Es una pregunta razonable, y también es la menos interesante de las dos que un test puede responder. La otra es: ¿puedo cambiar esto mañana sin romper nada? Esa segunda pregunta es la que sostiene un sistema en el tiempo, y es la única que QA no puede contestar por ti.
La confusión más común es tratar a los tests como una etapa del proceso: escribes el código, lo pasas a QA, QA aprueba, sale a producción. Bajo esa lógica los tests automatizados parecen un duplicado caro de algo que una persona ya hace mejor. Y si el único objetivo fuera verificar que la feature funciona el día que se entrega, quien piensa así tendría razón.
El problema es que ese no es el objetivo. Este post va sobre lo otro: qué compra realmente un test en un backend, por qué la pirámide no es un adorno académico, y sobre todo cuáles son las cinco dimensiones que tu suite funcional deja pasar aunque esté toda en verde.
QA y tests automatizados no son lo mismo
No son la misma actividad y no compiten. QA valida el producto contra el requerimiento: ¿construimos lo correcto? Los tests automatizados validan el código contra sí mismo: ¿sigue haciendo lo que hacía, después de 400 commits y tres refactors? Son dos ejes distintos, y ninguno cubre al otro.
La diferencia más importante entre ambos no es qué verifican, sino cuándo. QA revisa una vez por release. Un test corre en cada push. Un bug que un test unitario atrapa en ocho segundos te cuesta un ciclo de atención: todavía tienes el problema en la cabeza. El mismo bug encontrado por QA tres días después te cuesta reconstruir el contexto, buscar el commit, recordar por qué escribiste esa condición y volver a validar todo lo que tocaste en el camino.
Cuando no hay tests, QA se convierte en el único freno del sistema. Todo el cambio del equipo pasa por un solo punto de verificación manual, y ese punto se satura. El síntoma clásico: los releases se vuelven grandes y poco frecuentes, porque cada uno cuesta días de validación. Y mientras más grande el release, más difícil es saber cuál de los cuarenta cambios fue el que rompió producción.
Los tests no reemplazan a QA, lo liberan
Un equipo de QA que ya no gasta el ochenta por ciento de su tiempo repitiendo regresión manual puede dedicarse a lo que una máquina no hace: pruebas exploratorias, casos raros, usabilidad, y hacer las preguntas incómodas sobre el requerimiento antes de que el código exista.
Lo que realmente compras con un test
Vale la pena ser explícito, porque casi nunca se dice en voz alta. Un test te compra cuatro cosas concretas, y solo la primera tiene que ver con encontrar errores.
- Feedback en segundos. El costo de un bug crece con el tiempo que pasa entre escribirlo y descubrirlo.
- Permiso para refactorizar. Sin tests, el código se vuelve intocable.
- Documentación que no puede mentir. Un README envejece en silencio; un test no envejece sin ponerse rojo.
- Presión de diseño. Si probar una clase duele, la clase está mal diseñada.
La segunda es la más subestimada. La deuda técnica no crece porque el equipo sea flojo: crece porque nadie puede confirmar que quitar esa función rara es seguro. Sin una red que se ponga roja, la respuesta racional a cualquier código sospechoso es dejarlo donde está. Así se acumulan los sistemas que nadie se atreve a tocar.
La cuarta merece un ejemplo, porque es la que más gente confunde con una molestia del testing cuando en realidad es una señal del diseño:
kotlin
// Difícil de probar: la dependencia está escondida adentro
class SubscriptionService(private val repository: SubscriptionRepository) {
fun isExpired(id: String): Boolean {
val subscription = repository.findById(id)
return subscription.expiresAt.isBefore(LocalDateTime.now())
}
}
// Testeable: la dependencia es explícita
class SubscriptionService(
private val repository: SubscriptionRepository,
private val clock: Clock
) {
fun isExpired(id: String): Boolean {
val subscription = repository.findById(id)
return subscription.expiresAt.isBefore(LocalDateTime.now(clock))
}
}La primera versión solo se puede probar esperando a que pase el tiempo real o parcheando un método estático. La segunda se prueba pasando un reloj fijo. Y no es mejor por casualidad: una clase que declara de qué depende es más fácil de entender, de reutilizar y de mover. El test no impuso el buen diseño, simplemente fue el primero en notar que faltaba.
La pirámide, y qué compra cada capa
La pirámide de tests se dibuja mucho y se explica poco. No es una regla estética sobre cuántos tests escribir: es una consecuencia del costo. Mientras más arriba, más caro es cada test, más lento corre y más razones ajenas tiene para fallar. Por eso quieres muchos abajo y pocos arriba.
Qué compra cada capa, y qué no
Cada capa compra algo que la de abajo no puede comprar. Los unitarios te dicen que tu lógica de negocio es correcta, pero no que las piezas encajan. Los de integración te dicen que tu query, tu mapeo y tu transacción hacen lo que crees contra un Postgres o un Kafka de verdad, no contra un mock que responde lo que tú programaste que respondiera. Los de contrato te dicen que el payload que publicas sigue siendo el que tus consumidores esperan. Los E2E te dicen que el camino completo funciona, y no mucho más, porque son demasiado lentos para cubrir casos raros.
La base son tests como este: lógica de negocio pura, sin base de datos, sin red, sin contexto de Spring levantándose. Corren en milisegundos y por eso puedes tener miles.
kotlin
class OrderTest {
@Test
fun `a cancelled order cannot be delivered`() {
val order = Order(id = "ORD-1", status = OrderStatus.CANCELLED)
val error = assertThrows<IllegalStateException> {
order.markAsDelivered()
}
assertEquals("Cannot deliver a cancelled order", error.message)
}
}Para la capa de integración, la herramienta que cambió el juego es Testcontainers: levanta un Postgres o un Kafka real en Docker durante el test y lo bota al terminar. Ya no hay excusa para probar tu repositorio contra una base en memoria que se comporta distinto a la de producción.
La proporción entre capas importa más de lo que parece. Cuando la pirámide se invierte y la mayoría de los tests son E2E, la suite empieza a tardar cuarenta minutos y a fallar por razones que no son tu código: un contenedor lento, una red intermitente, un dato que quedó sucio de la corrida anterior. El equipo aprende a re-ejecutarla hasta que pase. Ese es el momento exacto en que la suite murió, aunque siga corriendo en verde.
El test funcional pasa y el sistema falla igual
Aquí está el punto del post. Una suite funcional completa te dice que cada caso de uso hace lo que dice cuando se ejecuta solo, una vez, con datos limpios y con todas sus dependencias respondiendo rápido y bien. Producción no se parece a eso en nada. Estas son las cinco dimensiones que tu suite funcional casi con seguridad no cubre.
1. Concurrencia
Tu test de retiro pasa: cuenta con 100, retiro de 80, saldo queda en 20. En producción llegan dos requests en el mismo milisegundo, las dos leen saldo 100, las dos aprueban un retiro de 80 y la cuenta termina en menos 60. El caso de uso está bien probado. La condición de carrera no.
kotlin
@Test
fun `two simultaneous withdrawals cannot overdraw the account`() {
val accountId = accountRepository.save(Account(balance = "100".toBigDecimal())).id
val threads = 2
val barrier = CyclicBarrier(threads)
val executor = Executors.newFixedThreadPool(threads)
val results = (1..threads)
.map {
executor.submit<Boolean> {
barrier.await() // fires both calls at the same instant
runCatching { withdrawalService.withdraw(accountId, "80".toBigDecimal()) }.isSuccess
}
}
.map { it.get() }
assertEquals(1, results.count { it })
assertEquals("20".toBigDecimal(), accountRepository.findById(accountId).balance)
}El
CyclicBarrier es lo que hace útil al test: obliga a que ambos hilos entren al mismo tiempo en vez de uno después del otro. Sin eso estarías probando dos retiros secuenciales, que es justamente el caso que ya funcionaba.2. Idempotencia
Kafka entrega al menos una vez. Los clientes reintentan cuando su timeout es más corto que tu respuesta. Los balanceadores reenvían. En cualquier backend distribuido, el mismo mensaje va a llegar dos veces, no es una posibilidad remota sino una garantía. La pregunta no es si pasa, sino qué hace tu código cuando pasa. Y probarlo cuesta una línea extra:
kotlin
@Test
fun `processing the same payment event twice charges only once`() {
val event = PaymentApprovedEvent(paymentId = "PAY-1", orderId = "ORD-1", amount = "80".toBigDecimal())
paymentConsumer.handle(event)
paymentConsumer.handle(event) // the duplicate delivery
assertEquals(1, ledgerRepository.countByPaymentId("PAY-1"))
}Dos llamadas y una sola aserción. Ese test es la diferencia entre un cobro y un cobro doble, y casi ningún equipo lo tiene escrito.
3. Contratos entre servicios
Renombras un campo de
payment_id a paymentId en el evento que publicas. Toda tu suite pasa, porque tus tests serializan y deserializan con la misma clase: el cambio es invisible desde adentro. Tres consumidores se caen esa noche. Un test de contrato fija la forma del payload como texto, no como objeto, y por eso sí lo nota:kotlin
@Test
fun `the payment approved event keeps its published shape`() {
val json = objectMapper.writeValueAsString(event)
assertEquals(
"""{"payment_id":"PAY-1","order_id":"ORD-1","amount":"80.00"}""",
json
)
}Esa es la versión casera y alcanza para la mayoría de los casos. La versión formal son herramientas como Pact o Spring Cloud Contract, donde el consumidor declara qué espera y el productor corre ese contrato en su propio pipeline. En un ecosistema de microservicios que despliegan por separado, es la única forma de saber que no rompiste a nadie sin desplegar y averiguarlo.
4. Migraciones de base de datos
Este es silencioso y por eso es peligroso. Tus tests corren con
ddl-auto: create-drop, contra un schema que Hibernate genera desde tus entidades. Producción corre contra el schema que dejaron sesenta migraciones de Flyway aplicadas a lo largo de dos años. No son el mismo schema, y el día que difieren te enteras en el deploy.yaml
# src/test/resources/application-test.yml
spring:
jpa:
hibernate:
ddl-auto: validate # do not let Hibernate build the schema
flyway:
enabled: true # the tests run the same migrations as productionLa regla es simple: los tests de integración deben levantar la base aplicando exactamente las mismas migraciones que producción. Así, una migración mal escrita falla en el pipeline y no en el deploy. Si quieres el detalle de cómo se versiona un schema, lo cubrí en el post de Flyway.
5. El camino del error
Todos los tests mockean el cliente HTTP para que responda 200. Entonces el timeout, el retry, el fallback y el circuit breaker que escribiste nunca se ejecutaron ni una sola vez antes de producción. Es código sin probar disfrazado de código defensivo, y se estrena justo el día que el proveedor se cae, que es el peor día posible para descubrir que estaba mal.
kotlin
@Test
fun `a slow provider falls back instead of hanging the request`() {
wireMock.stubFor(
get(urlEqualTo("/rates"))
.willReturn(aResponse().withFixedDelay(5_000).withStatus(200))
)
val result = ratesClient.fetchRates() // client timeout is 1s
assertEquals(Rates.FALLBACK, result)
}Por qué QA no encuentra ninguna de estas cinco
Ninguna aparece en un ticket. El requerimiento dice "el usuario puede retirar dinero", no "dos usuarios no pueden retirar el mismo dinero al mismo tiempo". QA valida contra lo que está escrito, y estas fallas viven en lo que nunca nadie escribió. Son responsabilidad de quien conoce el código, y esa persona eres tú.
Cómo saber si tu suite vale algo
La cobertura es la métrica favorita de los reportes y una de las peores para tomar decisiones. Mide líneas ejecutadas, no comportamiento verificado. Un test que llama a un método y no afirma nada te da cobertura del cien por ciento sobre ese método y no te protege de absolutamente nada.
La pregunta práctica es otra: si borro esta línea o invierto esta condición, ¿algún test se pone rojo? Si la respuesta es no, esa línea no está probada aunque el reporte diga que sí. Hacerse esa pregunta a mano sobre el código crítico toma cinco minutos y es más informativo que cualquier porcentaje. Automatizada, esa pregunta tiene nombre: mutation testing, y en el mundo JVM la herramienta es Pitest.
Hay una segunda señal, más barata todavía. Un test que nunca falló en dos años probablemente no te está protegiendo de nada: o prueba algo que nunca cambia, o no prueba lo que dice probar. Los tests que valen son los que de vez en cuando te frenan.
Por dónde empezar si hoy no tienes tests
No empieces por una meta de cobertura. Poner "llegar al ochenta por ciento" como objetivo produce cientos de tests que ejecutan código sin verificarlo, y el equipo termina con una suite lenta en la que nadie confía. Tres pasos, en este orden:
- El próximo bug de producción se arregla con un test que lo reproduzca primero. Rojo, después verde, sin excepción. Cada incidente se convierte en protección permanente y la suite crece justo donde el sistema ya demostró ser frágil.
- Cubre el negocio, no los controllers. El cálculo del precio, la máquina de estados de la orden, las reglas de descuento. Ahí está el valor y ahí duelen los errores.
- Después sube una capa: un test de integración con base real por cada query no trivial, y uno de idempotencia por cada consumidor de eventos.
Un backend sin tests no es un backend rápido: es uno que todavía no ha pagado la cuenta. La paga después, en releases que dan miedo, en refactors que nadie autoriza y en bugs que se descubren por un reclamo de un cliente. Los tests no existen para probar que tu código funciona hoy. Existen para que puedas cambiarlo mañana.