En las últimas dos semanas se ha armado bastante revuelo a raíz de un post de David Heinemeier Hansson, creador de Ruby on Rails, titulado TDD is dead. Long live testing. Con un título tan provocador, y más viniendo de alguien con su influencia, el debate no se ha hecho esperar y hemos podido disfrutar de argumentos a favor de TDD como los Uncle Bob o Jason Gorman y otros más cercanos a la postura de David Heinemeier como el de Rob Ashton.
Dejando de lado algunos desvaríos y salidas de tono, es un debate interesante y que, en general, podemos centrar en dos puntos. Por una parte, está TDD como herramienta de diseño y por otra el papel que juegan los tests unitarios (componente fundamental de TDD) en toda esta historia.
Sobre el primer punto, el valor de TDD como herramienta de diseño, he de reconocer que no tengo una postura clara, o al menos no tan definida como parecen tener la mayoría de los participantes en el debate. Lo he discutido más de una vez en twitter y hay veces que me parece muy útil y otras en las que no estoy muy seguro de que lleve a buenas soluciones.
Sin embargo, con respecto al rol que juegan los tests unitarios en toda esta historia sí que tengo una opinión más clara (al menos a día de hoy, seguro que en unos meses la cambio).
La pirámide de los tests
La pirámide de los tests es una forma muy extendida (y aceptada) de organizar los tests en distintos niveles, siguiendo una estructura parecida a una pirámide:
La gente de Prismatic tiene un buen artículo al respecto, pero si no tenéis ganas de leer mucho, la idea es que debemos organizar los tests de tal manera que tengamos muchos tests que se ejecutan rápido y comprueban el funcionamiento de componentes pequeños (tests unitarios) y, según vamos aumentando el tiempo de ejecución de los tests y el número de componentes que testean (tests de integración, de extremo a extremo, etc.), ir reduciendo el número de tests.
La idea es razonable. Los tests unitarios, al ser rápidos, podemos ejecutarlos frecuentemente, lo que nos proporciona un feedback rápido sobre los cambios que hacemos en el código y las nuevas funcionalidades que vamos implementando. Al combinar esto con tests más lentos pero que comprueban el funcionamiento de más partes de la aplicación, se supone que podemos alcanzar un equilibrio entre tener un feedback rápido y tener mayor confianza sobre la aplicación al completo y sobre la integración de los distintos componentes que forman parte del mismo.
Desmontando la pirámide
El problema de la pirámide de los tests es que a veces se aplica sin tener en cuenta la arquitectura que tiene la aplicación que hay por debajo.
Tener muchos tests unitarios que se ejecutan sobre un modelo complejo en memoria, sólo tiene sentido si tu modelo tiene lógica que merezca la pena testear. Si todo lo que hace tu modelo en memoria es mover información entre una base de datos y un interfaz web, tener cientos (o miles) de tests unitarios alrededor de eso, sólo sirve para invertir tiempo en escribir y mantener tests, pero no va a hacer gran cosa por nuestro diseño ni por la calidad de la aplicación.
Al aplicar TDD, ese modelo «surge» casi obligatoriamente para poder montar todos los tests unitarios alrededor de él. Que eso sea algo bueno o no es discutible (y de hecho es parte del debate que mencionaba al principio del post), pero si aplicamos el sentido el común, es difícil pensar tener un modelo complejo sea siempre la mejor opción. Sencillamente, hay demasiados tipos de aplicaciones diferentes como para que una única solución sea la mejor en todos los casos.
Cuando encontramos una aplicación que no se ajusta a esta filosofía, los tests unitarios empiezan rápidamente a perder valor, mientras que otros tipos de tests se vuelven más útiles. En una aplicación con un interfaz de usuario muy complejo, los tests de extremo a extremo pueden aportar mucho más valor que los tests unitarios sobre los componentes individuales del interfaz. En una aplicación de reporting, donde la complejidad reside en consultas lanzadas a una base de datos, tener tests de integración contra la base de datos tiene mucho más sentido que testear con mocks los componentes que encapsulan las consultas a la base de datos.
Es cierto que estos tipos de tests son más lentos de ejecutar y complejos de escribir que los tests unitarios, pero también es verdad que la seguridad que nos aportan es mayor y que el tiempo que nos ahorran (comparándolos con realizar esas mismas pruebas manualmente) es muy importante, por lo que pueden merecer la pena.
Desde los defensores de TDD se hace mucho hincapié en que los tests deben guiar el diseño, pero hay veces que debe ser la arquitectura la que guíe el tipo de tests a realizar, y puede que tengamos que deformar la pirámide de los tests para adecuarla a nuestras necesidades:
En resumen…
No hay que ver la pirámide de los tests, y especialmente el uso de tests unitarios, como un objetivo en si mismo. A la hora de escribir un test, es importante tener en cuenta qué estamos buscando con él y qué tipo de test nos sirve mejor para conseguir ese propósito. Si al final resulta que los tests «se ajustan» a la pirámide, estupendo, pero si no, no hay ningún problema. Deja que la arquitectura de la aplicación guíe el tipo de tests a utilizar.
El debate sobre TDD como herramienta de diseño y sobre el uso de tests unitarios tiene muchas vertientes y lo que hemos visto en este post es una parte muy pequeña, pero atacar todo a la vez es complicado. Os recomiendo que le echéis un vistazo a los enlaces que mencionaba al principio de este post (y a los comentarios de los mismos) porque merece la pena conocer todos esos puntos de vista.
Yo pienso que no hay que ser purista con las cosas y que a veces lo más sencillo es aplicar el sentido común.
Estoy de acuerdo en que en una aplicación donde no hay lógica que probar no tiene mucho sentido hacer test unitarios, sin embargo en una aplicación con bastante lógica es fundamental.
Saludos
Normalmente observo que quienes se manifiestan en contra de TDD desarrollan las pruebas unitarias siempre basándose en «mocks» y «stubs» generados con «frameworks» de «mocking», tipo Moq, NSubstitute, FakeItEasy. Rara vez generan los objetos «fake» aprovechando la herencia y los «override» sobre métodos y propiedades.
No voy a defender TDD a muerte porque es cierto que su uso puede generar una arquitectura poco adecuada.
mmm ¿y que tiene que ver todo esto con la tdd?
O sea, si hago tdd voy a tener tests unitarios pero creo que no hace falta hacer tdd para tener tests unitarios, como se intenta establecer implicita y explicitamente por los defensores de la tdd
Vaya, se me adelantó @janeira XD XD XD
Baldomero, no soy precisamente un gran fan de los mocks (https://blog.koalite.com/2011/10/adios-mocks/, https://blog.koalite.com/2012/09/como-no-escribir-tests-unitarios-verificando-interacciones/), pero no creo que usar mocks o fakes escritos manualmente cambie el escenario.
Al final, los tests unitarios sirven para validar eso, unidades sin dependencias externas (sea lo que sea una unidad), y si tu aplicación no tiene la lógica centralizada en esas unidades, los tests unitarios sirven de poco.
jneira, estoy de acuerdo en lo que comentas. Hay algunos que defienden TDD como el único camino para generar código que luego sea susceptible de ser testeado con tests unitarios, pero se puede llegar a eso (suponiendo que te interese, claro) sin usar TDD.
Sí cambia bastante el escenario el utilizar «fakes» creados con «frameworks» de «mocking» frente a los hechos a mano basados en herencia y «override».
Cuando se usan «fakes» creados con «frameworks» de «mocking» la tendencia suele ser inyectarlo como parámetro en el constructor de la clase bajo pruebas:
IBrowserService browser = Substitute.For(); // fake
var client = new Client(browser);
Esta forma de romper la dependencia no siempre es la más adecuada y aplicarlo en todas las situaciones puede llevar a diseños poco optimizados.
Una opción para probar la clase «Client» es heredando de ella y sobrescribiendo los métodos que tienen dependencias. Este diseño de la clase «Client» para probarla puede mejorar bastante el diseño en su conjunto, por ejemplo evitando parámetros en su constructor.
Lo que veo actualmente es que la gente va a piñón con las dependencias y todo se limita a interfaces, inyecciones en el constructor y «fakes» con «frameworks» de «mocking». Haciéndolo siempre así el diseño final puede quedar bastante poco agradable.
Baldomero,
Estoy de acuerdo en que el código queda más claro usando ese tipo de técnicas y, además, imponen menos cambios estructurales innecesarios (header interfaces para todo).
A lo que me refería con que el escenario no cambia, es que el uso de tests unitarios sigue ligado a que tengas clases con lógica que testear por separado.
En tu ejemplo, si la clase client lo que hiciese es descargar un fichero y guardarlo en disco sin procesar nada, me daría igual aislar la interacción con los recursos externos (red y disco) detrás de interfaces o dentro de métodos virtuales: o hago un test de integración, o el test no me aporta mucho.
El problema que siempre noto cuando se habla de TDD es que se olvida una parte fundamental, justamente esa que mencionas al inicio y en la que no tienes una postura: «El diseño».
Mucha gente se postula sobre TDD pensando solo en las pruebas, en calidad de código, en si nos permite conocer si algo anda mal o no, en que nos permite tener controlada la parte gruesa de la lógica de nuestra aplicación. El problema es que todo eso lo puedo hacer sin hablar de TDD. Lo que más valoro de TDD es justamente que me guía durante el diseño de mi dominio.
El ejemplo que pones sobre un sistema de reportes. Es verdad que la lógica de este tipo de proyecto está en las consultas y que para garantizar calidad y detectar tempranamente posibles errores antes de realizar subidas a producción, el grueso de los test tiene que estar centrado en esa parte de la app. Si a este tipo de app le aplicas TDD, entonces las cosas cambian un poco.
¿Qué hago para generar un reporte?
– ¿Hago un query que me retorne el ID de la compra y luego hago otro query que me retorne el listado de productos pasando el id de la compra? (ojo que esto lo he visto muchas veces) o ¿Hago un query que en un solo paso me retorne lo que necesito? (Esto es diseño)
Si comienzas por el test, seguramente optarás por la segunda opción, somos programadores y nos gusta escribir poco código :) y aquí es donde veo la verdadera ayuda de TDD. Me ayuda a que las llamadas a mi dominio sean simples sin mezclar responsabilidades.
La mayoría de las veces que escribimos código lo hacemos para nosotros mismos. Si escribimos una clase para enviar notificaciones al final la usamos nosotros mismos y, pocas veces nos damos cuanta de si lo que expone dicha clase tiene un diseño óptimo o no. Cuando escribimos código que usarán otros desarrolladores entonces la cosa cambia :) porque nos critican si es poco usable. Por ejemplo, los desarrolladores de APIs son programadores que tienen que tener muy en cuenta la usablidad y TDD da una ayuda inmejorable en este sentido.
saludos…
Gracias por tu visión, Omar.
Con la parte de diseño, la duda que me surge es si realmente necesito TDD para conseguir esos puntos que mencionas y que, sin duda, son importantes.
Por ejemplo, para cuidar el API de una clase o para evitar crear métodos innecesarios (YAGNI y todas esas cosas), aplicar técnicas de diseño descendente (up-down) son igual de útiles. De hecho, es lo que haces con TDD, partes de fuera (los tests) para decidir qué necesitas dentro.
En el caso de la aplicación de reporting, aplicando TDD estricto es muy tentador cargar todos los datos en memoria para poder tener tests unitarios más fáciles de escribir, cuando probablemente eso sea contraproducente desde el punto de vista de la eficiencia.
A mi TDD (en cuanto a diseño, no a tests) me resulta útil cuando ando muy perdido y quiero poder hacer pruebas rápidas sobre cómo va a funcionar algo, pero también me resulta útil para eso usar un REPL o incluso un papel y un boli ;)
Un saludo,
Juanma
Hola Juanma
Lo de YAGNI o up-down yo lo veo muy bien y estoy de acuerdo contigo en que de alguna manera es lo mismo que se hace con TDD. En mi caso con TDD voy un poco más allá, quizás, y lo reconozco, influenciado un poco por la técnica de documentación por código. Soy muy fan de mirar un código y sin necesitar nada más, ser capaz de entender lo que hace, mi memoria así me lo exige porque de lo contrario no supiera lo que he hecho pasada una semana :) Un test es el panorama perfecto para saber si estoy contento con lo que estoy haciendo o no.
Otra cosa es cuando comienzas con los «refactoring». Es imposible que desde un inicio yo piense y cree una lógica que será reutilizada y, durante el ciclo de desarrollo esa lógica no cambie. Aquí TDD también me ayuda a que todo se mantenga funcionando por mucho que «refactoring» que yo haga. Quizás para mi, TDD sería la unión de (YAGNI + up-down) + unit test. Un todo en uno… :)
En el caso de reporting no sé si estoy de acuerdo contigo… bueno sí lo sé, no lo estoy :) No sé por qué TDD es tentador a cargar datos en memoria… Creo, y esta es una opinión personal, que aquí la culpa es más nuestra que de TDD. Igual de malo es no llegar que pasarse. Yo procuro traer lo que realmente voy a mostrar evitando a toda costa el lazy-load. Hoy en día con la «moda» de los ORM, no nos damos cuenta del daño que implica dejarlo todo a su responsabilidad sin pensar antes en lo que se necesita. El típico caso de mostrar Customer+Addresses en una misma página:
– Mostramos los datos del Customer
– Listamos sus N direcciones
Esto con lazy termina generando N+1 consultas a la BD. Si yo ya sé lo que voy a mostrar, ¿por qué no traerlo todo? En estos casos, mis test comprueban varias cosas.
1- Que mi código sea legible y fácil de usar (importante para mi)
2- Que traigo los datos que realmente voy a mostrar (evitar las idas y venidas)
2- Que dentro de los datos que traigo, no tengo acceso a nada más (no lo necesito)
Nos leemos.. ;)
Interesante enfoque el de descomponer la pirámide. Personalmente nunca tomé la pirámide como una guía de los test que debo hacer sino más bien como un resultado natural de aplicar otras técnicas, entre ellas TDD.
En general uso mucho TDD pero no exclusivamente a nivel de clase o sea, si bien se asocia TDD con un nivel diseño de clase/método y por consiguiente se trabaja sobre a la prueba unitaria, es perfectamente posible aplicar la práctica a un nivel más alto como de hecho se propone en BDD. Esto me lleva a que cuando trabajo a nivel de servicios también hago TDD pero en ese caso las pruebas son naturalmente de integración.