Cómo NO escribir tests unitarios: Verificando interacciones

Acabando con la serie de cosas que hice mal escribiendo tests unitarios, hoy llega un punto que puede resultar controvertido, sobre todo para los amantes de las librerías de mocks y de alcanzar el 100% de cobertura con los tests. En general, no merece la pena testear interacciones entre clases.

Los tests unitarios se pueden dividir en dos grandes categorías:

  • Tests de estado. Son tests en los que se valida el estado de un objeto, que puede ser el propio objeto testeado, un parámetro de un método o el valor de retorno de un método. Son los típicos tests que acaban un Assert.AreEquals(...).
  • Tests de interacción. Son tests en los que se comprueba que se realizan las invocaciones correctas entre objetos, posiblemente verificando también que los parámetros eran los adecuados. Casi siempre se implementan con ayuda de alguna librería de mocks/fakes/stubs, como Moq, FakeItEasy o Rhino.Mocks, y suelen acabar con un service.AssertWasCalled(x => x.SendEmail()).

Los tests de interacción presentan varios problemas que, desde mi punto de vista, los hacen mucho menos prácticos que los tests de estado.

Los tests de interacción son poco fiables

Al final, lo que están testeando es que las llamadas entre componentes se realizan como deben, pero no que los componentes hacen lo que deben hacer. Sí, se que para muchos esa es la definición de test unitario, testear cada componente por separado, pero yo no lo tengo tan claro.

Parten de la premisa de que los contratos marcados con los interfaces de los componentes son los adecuados, pero muchas veces, sobre todo cuando se hace diseño de arriba a abajo, vamos creando contratos que luego no cumplimos de la forma que estaba prevista, o directamente no podemos cumplir.

Los tests de interacción son frágiles

El mayor de los problemas de los tests de interacción, y especialmente cuando se usan librerías para crear los mocks/fakes/stubs, es que son una copia prácticamente exacta del código de producción y ya dije en su momento que es un error duplicar el algoritmo testeado en el propio test.

Cuando se usan tests de interacción, es muy frecuente ver código como el del siguiente ejemplo. Supongamos que tenemos un método que recibe una cadena de texto encriptado que contiene un objeto Order serializado que debemos guardar en la base de datos:

public class OrderService
{
   public void AddEncryptedOrder(string encryptedSerializedOrder)
   {
       string serializedOrder = cryptoService.Decrypt(encryptedSerializedOrder);
       Order order = serializer.Deserialize(serializedOrder);
       repository.Add(order);
   }
}

public class OrderServiceTest
{
   public void AddEncrypterOrder_Add_The_Actual_Order_To_The_Repository()
   {
       // Arrange
       var cryptoService = MockRepository.GenerateMock<ICryptoService>();
       var serializer = MockRepository.GenerateMock<IOrderSerializer>();
       var repository = MockRepository.GenerateMock<IOrderRepository>();
       
       cryptoService.Stub(x => x.Decrypt("encryptedOrder"))
           .Return("unencryptedOrder");
       
       Order theOrder = Build.Order();
       
       serializer.Stub(x => x.Deserialize("unencryptedOrder"))
           .Return(theOrder);
       
       var service = new OrderService(repository, serializer, cryptoService);
       
       // Act
       service.AddEncryptedOrder("encryptedOrder");
       
       // Assert
       repository.AssertWasCalled(x => x.Add(theOrder));
   }
}

Un buen test debe validar lo que hay que hacer, no cómo hacerlo. Este test está especificando demasiado la forma de hacer las cosas, hasta el punto de que si quisiéramos, por ejemplo, usar una sobrecarga distinta del método Deserialize, aunque el código fuese válido, el test fallaría.

Aisla y potencia los tests de estado

Invertir tiempo en testear métodos como el del ejempo anterior puede no ser muy rentable y seguramente a muchos no nos duela dejar de testearlo. Sin embargo, hay veces en que un método tiene dependencias de otros servicios y, a la vez, lógica que es importante testear.

Cuando se produce esta situación, lo mejor es refactorizar el método para aislar la parte que se puede testear con un test de estado de la parte que depende de factores externos.

Por ejemplo, el siguiente código usa TinyTwitter para revisar los tweets de nuestro timeline y hacer un retweet de todas las respuestas que hemos tenido.

