09 septiembre 2011

¿Realmente emerge el diseño con TDD?

Hola a tod@s,

como desarrollador de software cada vez soy más fan de TDD y creo que el “diseño emergente” nos conduce de forma natural a un diseño correcto de nuestras aplicaciones. Hace unos días intentaba explicar las bondades de TDD a unos compañeros y lo hacia con el típico ejemplo de la kata FizzBuzz:

  • caso de prueba con el 3: Red
  • implementación del método para el 3: Green
  • caso de prueba para el 5: Red
  • implementación del  código para el 5: Green
  • refactorización
  • caso de prueba para el 17: Red
  • implementación del  código para el 17: Green
  • refactorización para generalizar y eliminar duplicidades
  • etc…

encuentro que es un ejemplo ideal para explicar como hacer TDD y sus bondades, creo que quedaron convencidos que es una técnica interesante de diseño y no solo una forma de tener test unitarios para poder hacer refactorizaciones sin morir en el intento.

Unos días después estos mismos compañeros estaban leyendo un libro sobre ASP.NET MVC3 y se pusieron a codificar su primer proyecto MVC3 siguiendo el ejemplo y intentando aplicar TDD. En esto que uno de ellos me comento: “Ei, yo ya se que usar dependency injection es una buena decisión de diseño en una aplicación ASP.NET MVC3, pero ¿puedo llegar a esta conclusión mediante un enfoque TDD?” Ostras, la pregunta tiene miga, si la rerformulamos de forma un poco más genérica:

¿nos va a “empujar” TDD a tomar decisiones de diseño “correctas” como el uso de DI o este tipo de decisiones no pueden “emerger” del diseño basado en tests y deben tomarse a priori?

Esta reflexión despertó dos de las preguntas que me hago cada vez que explico/aplico TDD:

  • ¿hasta que punto el diseño “emerge”?
  • ¿hay decisiones de diseño a las que nunca podremos llegar por mucho TDD que hagamos?

el objetivo del post es intentar responder a estas dos cuestiones; para ello me centraré en el ejemplo concreto del uso de DI en una aplicación MVC3 analizando si TDD nos lleva de forma natural a esta decisión, o, por el contrario, TDD nunca nos empujará a tomar una decisión de este tipo.

¿Qué queremos hacer?

La aplicación que usaremos como ejemplo es muy, muy simple: queremos hacer una aplicación ASP.NET MVC3 para mostrar un listado de las películas que hay en cartelera ordenadas por número de espectadores que la han visto. Supongamos que disponemos de un servicio remoto IMoviesService que nos proporciona este listado ordenado y que, al ser un servicio remoto, la consulta de películas puede ser bastante lenta.

Primera iteración: Red, Green, Refactor

Como vamos a desarrollar esta mini aplicación bajo un enfoque TDD lo primero que debemos hacer es crear una prueba para la acción de listado (en esta prueba, para no complicar el ejemplo, simplemente vamos a comprobar que la acción deja en el modelo la colección de películas que devuelve el servicio):

[TestClass]
public class MovieControllerTest
{
    [TestMethod]
    public void ListMoviesPutAllMoviesInModel()
    {
        // Arrange
        var movieController = new MovieController();
        // Act
        var movies = ((IEnumerable<Movie>)movieController.List().Model).ToArray();
        // Verify
        Assert.AreEqual(movies.Length, 5);
        Assert.AreEqual(movies[0].Title, "X-Men");
        Assert.AreEqual(movies[4].Title, "Batman");
    }    
}

Nota: sabemos que el número de películas en cartelera para esta semana es de 5, que hoy la película más vista es “X-Men” y la menos vista “Batman”.


Ejecutamos la prueba y esta ni compila! Red! Evidente, no hemos creado ni el controlador! El siguiente paso es pues crear el controlador y implementar la acción List para que pase la prueba unitaria.

public class MovieController : Controller
{
    public ViewResult List()
    {
        var movieService = new MovieService();
        return View(movieService.AllMovies());
    }   
}

