Rails sostenible (VII): testing y un ejemplo end-to-end
Séptima entrega de la serie Rails sostenible, sobre el libro de David Bryant Copeland. Tiempo de lectura estimado: 14 minutos.
El testing es donde más fácil se peca por exceso y por defecto. Copeland no predica el "testéalo todo" ni el "los tests frenan", sino algo más maduro: los tests son una inversión con coste, y la sostenibilidad consiste en maximizar el valor que dan por el coste que tienen. En este post junto el capítulo sobre testear la vista con el ejemplo end-to-end, porque juntos enseñan el flujo de trabajo completo que propone el libro.
El valor y el coste de los tests
Todo test tiene valor: detecta regresiones, documenta comportamiento, te da confianza para cambiar. Pero todo test tiene coste: hay que escribirlo, mantenerlo cuando el código cambia, y ralentiza la suite. Un test sostenible es el que da mucho valor por poco coste. Uno insostenible es el que se rompe cada vez que tocas un detalle de implementación sin que nada se haya roto de verdad —esos tests frágiles que acabas borrando o, peor, ignorando.
No preguntes "¿esto tiene tests?", pregunta "¿estos tests me dan confianza sin frenarme?". Cobertura alta con tests frágiles es una falsa sensación de seguridad.
De aquí salen las dos grandes decisiones: qué nivel de test usar para cada cosa, y cómo escribirlos para que no sean frágiles.
rack_test para flujos sin JavaScript
Para la mayoría de flujos de usuario —los que no dependen de JavaScript— Copeland recomienda los system tests con el driver :rack_test: simulan un navegador a nivel de HTTP, sin arrancar un navegador real. Son rápidos, deterministas y suficientes para verificar que un formulario se envía, que aparece el contenido correcto, que una redirección lleva donde debe.
# test/system/customer_signup_test.rb
require "application_system_test_case"
class CustomerSignupTest < ApplicationSystemTestCase
driven_by :rack_test # rápido, sin navegador real
test "un visitante puede registrarse" do
visit new_customer_path
fill_in "Nombre", with: "Ada Lovelace"
fill_in "Email", with: "ada@example.com"
click_on "Crear cuenta"
assert_text "Bienvenida, Ada"
end
end
Reservas el navegador real (Selenium, Cuprite) solo para los flujos que de verdad necesitan JavaScript. Esa fue una de las conclusiones de la quinta entrega: si una funcionalidad depende de JS, su system test debe correr en un navegador de verdad, de modo que si el JavaScript se rompe, el test falle. Pero esos tests son lentos y frágiles, así que cuantos menos, mejor.