public void RetweetReplies(string user)
{
   var tweets = twitter.GetTimeline();
   foreach (var tweet in tweets)
   {
       if (tweet.Text.StartsWith(user))
       {
           var message = string.Format("RT {0}: {1}", tweet.Username, tweet.Text);
           twitter.UpdateStatus(message);
       }
   }
}

El problema de este código es que se mezcla la interacción con el objeto twitter, el servicio que nos permite obtener y enviar tweets, con la lógica necesaria para saber que si un tweet es un reply y para generar un retweet.

Testear esa lógica con el método tal cual está es complicado porque obliga a preparar stubs sobre las llamadas a twitter.GetTimeline, pero si refactorizamos el método:

public void RetweetReplies(string user)
{
   var tweets = twitter.GetTimeline();
   var retweets = GetRetweets(user, tweets);
   foreach (var retweet in retweets)
       twitter.UpdateStatus(retweet);
}

public IEnumerable<string> GetRetweets(string user, IEnumerable<Tweet> tweets)
{
   foreach (var tweet in tweets)
   {
       if (tweet.Text.StartsWith(user))
           return string.Format("RT {0}: {1}", tweet.Username, tweet.Text);
   }
}

Acabamos con algo más de código, pero hemos separado la parte de interacción con componentes externos de la parte de lógica «pura». De hecho, GetRetweets es una función sin side effects (efectos colaterales), que es seguramente lo mejor que nos podemos encontrar para escribir un test.

Con la nueva estructura escribir tests para distintos casos es mucho más sencillo porque no depende de mocks, fakes, stubs, ni de nada externo al propio método. De esta forma podemos cubrir más comódamente todos los casos que consideremos necesarios para estar seguros de que nuestra lógica funciona, sin introducir ruido adicional en esos tests tratando de lidiar con las dependencias externas.

Conclusión

Los tests de interacción suele aportar mucho menos valor que los test de estado, siempre que sea posible, separa la lógica en métodos que no dependan de servicios externos y puedan ser testeados cómodamente.

Un test no es diferente del resto de cosas que manejamos durante el desarrollo de la aplicación y no debe escribirse «porque sí». Si un test no va a aportar valor, es mejor no escribirlo porque lo único que conseguirás es invertir tiempo al escribirlo, al mantenerlo y en esperar a que se ejecute cada vez que lances la suite completa de tests.

Esto no quiere decir que no merezca la pena escribir tests, al contrario, creo que es de lo mejor que puedes hacer por una aplicación, pero haz que esos tests sean realmente útiles.

2 comentarios en “Cómo NO escribir tests unitarios: Verificando interacciones

  1. No estoy de acuerdo en que las pruebas unitarias sólo deben validar lo que hay que hacer, el cómo está hecho también es muy importante.

    Las pruebas unitarias ayudan a crear una arquitectura bien estructurada y testeable. Si implementas pruebas unitarias orientadas al cómo está hecho logras que la arquitectura de tu aplicación sea más robusta.

    Es cierto que lo normal es que en el código de pruebas el 80% sean «stubs» y el resto «mocks» pero me parece excesivo tanta argumentación en contra de los «tests de interacción».

    Lo dicho, las pruebas unitarias son importantes para validar las respuestas de la aplicación pero también para garantizar una arquitectura robusta. Es en este segundo punto especialmente donde las pruebas de interacción cobran importancia.

  2. Sabía que podía ser un tema controvertido y, por tanto, divertido :-)

    Dices que «Las pruebas unitarias ayudan a crear una arquitectura bien estructurada y testeable.». Estoy de acuerdo, aunque más que arquitectura yo diría diseño. Si tienes que escribir tests unitarios para una aplicación, generalmente acabas teniendo un diseño mejor. El «diseño mejor» es un «subproducto» (muy grato) de las pruebas unitarias, que obligan a hacer un código más SOLID.

    Lo que no veo tan claro es cómo los tests de interacción ayudan a tener una arquitectura más robusta. ¿A qué te refieres exactamente con eso? ¿A cosas como validar que desde un Controller se pasa por un IRepository en lugar de acceder directamente a la base de datos?

Comentarios cerrados.