Ejecutamos el test y Bingo! Green!


Refactor: Ya esta ¿no?, nuestro caso de prueba ya pasa  y yo no veo que pueda refactorizar el código por ningún lado… Llegados a este punto podríamos concluir que TDD NO ha hecho emerger la necesidad de usar dependency injection! pero nos olvidamos de algunos detalles importantes:



  • Nuestros tests muy lentos: estamos atacando a la implementación real del servicio y esto es lento.
  • Los tests no son repetibles, dependen del resultado que nos proporcione el servicio. Mañana posiblemente cambie el número de espectadores que han visto las películas y la próxima semana las películas en cartelera.
  • El SUT (Subject Under Test) no es el servicio si no el método List del controller!):



Segunda iteración: introducimos un doble para el servicio


Si queremos que nuestros tests sean rápidos (condición indispensable para que se ejecuten con regularidad) y repetibles tenemos que atacar a un doble del servicio y no a la implementación real. Fijaros que este requerimiento no emerge del proceso normal de TDD (Red, Green, Refactor) ya que los test creados en la primera iteración NO eran correctos.


Necesitamos poder trabajar con dobles des de los test unitarios y con la implementación real cuando se ejecute la aplicación. Tenemos que cambiar el new MovieService() por algo configurable que nos permita usar tanto la implementación real como un doble. La teoría nos dice que podemos hacer esto aplicando un patrón Service Locator (bien sea con una factoría propia o usando un contenedor de IoC), para el ejemplo escogemos la opción de usar una factoría de servicios propia con un método GetService<T> para resolver un servicio y un método RegisterService<T> para registrar servicios (obviamos la creación de esta factoría también lo haríamos siguiendo un enfoque TDD).


Refactorizamos el código de nuestro test para usar la factoría y lo dejamos así:

[TestClass]
public class MovieControllerTest
{
    [TestMethod]
    public void ListMoviesPutAllMoviesInModel()
    {
        // Arrange
        Mock<IMovieService> mock = new Mock<IMovieService>();
        Movie[] movies = new Movie[] 
        {
            new Movie() { Title = "X-Men" },
            new Movie() { Title = "X" },
            new Movie() { Title = "Y" },
            new Movie() { Title = "Z" },
            new Movie() { Title = "Batman" }
        };
        mock.Setup(m => m.AllMovies()).Returns(movies);
        IoCFactory.RegisterService<IMovieService>(mock.Object);
        var movieController = new MovieController();
        // Act
        var movies = ((IEnumerable<Movie>)movieController.List().Model).ToArray();
        // Verify
        Assert.AreEqual(movies.Length, 5);
        Assert.AreEqual(movies[0].Title, "X-Men");
        Assert.AreEqual(movies[4].Title, "Batman");
    }
}

Nota: Usamos Moq para crear un doble del servicio de cartelera, tenéis mas información sobre Moq aquí.


Ejecutamos: Red!


Modificamos el código de la acción List para que obtenga el servicio de nuestra factoría de servicios:

public class MovieController : Controller
{    
    public ViewResult List()
    {
        var movieService = IoCFactory.GetService<IMovieService>();
        return View(movieService.AllMovies());
    }
}

Ejecutamos el test: Green!


Ahora si que ya lo tenemos! Pero tampoco hemos llegado a la necesidad de usar dependency injection! Normalmente nos quedaríamos aquí y no le daríamos mas vueltas pero estamos olvidando la etapa de Refactor! En esta etapa tenemos que refactorizar el código para que, a parte de pasar los test unitarios, nuestro código cumpla los principios de diseño orientado a objetos.


Si analizáis con detenimiento el código generado veréis que no cumple algunos de estos principios:



  • No cumple el principio SOLID de responsabilidad única: El método List tiene dos responsabilidades claras, proporcionar un listado de películas a la vista y gestionar la resolución del servicio mediante la factoría. 
  • Las dependencias de nuestro controlador no están claras, al tener acceso completo a la factoría sus métodos pueden acceder a cualquier objeto que esté registrado en esta.

