Pensamiento lateral en pruebas unitarias: Introducción y puesta en marcha

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

Existen multitud de frameworks para desarrollar pruebas unitarias (Moq, Rhino Mocks, NSubstitute,…) y unos cuantos blogs que realizan las pertinentes comparaciones comentando los pros y contras de cada uno de ellos, realmente son en gran medida similares y solo difieren en unos pocos aspectos que en general son más dependientes de gustos personales que funcionales, en este punto quizás solo destacaría Microsoft Fakes como el más completo, aunque lamentablemente es de pago -por eso probablemente no sea el más conocido-.

A la hora de hacer pruebas unitarias más allá del framework particular existen las buenas prácticas como el patrón AAA (Arrange, Act, Assert) del cual solo puedo decir que pertenece a esa época en la que cualquier idea envuelta en un acrónimo le llamaban patrón -pero bueno ahí esta para el que no sepa donde van los saltos de línea en una prueba unitaria-.

En general se considera buena idea, -de hecho por propio concepto- que las pruebas unitarias estén auto contenidas, es decir que todo lo que implica ejecutar esa prueba unitaria esté dentro de la misma, esto es interesante pero también a veces puede generar pruebas demasiado largas o un tanto difíciles de entender y mantener, los frameworks actuales intentan hacerlo más cómodo usando técnicas fluent pero aun así a veces es difícil de evitar.

Algunas veces para evitar esto se generan pequeños trozos de código reutilizable, como por ejemplo las clásicas entidades de base de datos que se usan en muchas pruebas (Given.A.User…) o pequeños trozos de invocación de métodos (autenticación, llamadas a handlers…) en este punto cada cual puede estar más o menos a favor o en contra de esto, al final el problema reside en cuantos ficheros de código quiero tener o como de grandes puedo tolerar que sean mis pruebas.

Las pruebas unitarias generalmente tienen a degradarse más rápido que el resto del código a fin de cuentas si se generan algunos ficheros de código reutilizable puede que la nueva prueba unitaria no haga uso de los mismos por desconocimiento o que diferentes pruebas según quién las haga tengan un formato diferente, seguro que ahora algunos pensarán que para eso se inventaron las pull request, pero… ¿Quién revisa realmente bien las pruebas unitarias de una pull request?.

De hecho uno de los problemas añadidos es que a veces interesa enfocar las pruebas de diferentes formas, por ejemplo las pruebas unitarias sobre una base de datos (ya sea creada ex profeso o en memoria) tienden a ser más cómodas de realizar porque se puede usar el mismo código que se usaría normalmente para el código de la aplicación y evita tener que lidiar con el framework correspondiente averiguando como hacerlo.

El problema es que suele acarrear una mayor complejidad a la hora de realizar las pruebas y puede proporcionar un peor rendimiento, una de la razones -entre otras muchas- por las que se creo el concepto flaky tests, una forma de resignarse a que algunas pruebas nunca funcionarán bien del todo pero aun así hay que desplegar.

Otro de los problemas clásicos es como hacer un fake de los servicios, si el servicio se ha creado en la propia aplicación no es un problema pero si es un servicio de terceros quizás las cosas se compliquen un poco aunque sea un servicio que realiza una tarea concreta y de poco riesgo (por ejemplo Azure emitiendo una notificación) aun así puede ser interesante a veces poder realizar algún tipo de procesamiento.

Es verdad que un buen sistema de pruebas unitarias puede hacer mucho de este trabajo pero el problema es que el framework tiene que ser capaz de ver la interfaz del servicio y poder actuar sobre ella y eso a veces no es trivial, así que para el resto de los conceptos que se van a explicar vamos a suponer que no se usan estos frameworks -no es un truco fácil, tiene su sentido que se verá más adelante-.

Podríamos decir que hay tres escenarios posibles según el nivel al que queramos trabajar.

  1. No hacer nada, simplemente confiamos en la implementación original del servicio que hemos creado y es lo que realmente queremos probar, esto que puede parecer trivial no lo es tanto si el servicio no está preparado, por ejemplo por tener acceso a bases de datos u otros elementos externos.
  2. Crear una clase que implemente la interfaz con código útil para las pruebas unitarias, en este caso solo quedaría inyectar la interfaz o usar el framework correspondiente para indicárselo.
  3. Cambiar solo la implementación de un método, esto puede sonar raro pero hay interfaces que pueden tener muchos métodos, por ejemplo la interfaz INotificationHubClient de Azure tiene más de 130 métodos y puede que solo usemos unos pocos y no nos interese añadirlos todos.

Y por supuesto queda la combinación, es decir, en algunas pruebas igual solo nos interesa el punto 2 o el 3, pero el resto deberían usar el 1 y para esto aunque xUnit de algunas facilidades no siempre es tan fácil y tampoco suele ser interesante crear diferentes clases y agruparlas en base a razones técnicas, se suele preferir agrupar por criterios de características o de escenarios.

También estaría bien poder crear estas pruebas de forma cómoda y rápida sin tener que revisar la documentación cada vez que haya un caso un poco diferente y como no también estaría bien no tener muchos ficheros con los que trabajar y que la prueba unitaria fuese fácil de entender y con poco margen de error para que la mayor cantidad de pruebas tuvieran un aspecto lo más similar posible.

En este caso vamos a comentar brevemente el punto 1, el 2 y el 3 quedan para los siguientes capítulos.

.Net tiene una clase que se llama WebApplicationFactory<T> que ayuda bastante en este trabajo, incorpora entre otros un método ConfigureServices(), por ejemplo:

public class AWebApplicationFactory : WebApplicationFactory<Startup>
{
  protected override void ConfigureWebHost(IWebHostBuilder builder)
  {
  	builder.ConfigureAppConfiguration((hostingContext, configurationBuilder) =>
  	{
  	  configurationBuilder.Sources.Clear();
  	  configurationBuilder
  		.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
  	  .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
  	});
  
  	builder.ConfigureServices(services =>
  	{
  	  services.AddScoped<INotificationService, NotificationService>();
  	  services.AddScoped<IBlobService, BlobService>();  
  	  var sp = services.BuildServiceProvider();
  	  using (var scope = sp.CreateScope())
  	  {
  		var scopedServices = scope.ServiceProvider;
  		var db = scopedServices.GetRequiredService<ADbContext>();
  		db.Database.EnsureCreated();
          }
  	}).UseEnvironment("Test");
  }
}

En este caso hemos creado un host de test que tiene un método ConfigureServices() que emula prácticamente la misma funcionalidad que un host real, aquí podemos añadir servicios e implementaciones concretas como haríamos en el host, este método se ejecutará después del ConfigureServices() del host real. De igual forma tenemos un ConfigureAppConfiguration() para poder adaptar la configuración.

Este es un ejemplo sencillo que se puede ampliar consultando la documentación relacionada. Esto cubriría el primer caso pero no el caso en el que queremos que pruebas concretas utilicen otra implementación de la interfaz del servicio, esto queda para el próximo capítulo.

Pensamiento lateral en pruebas unitarias: Introducción y puesta en marcha