data-testid contra los tests frágiles
Aquí está el truco más práctico del capítulo. ¿Por qué se rompen tantos system tests? Porque seleccionan elementos por cosas que cambian por motivos de presentación: el texto exacto de un botón, una clase CSS, la estructura del DOM. Cambias "Crear cuenta" por "Registrarme" y veinte tests se ponen rojos sin que nada funcional se haya roto.
La solución de Copeland: usar atributos data-testid como puntos de anclaje estables, desacoplados del estilo y del texto:
<button data-testid="signup-submit" class="btn btn--primary">
Crear cuenta
</button>
find("[data-testid='signup-submit']").click
Ahora el diseñador puede cambiar el texto, las clases y el layout cuanto quiera: mientras el data-testid siga ahí, el test aguanta. Separas lo que el test necesita (un punto estable de interacción) de lo que cambia constantemente (la presentación). Es la misma filosofía de minimizar el coste de mantenimiento que recorre todo el libro.
Herramientas de diagnóstico explícitas
Copeland insiste en algo que ahorra horas: cultivar herramientas de diagnóstico para cuando un test falle. Capturas de pantalla automáticas al fallar (Rails las hace de serie en system tests), volcados del HTML, helpers propios que impriman el estado relevante. Un test que falla con "no encontré el elemento" sin más contexto cuesta una tarde; uno que te deja una captura y el HTML del momento, cinco minutos.
Falsea el back-end para sacar el system test adelante
Una técnica clave del flujo end-to-end: para que un system test pase antes de tener la lógica de negocio implementada, puedes falsear el back-end. Construyes la UI, escribes el test que la dirige, y haces que el controlador devuelva datos de pinta o un servicio falso. El test verde te confirma que la UI y el flujo están bien antes de invertir en la lógica real. Esto encaja con el seam: como la lógica vive detrás de una costura, es fácil sustituirla por un doble mientras desarrollas la interfaz.
El ejemplo end-to-end, paso a paso
El capítulo diecisiete es oro porque une todo lo de la serie en un flujo de trabajo concreto. Copeland desarrolla una feature completa siguiendo este orden:
- Define los requisitos en lenguaje de negocio. Qué tiene que pasar, no cómo.
- Construye la UI primero. El HTML y la pantalla, con datos falsos si hace falta. Empezar por la interfaz te obliga a entender qué necesita el usuario antes de diseñar la trastienda.
- Escribe un system test que recorra el flujo de usuario de principio a fin. Con
rack_testsi no hay JS. Este test es tu red de seguridad de alto nivel. - Esboza la lógica de negocio y define el seam. Decides qué operación del dominio hace falta y cuál es su interfaz (el nombre de clase y de método explícitos de la tercera entrega), aún sin implementarla del todo.
- Implementa y testea la lógica de negocio con tests unitarios rápidos y enfocados. Como vive fuera de Rails, estos tests no necesitan la base de datos ni el framework para casos puros: son veloces.
- Conecta todo y mira el system test ponerse verde de verdad, ya sin falsos.
- Reflexiona sobre lo construido: ¿está la lógica donde toca? ¿los tests dan confianza sin ser frágiles?
# Test unitario de la lógica de negocio: rápido y enfocado
class CancellationsTest < ActiveSupport::TestCase
test "cancelar un pedido pagado genera un reembolso" do
order = orders(:paid)
result = Orders.new.cancel(order)
assert result.cancelled?
assert result.refund.present?
end
end
Lo bonito de este flujo es cómo reparte el esfuerzo de testeo: un system test de alto nivel que verifica que el usuario puede hacer la cosa, y muchos tests unitarios rápidos sobre la lógica de negocio donde viven los casos límite. Pocos tests lentos y caros arriba; muchos tests baratos y veloces abajo, donde está la complejidad real.
Mi versión
El framing de "valor vs coste" me parece la mejor vacuna contra las dos enfermedades del testing. Contra el dogmático del 100% de cobertura, que llena la suite de tests frágiles que verifican detalles de implementación y que el equipo acaba ignorando. Y contra el que dice "los tests me frenan", que normalmente ha sufrido justo esos tests frágiles y ha sacado la conclusión equivocada.
Lo de data-testid lo adopté hace tiempo y no he mirado atrás. Antes, cada rediseño me rompía media suite de system tests por cambios cosméticos. Con anclas estables, el diseño y los tests viven en paz. Es de esos trucos baratos con un retorno enorme.
Y el flujo end-to-end —UI primero, system test, seam, lógica con sus unitarios, conectar— es prácticamente como trabajo cuando la feature es relevante. Empezar por la pantalla me obliga a entender el problema desde el usuario, y tener el seam definido me deja avanzar en la UI con un back-end falso sin bloquearme. Pocos tests caros arriba, muchos baratos abajo: esa pirámide, bien entendida, es pura sostenibilidad.
Lo que viene
Con el testing encarrilado, volvemos a las capas de Rails que conectan el framework con el mundo. En el próximo post toca el lado "frontera" del seam: controladores, jobs y otras clases frontera. Veremos por qué un controlador debería ser casi configuración, cómo convertir parámetros en tipos ricos, por qué Sidekiq sigue siendo la mejor opción de jobs para la mayoría, la importancia de la idempotencia, y el papel de mailers y rake tasks.