Nota: otro factor que nos empujaría a refactorizar el código una vez avancemos en el desarrollo es que estaríamos repitiendo la instrucción IoCFactory.GetService<IMovieService>() en todas las acciones del controlador y esto viola el principio DRY. Esto no lo podríamos ver ahora, pero conforme fuéramos implementando acciones se pondría de manifiesto.


Tercera iteración: Necesitamos dependency injection!


Para resolver los problemas anteriores (detectados en la fase de refactoring) tendríamos que poder pasar al controlador la implementación del servicio y usar esa implementación en todas sus acciones, el código de nuestro test debería quedar así:

[TestClass]
public class MovieControllerTest
{
   [TestMethod]
    public void ListMoviesPutAllMoviesInModel()
    {
        // Arrange
        Mock<IMovieService> mock = new Mock<IMovieService>();
        Movie[] movies = new Movie[] 
        {
            new Movie() { Title = "X-Men" },
            new Movie() { Title = "X" },
            new Movie() { Title = "Y" },
            new Movie() { Title = "Z" },
            new Movie() { Title = "Batman" }
        };
        mock.Setup(m => m.AllMovies()).Returns(movies);        
        var movieController = new MovieController(mock.Object);
        // Act
        var movies = ((IEnumerable<Movie>)movieController.List().Model).ToArray();
        // Verify
        Assert.AreEqual(movies.Length, 5);
        Assert.AreEqual(movies[0].Title, "X-Men");
        Assert.AreEqual(movies[4].Title, "Batman");
    }
}

Para hacer este refactoring tenemos que saber que ASP.NET MVC3 nos permite cambiar el resolvedor de dependencias por una implementación propia que use un contenedor de IoC para poder pasar objetos al constructor del controlador y configurar en este contenedor de IoC los servicios que necesitemos. Vemos que el enfoque TDD nos lleva a esto pero debemos tener el conocimiento suficiente de la tecnología con la que trabajamos para ver que la solución es viable.


Nota: Fijaros que como efecto colateral hemos eliminado la necesidad de configurar la factoría de servicios (ni ningún contenedor de IoC) en los test unitarios, creamos el doble en el test y se lo pasamos al controlador!


Ejecutamos: Red!


Modificamos el código del controlador para cambiar el contenedor de IoC por una implementación del servicio y la acción List para que use este servicio:

public class MovieController : Controller
{
    private IMovieService movieService;
    
    public MovieController(IMovieService service)
    {
        this.movieService = service;
    }
    public ViewResult List()
    {        
        return View(this.movieService.AllMovies());
    }
}

Ejecutamos: Green!


Refactor: parece que el código de la acción List no incumple ninguno de los principios de diseño OO y los test se ejecutan correctamente => END!


Conclusión


En esta entrada he intentado explicar mediante un ejemplo concreto una de las dudas que me asaltan con mas frecuencia cuando desarrollo siguiendo un enfoque TDD: ¿hasta que punto TDD nos lleva a hacer “buenos” diseños?


Yo creo que algunas decisiones arquitectónicas de alto nivel se deben tomar inicialmente (por ejemplo, en nuestro caso, el hecho de usar ASP.NET MVC3) pero otras cuestiones de diseño pueden aflorar de una manera mas o menos clara siempre y cuando se cumplan algunas premisas:



  • Conocer las características de un buen test unitario (rápido, repetible, claros, testean solo el SUT, etc…), sin esto nos hubiéramos quedado en la primera iteración y el test hubiera fallado al cabo de un par de días!
  • Alto grado de dominio de la tecnología que estemos usando (en el ejemplo no hubiéramos podido hacer la tercera iteración sin conocer a fondo las capacidades que brinda ASP.NET MVC)
  • Hacer siempre la fase de refactor asumiendo que, a parte de que los casos de prueba pasen, es importantísimo respetar los principios de diseño OO (tampoco hubiéramos llegado el tercer paso sin esto)

