Tests unitarios y dependencias

Los que siguen este blog saben que los tests automatizados son algo que me interesa mucho y que considero muy importante para poder mantener la calidad de un proyecto a medio plazo. He escrito bastante sobre tests unitarios, incluyendo mis experiencias sobre cómo no escribir tests unitarios donde se explica bastante bien mi filosofía a la hora de escribir tests.

En este post vamos a intentar analizar los tipos de dependencias que podemos encontrar en un método y cómo nos afectan a la hora de testearlo, tanto desde el punto de vista de la facilidad para escribir el test, como de la fiabilidad y utilidad del mismo, dos factores, que, como veremos, están muy ligados.

Sin dependencias

Es el caso más simple que podemos encontrar a la hora de escribir un test. Tanto, que es el primer ejemplo que se suele poner con cosas del tipo:

public int Add(int a, int b)
{
   return a + b;
}

[Test]
public void AddTwoPositiveNumbers()
{
  Assert.AreEquals(6, Add(2, 4));
}

En estos tests estamos tratando con funciones puras en las que el resultado únicamente depende de los parámetros de entrada. El mayor problema que podemos encontrar es que a veces la construcción de los parámetros puede ser complicada, pero hay técnicas para conseguir que el test no dependa demasiado de la forma de construir sus parámetros.

Es el caso ideal para testear porque no hay ninguna dependencia adicional, lo que hace que sean mucho más fáciles de escribir y de entender, y por ello los que ofrecen mayores garantías de que el test es correcto y, por tanto, el código testeado también.

Dependencia sobre el estado interno del objeto

Uno de los pilares de la programación orientada a objetos es, precisamente, crear objetos que encapsulen datos y comportamiento. Esto lleva a tener métodos cuyo comportamiento dependen del estado interno del objeto o que modifican este estado.

Un ejemplo típico sería algo así:

public class Order
{
  public List<OrderLine> lines = new List<OrderLine>();
  
  public void Add(string product, decimal price)
  {
    lines.Add(new OrderLine(product, price));
  }

  public decimal Total 
  {
    get { return lines.Sum(x => x.Price); }
  }
}

[Test]
public void TotalIsTheSumOfProductPrices()
{
  var order = new Order();
  order.Add("Mejillones", 4m);
  order.Add("Lentejas", 1m);
  
  Assert.AreEquals(5, order.Total);
}

Este caso es algo peor que el caso anterior, porque para poder escribir los tests tenemos que tener en cuenta el estado interno del objeto, lo que implica un mayor conocimiento de lo que está pasando y suele requerir más configuración para poder realizar la validación que queremos.

Además, puede darse el caso de que el estado interno que queremos testear no sea observable, lo que complica un poco más la cosa, pero en general, estos tests suelen ser fiables y aportar bastante valor.

Dependencia sobre el resultado de invocar métodos en otros objetos con resultado observable

Aquí las cosas se empiezan a poner un poco peor. Cuando empiezas a separar un diseño en distintas clases, es frecuente que unas clases dependan de otras y tenga que invocarlas métodos en ellas para poder implementar su funcionalidad.

Un ejemplo típico es un servicio de aplicación que depende de un repositorio para acceder a la base de datos:

public void DiscontinueProduct(int productId, string reason)
{
  var product = repository.FindById(productId);
  product.Discontinue(reason);
}

[Test]
public void ItSetsTheProductAsDiscontinued()
{
  var repository = new Mock<IProductRepository>();
  var service = new ProductService(repository);

  var fanta = new Product(15, "Fanta Naranja");
  repository.Stub(x => x.FindById(15)).Return(fanta);

  service.DiscontinueProduct(15, "Ya no vendemos fanta");

  Assert.IsTrue(fanta.Discontinued);
  Assert.AreEquals("Ya no vendemos fanta", fanta.DiscontinueReason);
}

El método a testear DiscontinueProduct, no produce ningún resultado observable a través de un valor de retorno ni del estado de la clase que lo contiene, pero genera cambios observables indirectamente en otros objetos, en este caso un objeto Product.

En este caso la preparación del test empieza a ser más complicada. Para empezar, al escribir el test ya estamos asumiendo que la carga del producto se realizará a través de un IProductRepository y usando el método FindById, lo en cierto modo está acoplando el test a la implementación interna del método.

Para testearlo, estamos usando un mock, que es la alternativa más habitual (aunque a mi cada vez me guste menos usar mocks), lo que hace que sea más complicado de leer.

Además, el test es más frágil porque si decidimos cambiar la forma en que se carga el producto, el test fallará aunque la implementación fuese correcta, minimizando uno de los beneficios del test: permitir la refactorización segura.

Dependencia sobre la actuación de otros objetos sin resultado observable

Este es el peor caso que podemos encontrar en un test. Tenemos un método que se limita a coordinar la actividad de otros objetos sin que ninguno de ellos tenga un estado que podamos observar fácilmente.

Algo así:

public void NotifyPreferredCustomers()
{
  var customers = repository.FindPreferred();
  foreach (var customer in customers)
    notificationSender.SendNotification(customer);
}

[Test]
public void EachPreferredCustomerIsNotified()
{
  var repository = new Mock<ICustomerRepository>();
  var sender = new Mock<INotificationSender>();
  var service = new CustomerService(repository, sender);
  
  var customers = new [] { new Customer("Marta"), new Customer("Pedro") };
 
  repository.Stub(x => x.FindPreferred()).Return(customers);

  service.NotifyPreferredCustomers();
  
  sender.AssertWasCalled(x => x.SendNotification(customers[0]));
  sender.AssertWasCalled(x => x.SendNotification(customers[1]));
}

Este test tiene varios problemas. Por una parte, el código del test es mucho más complicado que el código que queremos testear. Parece más fácil introducir un bug en el propio test que en el código de producción.

Por otro lado, el nivel de acoplamiento entre el test y el código es enorme. Prácticamente estamos replicando la lógica en el test.

Personalmente, no suelo escribir este tipo de tests de interacción porque me parece que no son rentables. Prefiero intentar cubrirlos con tests de integración que ofrezcan un nivel de confianza mayor.

Conclusiones

Como hemos visto en este post, no todos los tests unitarios son iguales. Hay ciertos tipos de tests que resultan mucho más fáciles de escribir y mucho más útiles que otros.

Una buena técnica es intentar concentrar el máximo de lógica posible en tests de los primeros 2 tipos y centrarse en testear esa lógica bien. Los tests que empiezan a depender de otros objetos son menos prácticos, y cuando llegas al punto de testear puramente interacciones, plantéate seriamente si realmente te merece la pena.