ruby-on-rails - tests - rspec test example
Pruebas: ¿cómo enfocarse en el comportamiento en lugar de la implementación sin perder velocidad? (3)
Parece que hay dos enfoques totalmente diferentes para las pruebas, y me gustaría citarlos a ambos.
La cuestión es que esas opiniones se expresaron hace 5 años (2007) y me interesa lo que ha cambiado desde entonces y hacia dónde debo ir.
La teoría es que se supone que las pruebas son agnósticas de la implementación. Esto lleva a pruebas menos frágiles y realmente prueba el resultado (o comportamiento).
Con RSpec, siento que el enfoque común de burlarse completamente de sus modelos para probar sus controladores termina obligándolos a mirar demasiado en la implementación de su controlador.
Esto, por sí mismo, no es tan malo, pero el problema es que se asoma demasiado al controlador para dictar cómo se usa el modelo. ¿Por qué importa si mi controlador llama Thing.new? ¿Qué pasa si mi controlador decide tomar el Thing.create! y la ruta de rescate? ¿Qué sucede si mi modelo tiene un método de inicialización especial, como Thing.build_with_foo? Mi especificación de comportamiento no debería fallar si cambio la implementación.
Este problema empeora aún más cuando tiene recursos anidados y está creando varios modelos por controlador. Algunos de mis métodos de configuración terminan siendo 15 o más líneas largas y MUY frágiles.
La intención de RSpec es aislar completamente la lógica de su controlador de sus modelos, lo cual suena bien en teoría, pero casi funciona contra el grano para una pila integrada como Rails. Especialmente si practicas la disciplina flaca del controlador / modelo gordo, la cantidad de lógica en el controlador se vuelve muy pequeña, y la configuración se vuelve enorme.
Entonces, ¿qué es un BDD-wannabe que hacer? Dando un paso atrás, el comportamiento que realmente quiero probar no es que mi controlador llame a Thing.new, sino que dado los parámetros X, crea una cosa nueva y la redirige a ella.
David Chelimsky:
Todo se trata de compensaciones.
El hecho de que AR elija la herencia en lugar de la delegación nos pone en un vínculo de prueba: tenemos que estar acoplados a la base de datos O tenemos que ser más íntimos con la implementación. Aceptamos esta opción de diseño porque obtenemos beneficios en expresividad y DRY-ness.
Al lidiar con el dilema, elegí pruebas más rápidas a costa de un poco más frágil. Estás eligiendo pruebas menos frágiles a costa de que se ejecuten un poco más lento. Es una compensación de cualquier manera.
En la práctica, realizo las pruebas cientos, si no miles, de veces al día (uso autotest y tomo pasos muy detallados) y cambio si uso "nuevo" o "creo" casi nunca. También debido a los pasos granulares, los nuevos modelos que aparecen son bastante volátiles al principio. El enfoque valid_thing_attrs minimiza el dolor de esto un poco, pero aún significa que cada nuevo campo requerido significa que tengo que cambiar valid_thing_attrs.
Pero si su enfoque está funcionando para usted en la práctica, ¡entonces es bueno! De hecho, le recomiendo encarecidamente que publique un complemento con generadores que produzcan los ejemplos de la forma que más le gusten. Estoy seguro de que muchas personas se beneficiarían de eso.
Por curiosidad, ¿con qué frecuencia usa usted simulacros en sus pruebas / especificaciones? Tal vez estoy haciendo algo mal, pero lo encuentro muy limitante. Desde que cambié a rSpec hace más de un mes, he estado haciendo lo que recomiendan en los documentos donde el controlador y las capas de vista no llegan a la base de datos y los modelos están completamente burlados. Esto le da un buen impulso de velocidad y hace que algunas cosas sean más fáciles, pero estoy descubriendo que las desventajas de hacerlo son mucho mayores que las ventajas. Desde el uso de simulacros, mis especificaciones se han convertido en una pesadilla de mantenimiento. Las especificaciones están destinadas a probar el comportamiento, no la implementación. No me importa si se llamó a un método, solo quiero asegurarme de que la salida resultante sea correcta. Debido a que la burla hace que las especificaciones sean delicadas con respecto a la implementación, hace que las refactorizaciones simples (que no cambian el comportamiento) sean imposibles de realizar sin tener que volver constantemente y "arreglar" las especificaciones. Tengo muchas opiniones sobre lo que deben cubrir las especificaciones / pruebas. Una prueba solo debería romperse cuando la aplicación se rompe. Esta es una de las razones por las que apenas pruebo la capa de vista porque la encuentro demasiado rígida. A menudo conduce a que se rompan las pruebas sin que se rompa la aplicación al cambiar las cosas pequeñas en la vista. Estoy encontrando el mismo problema con las burlas. Además de todo esto, me di cuenta hoy de que burlarse / apalear un método de clase (a veces) se pega entre las especificaciones. Las especificaciones deben ser autocontenidas y no estar influenciadas por otras especificaciones. Esto rompe esa regla y conduce a errores complicados. ¿Qué he aprendido de todo esto? Ten cuidado cuando te burlas. Stubbing no es tan malo, pero todavía tiene algunos de los mismos problemas.
Tomé las últimas horas y eliminé casi todas las burlas de mis especificaciones. También fusioné el controlador y veo las especificaciones en una usando "integrevistas" en la especificación del controlador. También estoy cargando todos los accesorios para cada especificación de controlador, así que hay algunos datos de prueba para llenar las vistas. ¿El final resulto? Mis especificaciones son más cortas, más simples, más consistentes, menos rígidas, y prueban toda la pila juntas (modelo, vista, controlador) para que ningún error pueda pasar por las grietas. No estoy diciendo que esta sea la forma "correcta" para todos. Si tu proyecto requiere un caso de especificaciones muy estricto, puede que no sea para ti, pero en mi caso este es un mundo mejor que el que tenía antes de usar mocks. Sigo pensando que apuñalar es una buena solución en algunos puntos, así que sigo haciendo eso.
Creo que las tres opiniones siguen siendo completamente válidas. Ryan y yo estábamos luchando con la capacidad de mantenimiento de la burla, mientras que David sintió que la compensación del mantenimiento valía la pena por el aumento de la velocidad.
Pero estas compensaciones son síntomas de un problema más profundo, al que David aludió en 2007: ActiveRecord. El diseño de ActiveRecord lo alienta a crear objetos divinos que hacen demasiado, saben mucho sobre el resto del sistema y tienen demasiada área de superficie. Esto conduce a pruebas que tienen mucho que probar, saben mucho sobre el resto del sistema y son demasiado lentas o frágiles.
Entonces, ¿cuál es la solución? Separe la mayor parte posible de su aplicación del marco. Escribe muchas clases pequeñas que modelan tu dominio y no heredan de nada. Cada objeto debe tener un área de superficie limitada (no más de unos pocos métodos) y dependencias explícitas pasadas a través del constructor.
Con este enfoque, solo he estado escribiendo dos tipos de pruebas: pruebas de unidad aislada y pruebas de sistema de pila completa. En las pruebas de aislamiento, me burlo o apago todo lo que no es el objeto bajo prueba. Estas pruebas son increíblemente rápidas y, a menudo, ni siquiera requieren cargar todo el entorno de Rails. Las pruebas de pila completa ejercitan todo el sistema. Son dolorosamente lentos y dan comentarios inútiles cuando fallan. Escribo tan pocos como sea necesario, pero lo suficiente como para darme confianza de que todos mis objetos probados se integran bien.
Desafortunadamente, no puedo señalarle un proyecto de ejemplo que haga esto bien (todavía). Hablo un poco sobre eso en mi presentación en Por qué nuestro código huele , veo la presentación de Corey Haines en las pruebas de rieles rápidos y recomiendo leer el software orientado a objetos en crecimiento guiado por pruebas .
Gracias por compilar las citas de 2007. Es divertido mirar hacia atrás.
Mi enfoque de prueba actual está cubierto en este episodio de RailsCasts con el que estoy bastante contento. En resumen tengo dos niveles de pruebas.
Nivel alto: uso especificaciones de solicitud en RSpec, Capybara y VCR. Las pruebas se pueden marcar para ejecutar JavaScript según sea necesario. Aquí se evita la burla porque el objetivo es probar toda la pila. Cada acción del controlador se prueba al menos una vez, tal vez unas pocas veces.
Nivel bajo: aquí es donde se prueba toda la lógica compleja, principalmente modelos y ayudantes. Evito burlarme aquí también. Las pruebas llegan a la base de datos u objetos circundantes cuando es necesario.
Note que no hay especificaciones de controlador o vista. Siento que estos están adecuadamente cubiertos en las especificaciones de solicitud.
Ya que hay poca burla, ¿cómo mantengo las pruebas rápidamente? Aquí hay algunos consejos.
Evite la excesiva lógica de bifurcación en las pruebas de alto nivel. Cualquier lógica compleja debe moverse al nivel inferior.
Cuando genere registros (como con Factory Girl), use
build
primero y solo cambie paracreate
cuando sea necesario.Utilice Guard con Spork para omitir el tiempo de inicio de Rails. Las pruebas relevantes a menudo se realizan dentro de unos segundos después de guardar el archivo. Use una etiqueta de
:focus
en RSpec para limitar las pruebas que se ejecutan cuando se trabaja en un área específica. Si se trata de un conjunto de pruebas de gran tamaño, configureall_after_pass: false, all_on_start: false
en Guardfile para ejecutarlos todos solo cuando sea necesario.Yo uso múltiples aserciones por prueba. La ejecución del mismo código de configuración para cada aserción aumentará considerablemente el tiempo de prueba. RSpec imprimirá la línea que falló, por lo que es fácil localizarla.
Encuentro que la burla agrega fragilidad a las pruebas y por eso lo evito. Es cierto que puede ser excelente como ayuda para el diseño de OO, pero en la estructura de una aplicación Rails esto no se siente tan efectivo. En cambio, confío en gran medida en la refactorización y dejo que el código en sí me diga cómo debe ir el diseño.
Este enfoque funciona mejor en aplicaciones Rails de tamaño pequeño a mediano sin una lógica de dominio extensa y compleja.
Grandes preguntas y gran discusión. @ryanb y @bkeepers mencionan que solo escriben dos tipos de pruebas. Tomo un enfoque similar, pero tengo un tercer tipo de prueba:
- Pruebas unitarias: pruebas aisladas, típicamente, pero no siempre, contra objetos de rubí lisos. Mis pruebas de unidad no involucran la base de datos, las llamadas a la API de terceros o cualquier otra cosa externa.
- Pruebas de integración: todavía están enfocadas en probar una clase; la diferencia es que integran esa clase con las cosas externas que evito en mis pruebas de unidad. Mis modelos a menudo tendrán pruebas unitarias y pruebas de integración, donde las pruebas unitarias se centran en la lógica pura que puede probarse sin la participación del DB, y las pruebas de integración involucrarán el DB. Además, tiendo a probar envoltorios de API de terceros con pruebas de integración, utilizando VCR para que las pruebas sean rápidas y deterministas, pero al permitir que las compilaciones de CI realicen las solicitudes HTTP de manera real (para detectar cualquier cambio de API).
- Pruebas de aceptación: pruebas de extremo a extremo, para una característica completa. No se trata solo de pruebas de interfaz de usuario a través de capybara; Hago lo mismo en mis gemas, que pueden no tener una interfaz de usuario HTML. En esos casos, esto ejerce lo que la gema hace de extremo a extremo. También tiendo a usar VCR en estas pruebas (si hacen solicitudes HTTP externas), y al igual que en mis pruebas de integración, mi compilación de CI está configurada para que las solicitudes HTTP sean reales.
En lo que respecta a la burla, no tengo un enfoque de "talla única". Definitivamente me he excedido en el pasado, pero aún así encuentro que es una técnica muy útil, especialmente cuando uso algo como rspec-fire . En general, me burlo de los colaboradores que desempeñan roles libremente (particularmente si los poseo y son objetos de servicio) y trato de evitarlos en la mayoría de los otros casos.
Probablemente el cambio más grande en mis pruebas durante el último año más o menos se haya inspirado en DAS : mientras que solía tener un spec_helper.rb
que carga todo el entorno, ahora solo cargo explícitamente la clase bajo la prueba (y cualquier dependencia). Además de la velocidad de prueba mejorada (¡lo que hace una gran diferencia!) Me ayuda a identificar cuándo mi clase bajo prueba está generando demasiadas dependencias.