Me gustaría saber que opináis al respecto:



  • ¿Creéis que la deducción está muy “cogida con pinzas”?
  • ¿Creéis que la implementación de la acción simple List bajo un enfoque TDD nos lleva de forma natural a deducir que necesitamos DI?
  • ¿Llegaríamos a ella sin tener asumido, a priori, que DI es una buena solución?
  • ¿Dónde está la frontera entre lo que puede aflorar y lo que no?

Saludos, Josep Maria

7 comentarios:

Jose dijo...

Yo creo que en este caso TDD *si* te ha llevado a usar DI porque el primer caso no es un test unitario y para poder hacerlo unitario has necesitado aplicar DI (entendiendo que TDD implica pruebas unitarias y no de integración).

Dicho esto, el caso de DI es un poco especial y el análisis que haces creo que es válido para otras decisiones como usar MVC o no, usar un ORM o no, etc.

Anónimo dijo...

Buen artículo y buena pregunta.

Mi opinión es que esperar que TDD nos lleve a un buen diseño "general" es una radicalización de la idea.

En cambio, sí creo que TDD ayuda a que lleguemos a un buen diseño de negocio (de lo que estemos resolviendo), a una separación de responsabilidades de las clases, a unas semánticas más claras en los métodos... Aunque ese "buen diseño" debería definirse mejor diciendo que es un diseño más fácilmente testeable. Puede haber otros buenos diseños "ahí fuera" que debemos descubrir nosotros: TDD no es un motor de búsqueda de buenos diseños, ni una combinatoria... es una herramienta que nos ayuda a pensar en el problema (como lo son los diagramas de UML).

Se me ocurre preguntar: si estás en una isla desierta y alguien te enseña TDD pero no DI, ¿llegarías a un diseño con DI de forma natural? Pues quizá (si eres bueno y tienes el tiempo suficiente), pero es una pregunta un poco metafísica. Hoy TDD y DI vienen de la mano por necesidad mutua, así que es un poco como preguntarse qué fue primero, el huevo o la gallina.

Encantado de descubrirte, nos seguimos leyendo.
Pablo.

Marc Rubiño dijo...

Creo que las pruebas en si no ayudan a mejorar el diseño, a mi entender la actitud de mejorar tu código y su reutilización si te da pié a eso. Por eso surgieron los patrones de diseño.

xampi dijo...

@Jose ¿Cuando dices que el análisis es valido para otras decisiones te refieres a que estas decisiones deben tomarse a priori? Como comentábamos en twiter, yo opino que TDD no nos ayudará (o al menos no he visto todavía como) a tomar estas decisiones mas arquitectónicas.

Saludos!

xampi dijo...

@Pablonete Gracias! Leyendo tu comentario me doy cuenta de que quizá el ejemplo de DI no haya sido el más adecuado para ilustrar el razonamiento pq, como dices, TDD está íntimamente ligado a usar DI... He construido el post en base a DI pero el concepto es extrapolable a otras consideraciones de diseño "complejas" como, por ejemplo, el uso del patrón repositorio o implementar una unidad de trabajo... ¿Llegaríamos a ello empujados por TDD?

xampi dijo...

@Marc Rubiño Yo creo que el hecho de tener que pensar primero las pruebas te permite mirar des de fuera tu código y solo por eso ya me gusta el enfoque... Y, como dices, la fase de refactor es clave para conseguir buenos diseños (y esta es la pregunta que me hago siempre, es suficiente para llegar a un buen diseño).

O todavía más metafísico: ¿TDD nos ayuda a no "sobrearquitecturar" nuestras soluciones? Es decir, si aplicando TDD no llegas de forma natural a la conclusión de que hay que aplicar un patrón repositorio, por ejemplo, quizá es porqué NO lo tienes que aplicar ;-)

Gracias por el comentario!

Anónimo dijo...

Bonjour xampi.blogspot.ru. J'ai trouvé votre site Web via Google tout en recherchant une affaire semblable, votre site web arrivé ici en place. Il semble bon. J'ai un signet dans mon google bookmarks de revenir plus tard.