Pensamiento lateral en pruebas unitarias: Servicios para todos

Introducción y puesta en marcha
Servicios para todos
Servicios para unos pocos
¿Y el resto?

¿Cómo haríamos para cambiar la implementación de un servicio concreto? Bueno puede ser que queramos mantener algunos servicios con la implementación original pero también poder sobrescribir durante la inicialización algunos de estos, ampliamos el ejemplo anterior:

builder.ConfigureTestServices((services) =>
{
  services.AddScoped<INotificationService, NotificationServiceFake>();
  services.AddScoped<IBlobService, BlobServiceFake>();
});

En este caso con el método ConfigureTestServices() pisamos la inyección de ConfigureServices() sobrescriendo la configuración de esta, ConfigureTestServices() se ejecuta después de ConfigureServices() así que de esta forma podríamos tener las dos opciones, mantener algunas con la implementación original y sobrescribir otras que nos interesen.

El problema aquí queda en que la implementación fake es la misma para todas las pruebas unitarias y eso puede que no siempre interese, se puede realizar lógica en el código fake para evitar esto por ejemplo creando identificadores concretos de pruebas y en base a esto se devuelva una u otra información -sí, un poco rebuscado-.

También existe la opción de crear una especia de almacén compartido entre esta clase y la prueba unitaria que permite acceder solo a la información que necesita una prueba concreta, pero es un código poco apropiado para pruebas unitarias, fácil que aumente su complejidad y perdería en cierta medida el concepto de unitario -sí, aún más rebuscado-.

En cualquiera de las opciones anteriores esto terminará con una implementación muy larga, es mejor mantener estas clases (las que indicamos en ConfigureServices() y ConfigureTestServices()) con un código común que puedan usar la gran mayoría y enfocarse en solucionar el problema de las pocas pruebas que no encajen en este patrón.

Una forma sencilla de lidiar con esto sería sobrescribir las implementaciones bajo petición, por ejemplo podríamos tener una prueba que hiciera algo como:

public async void A_Cool_Test_Returns_Always()
{
  Scenario
    .AddService<INotificationService, NotificationServiceCustomFake>()
    .Build();

  User user = Scenario.AUser();
  Model model = new Model();
  var id = Guid.NewGuid().ToString();
  model.Id = id;
  model.UserId = user.Id;
  
  var httpClient = Scenario
    .UseHttpClient
    .WithZumoVersion3()
    .WithValidAuthHeaders()
    .WithRelativeUri(Uri.Quick.CreateSomething());

  var response = await httpClient.SendPostAsync(model);
  response.StatusCode.Should().Be(HttpStatusCode.Created);
  var result = await response.ReadAsAsync();

 ... Verificación
}

Lo que estamos haciendo es usar el método AddService<T,U>() para añadir una implementación bajo demanda de un test concreto, esto provocará un cambio parcial en el host concreto de este test, lo cual supone una cierta merma de rendimiento pero en general no supone un grave problema y vale para este escenario -como siempre la clave es no abusar-.

Para poder hacer esto nos apoyamos en el propio método ConfigureTestServices() , un ejemplo podría ser:

public class ScenarioFixtureBuilder
{
  private ReplicaWebApplicationFactory factory;
  private readonly Dictionary<Type, Type> dependencies;
  
  public ScenarioFixtureBuilder AddService<TService, TImplementation>()
  	where TService : class
  	where TImplementation : class, TService
  {
  	this.dependencies.Add(typeof(TService), typeof(TImplementation));
  	return this;
  }
  
  public HttpClientBuilder UseHttpClient
  {
  	get
  	{
  	  if (this.httpClientBuilder == null)
  	    throw new InvalidOperationException("Build() not called!");
  	  return httpClientBuilder;
  	}
  }	
  
  public void Build()
  {
    var builtfactory = this.factory.WithWebHostBuilder(builder =>
    {
  	  builder.ConfigureTestServices((services) =>
  	  {
  	    foreach (var dependency in dependencies)
  	    {
  	      services.AddScoped(dependency.Key, dependency.Value);
  	    }
  	  });
  	});
  	this.httpClientBuilder = new HttpClientBuilder(builtfactory.CreateClient());
    }
}	

Aquí añadimos los servicios a un diccionario que luego usaremos al construir el escenario usando el método ConfigureTestServices() en el método Build().

Lo único que hay que tener en cuenta es que si vamos a crear un HttpClient para realizar pruebas contra una Api este HttpClient tiene que ser el generado en el método Build() ya que cualquier otro apuntará a otra versión del host y no funcionará (en este caso lo guardamos en la variable httpClientBuilder).

Vamos ahora con el tercer supuesto, solo interesa cambiar un método concreto y el resto no nos interesa ni lo vamos a implementar, ni siquiera queremos crear la clase que lo implemente aunque sea para devolver valores por defecto, es algo que solo queremos de una forma puntual para un test concreto y queremos una implementación concreta.

También queremos poder combinarlo con los métodos anteriores, es decir, el clásico todo lo anterior y además… bueno este caso es bastante más complicado y se comenta en el siguiente capítulo.

Pensamiento lateral en pruebas unitarias: Servicios para todos