Pensamiento lateral en pruebas unitarias: ¿y el resto?

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

Sí, es cierto que no todo son servicios en pruebas unitarias de hecho elementos comunes que se suelen encontrar aparte de servicios -que generalmente es lo más complicado de reemplazar- sería la propia verificación del resultado para lo cual existen interesantes librerías como FluentValidation aunque adolece de alguna carencia sobre todo en lo relacionado con recuperación de información de otras fuentes, aunque es verdad que este no es es su principal cometido.

Este escenario es muy habitual y tiene múltiples soluciones, por ejemplo:

private DbSet<T> Set<T>() where T : class
{
	var context = this.factory.Services.GetRequiredService<ReplicaDbContext>();
	return context.Set<T>();
}

Este es un ejemplo muy sencillo de crear un Set<T> de una base de datos para realizar consultas sobre la misma, también igualmente se puede usar para borrar datos enteros o volverlos a generar para pruebas concretas, existen librerías más especializadas como Respawn que cubren muy bien estos escenarios y con unos pocos métodos de extensión cubren la mayoría de los escenarios.

Otro caso distinto son las entidades que se usan para generar la prueba, principalmente los objetos que tienen que estar disponibles para la prueba, como entidades en base de datos o la propia petición que se envía a una API, en este caso volvemos al problema que comentamos antes, el equilibrio entre no tener pruebas demasiado largas y tampoco generar demasiado código reutilizable.

Estaría bien tener una forma cómoda y rápida de generar objetos sin tener que crear muchos ficheros y evitar generar excesivo código, o incluso código que genera los objetos necesarios para hacer las pruebas, -sí, esto existe-.

Podríamos intentar algo como:

var person = FluentBuilder
               .Create<Killer>()
	       .With(n => n.Name).Set("Hola")
	       .With(n => n.Surname).Set("Caracola")
               .Build();

No deja de ser una forma rápida de asignar valores a propiedades, con la comodidad de poder añadir algo de código de ser necesario, esto es especialmente útil para evitar crear objetos directamente y también en la propia prueba unitaria se puede ver que tipo de objeto se está creando con que propiedades en lugar de una referencia a un objeto común. De igual forma evita generar enormes clases fluent para poder generar cualquier objeto en cualquier escenario posible.

Este sería un ejemplo de implementación:

using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

namespace A.Builders
{
    public static class FluentBuilder
    {
        public static FluentBuilderFeatures<TEntity> Create<TEntity>()
            where TEntity : class, new()
        {
            TEntity entity = new TEntity();
            return new FluentBuilderFeatures<TEntity>(entity);
        }

        public static FluentBuilderFeatures<TEntity> Create<TEntity>(
          params object[] parameters) where TEntity : class
        {
            var constructor = typeof(TEntity)
                .GetConstructors()
                .Where(c => c.GetParameters().Length == parameters.Length
                &&
                Enumerable.SequenceEqual(
                    parameters.Select(p => p.GetType()), 
                    c.GetParameters().Select(c => c.ParameterType))
                    )
                .Single();
            var entity = (TEntity)constructor.Invoke(parameters);
            return new FluentBuilderFeatures<TEntity>(entity);
        }
    }

    public class FluentBuilderFeatures<TEntity>
        where TEntity : class
    {
        private TEntity entity = default(TEntity);

        private FluentBuilderFeatures() { }

        internal FluentBuilderFeatures(TEntity entity)
        {
            this.entity = entity;
        }

        public FluentBuilderProperty<TEntity, TProperty> With<TProperty>(Expression<Func<TEntity, TProperty>> propertyExpression)
        {
            return new FluentBuilderProperty<TEntity, TProperty>(
              this, entity, propertyExpression);
        }

        public TEntity Build()
        {
            return entity;
        }
    }

    public class FluentBuilderProperty<TEntity, TProperty>
        where TEntity : class
    {
        private TEntity entity = default(TEntity);
        private FluentBuilderFeatures<TEntity> builder = default(FluentBuilderFeatures<TEntity>);
        private Expression<Func<TEntity, TProperty>> propertyExpression = default(Expression<Func<TEntity, TProperty>>);
        private FluentBuilderProperty() { }

        internal FluentBuilderProperty(
            FluentBuilderFeatures<TEntity> builder,
            TEntity entity,
            Expression<Func<TEntity, TProperty>> propertyExpression)
        {
            this.entity = entity;
            this.builder = builder;
            this.propertyExpression = propertyExpression;
        }

        public FluentBuilderFeatures<TEntity> Set<TValue>(TValue value)
        {
            var property = GetPropertyInfo();
            property.SetValue(entity, value, null);
            return builder;
        }

        public FluentBuilderFeatures<TEntity> Get<TValue>(ref TValue value)
        {
            var property = GetPropertyInfo();
            value = (TValue)property.GetValue(entity);
            return builder;
        }

        private PropertyInfo GetPropertyInfo()
        {
            var memberSelectorExpression = 
              propertyExpression.Body as MemberExpression;
            if (memberSelectorExpression != null)
            {
                var property = memberSelectorExpression.Member as PropertyInfo;
                if (property != null)
                {
                    return property;
                }
            }
            throw new InvalidOperationException($"Action on a non-property {memberSelectorExpression.Member.ToString()}");
        }
    }
}

La idea en general de estos capítulos es poder tener pruebas unitarias lo más auto contenidas posible y que no implique la generación de muchos ficheros, que sean fáciles de mantener y también que sean sencillo crear nuevas para alguien que no esté acostumbrado a crearlas o que acabe de entrar al proyecto.

Es verdad que los frameworks actuales de pruebas ayudan a simplificar la generación de tests y son válidos en muchas ocasiones, pero también es verdad que poder usar el mismo estilo de código en la prueba que en el código de la aplicación ayuda a crear pruebas más rápido y de forma más intuitiva sin tener que consultar en la documentación formas de poder hacer una prueba unitaria por un caso concreto.

Posiblemente estos problemas se mitiguen con la práctica pero también alejan un poco al desarrollador del código original, poder crear una prueba como si fuera código de la aplicación con las mismas librerías y estilo hace una transición más fácil del código de la aplicación al código de las pruebas y ayuda a crear pruebas de de forma fácil y rápida en el que se vea mejor la intención de la prueba sin estar rodeada de código de librerías o de infraestructura que complican ver realmente que casos cubre esa prueba.

Bibliografía

En este caso ChatGPT me ha ayudado para el código Emit, aunque no ha sido capaz de sacarme todos los casos -habrá que probar la versión 4 cuando sea gratis-.

Lyrics

Face The PainStemm
Faster Within Temptation
Soul CreatorCinderella

Pensamiento lateral en pruebas unitarias: ¿y el resto?

Pensamiento lateral en pruebas unitarias: Servicios para unos pocos

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

Bien, estamos en el supuesto que queremos cambiar solo la implementación de un método concreto de una interfaz, un caso de uso concreto podría ser querer cambiar la implementación del método SendNotificationAsync(…) de la interfaz INotificationHubClient del paquete Microsoft.Azure.NotificationHubs.

Es verdad que algunos servicios de Azure se pueden emular con Azurite o en caso de bases de datos se puede crear una, pero no siempre es posible, no todos los servicios tienen una versión para test o no queremos lidiar con infrastructura compleja pero si puede interesar que el método final al que no tenemos acceso SendNotificationAsync(…) no intente nada raro, es decir, no queremos que se ejecute y lance una notificación o lo intente, queremos que tenga un comportamiento concreto.

Afortunadamente la mayoría de los métodos de las librerías de Azure implementan algún tipo de interfaz y muchas veces se puede inyectar esa librería en el propio host lo que también da opción a que se puede trabajar con ella a nivel de prueba unitaria.

Esto último es importante porque no nos interesa solo conque devuelva unos ciertos valores fijos de salida en base a unos valores de entrada como sería un caso habitual, ni queremos usar el método comentado anteriormente sobrescribiendo la interfaz con una implementación personalizada, esto nos condicionaría el resto de las pruebas y solo nos interesa en un test concreto.

Bien, esto… es bastante complicado, porque interesa hacerlo de una forma demasiado genérica y dinámica, queremos poder implementar cualquier método de una interfaz cualquiera sin tener que crear la clase expresamente -lo recalco en este punto para que cada cual se sienta libre de pensar en como solucionar esto-.

Llegados a este punto posiblemente la mayoría ha pensado en usar reflection, no es una tecnología que suela usar porque casi siempre hay una forma mejor y más sencilla de hacer las cosas, -lo siento por los fans de reflection-.

Pero en este caso es inevitable, de hecho incluso el uso habitual de reflection se queda corto, porque reflection permite hacer muchas cosas pero la mayoría son de modo lectura, por ejemplo podemos sacar mucha información de clases, métodos, etc… incluso ejecutar algunos, pero no podemos modificarlos a nivel de estructura y en este caso nos interesa, porque queremos implementar una interfaz I en una clase C cuando esta clase no la implementa de serie ni queremos hacerlo de manera habitual por código.

A partir de aquí el campo imaginativo se expande con inmensa felicidad para aquellos que gustan de reinventar la rueda (Dinamyc Proxy, Source Generators, Expression Trees…) y cualquier otra idea… creativa. Todas tienen sus pros y sus contras pero en este caso vamos a rizar el rizo y vamos a usar la librería Emit (que también pertenece a reflection) -no hay miedo-.

Esta librería casualmente -o no- también es utilizada por los principales paquetes de pruebas unitarias y no sin razón porque permite realizar casi cualquier cosa sin saber muy bien de donde vienen los tiros, se pueden crear tipos dinámicos con métodos dinámicos sacando la información para crearlos mediante reflection en otro tipo que tampoco sabemos en tiempo de compilación que estructura tiene -una fiesta-.

Pero en este caso es muy útil, porque vamos a hacer lo siguiente, vamos a crear una clase dinámicamente en tiempo de ejecución y le vamos a decir que implemente al vuelo una interfaz cualquiera, y además le vamos a indicar que para un método concreto de esa interfaz va a ejecutar con unos parámetros específicos un código que hemos diseñado a tal efecto, es decir, algo como esto:

public async void A_Cool_Test_Returns_Always()
{ … Code
  var notificationAsync = (Notification notification, string tagExpression) =>
    {  
      if (String.IsNullOrEmpty(tagExpression))    
        throw new Exception("test failed!!");  
      return new NotificationOutcome()  
      {
        Success = 0,    
        Failure = 1,    
        NotificationId = "Good morning with id 1",  
      };
   };  
   Scenario
     .WithService<INotificationHubClient>()
     .For(svm => svm.SendNotificationAsync(
                   n, 
                   $"user:{userId}"))    
     .Use(notificationAsync)    
     .Build();
... More code
}

En esta prueba estamos haciendo exactamente eso:

  • (WithService<T>) queremos que el servicio T INotificationHubClient de Azure…
  • (For(…)) para el método SendNotificationAsync con los parámetros dados…
  • (Use()) use la implementación que le pasamos en el delegado que hemos creado.

Para hacer esto vamos guardando toda la información tanto de las firmas como de los delegados, pero también falta la parte más importante, hacer que funcione.

public class ScenarioFixtureBuilder
{
  public ServiceFixtureBuilder<TS> WithService<TS>()
  	where TS: class
  {
  	var service = factory.Services.GetService<TS>();
  	ServiceFixtureBuilder<TS> sf = new ServiceFixtureBuilder<TS>(this);
  	return serviceFixture;
  }

  public ScenarioFixtureBuilder Use(Delegate method)
  {
  	this.Body = method;
  	serviceFixture.scenarioFixture.AddService<T>(this.serviceFixture.Service);
  	return serviceFixture.scenarioFixture;
  }
  
  public MethodFixtureBuilder For(Expression<Action<T>> expression)
  {
  	MethodFixtureBuilder method = new MethodFixtureBuilder(this, expression);
  	this.Service.AddMethod(method);
  	return method;
  }
  
  protected internal void Execute([CallerMemberName] string callerName = "")
  {
  	var del = this.Methods.Where(m => m.MethodName == callerName).First();
  	if (del == null)
  	{
  		return;
  	}
  	var act = del.Signature;
  	if (act.Body is MethodCallExpression lambdaBody)
  	{
  		// Obtener los parámetros de la expresión lambda
  		var parameters = lambdaBody.Arguments.ToArray();
  		var pars = new List<object>(parameters.Length);
  		foreach (var param in parameters)
  		{
  	          pars.Add(GetValueFromExpression(param));
  		}
  		var a = del.Body.DynamicInvoke(pars.ToArray());
  	}
  }
}

Este método Execute() usa el parámetro callerName que contiene el nombre del método invocador (el método de la interfaz) para recuperar la firma del método que hemos indicado en For() y va a usar estos parámetros para invocar al código que hemos pasado al método Use(), esto hará que cuando el servicio que tengamos que lanza notificaciones (siendo originales un INotificationService) acceda al método de INotificationHubClient lance el código del delegado con los parámetros que le hemos indicado y no use el código de la librería de Azure.

¿Cómo se hace esto? La idea es la siguiente, con Emit vamos a coger una clase que tenemos ya creada (siempre la misma para todas las pruebas) y dinámicamente le vamos a añadir una interfaz (la de Azure) esto claramente implica implementar todos sus métodos pero como no vamos a crear código para cada método porque no interesa ni tiene sentido vamos a hacer que todos los métodos llamen a un método común Execute() que como hemos indicado solo acepta un parámetro, el nombre del método que lo va a invocar (sí, el método de la interfaz) -bueno, en realidad necesitamos dos métodos Execute() uno que devuelve valor y otro que no ,pero para simplificar hablaremos solo de uno-.

Ahora viene la parte de Emit, es un tanto complicada y es de muy bajo nivel pero aquí estarían las partes más destacadas.

private DynamicServiceFixture CreateDynamicClass()
{
  // Crear un nuevo ensamblado dinámico
  var nombreEnsamblado = new AssemblyName("DynamicAssembly");
  var ensambladoBuilder = AssemblyBuilder.DefineDynamicAssembly(nombreEnsamblado, AssemblyBuilderAccess.Run);
  
  // Crear un nuevo módulo en el ensamblado
  var moduloBuilder = ensambladoBuilder.DefineDynamicModule("DynamicModule");
  
  // Crear un nuevo tipo dinámico que hereda de la clase e implementa la interfaz
  var tipoBuilder = moduloBuilder.DefineType(
  	"DynamicClassWithInterface",
  	TypeAttributes.Public | TypeAttributes.Class,
  	typeof(DynamicServiceFixture),
  	new Type[] { typeof(T) }
  );
  
  // Información del método ejecutar
  var executeMethod = typeof(DynamicServiceFixture)
    .GetMethod(
      nameof(ExecuteNoReturn), 
      BindingFlags.NonPublic | BindingFlags.Instance);
  
  var methods = typeof(T).GetMethods();
  foreach (var method in methods)
  {
  	MethodBuilder method = null;
  	// Implementar el método de la interfaz
  	method = tipoBuilder.DefineMethod(
  		method.Name,
  		MethodAttributes.Public | MethodAttributes.Virtual,
  		method.ReturnType,
  		method.GetParameters().Select(m => m.ParameterType).ToArray()
  	);
  	var generadorIL = method.GetILGenerator();
        // Cargar la instancia en la pila
  	generadorIL.Emit(OpCodes.Ldarg_0); 
        // Nombre del método invocador
  	generadorIL.Emit(OpCodes.Ldstr, method.Name);
  if (method.ReturnType != typeof(void))
  {   // El método tiene valor de retorno
      // Variable local donde guardar el resultado
      var local = generadorIL.DeclareLocal(method.ReturnType); 
      // Crear el genérico con el mismo tipo de retorno que el llamador
      generadorIL.Emit(
        OpCodes.Call, 
        executeMethod.MakeGenericMethod(method.ReturnType)); 
  }
  generadorIL.Emit(OpCodes.Ret);
  tipoBuilder.DefineMethodOverride(method, method);

  // Crear el tipo y devolver
  dynamic instance = Activator.CreateInstance(tipoBuilder.CreateType());
  return instance;
}

La primera parte es fácil, simplemente crea un tipo dinámico DynamicClassWithInterface, para eso necesitamos crear un ensamblado y un módulo que lo contengan, y vamos a hacer que herede de la clase que hemos comentado que hemos creado a tal efecto DynamicServiceFixture (que sirve para almacenar la información de las firmas y delegados y que usará Execute()) y también vamos a hacer que implemente la interfaz que nos interesa.

Ahora viene la parte interesante, de esta interfaz obtenemos todos los métodos de la misma y en el tipo que hemos definido anterior DynamicClassWithInterface vamos creando uno por uno cada uno de estos métodos, el código aquí implica bastantes más cosas sobre todo relacionadas con genéricos y valores de retorno, pero el código es ilustrativo de lo que se pretende.

Como un método implementado en una clase vía interfaz tiene que tener código -añeja pregunta de entrevista de trabajo- creamos ese código al vuelo, un código muy sencillo, lo único que va a hacer es invocar al método Execute() con el parámetro que hemos comentado que es el nombre del propio método de la interfaz que será el que se utilice para encontrar la información que necesita para ejecutar el delegado como hemos comentado antes.

Con esto lo tendríamos, inyectamos esta interfaz usando el objeto creado de esta forma y cuando se vaya a ejecutar el método SendNotificationAsync() ejecutará realmente el método Execute(«SendNotificacionAsync») y con los parámeros ejecutará el delegado que hemos indicado y devolverá el resultado al código que se está probando.

Este sistema es totalmente compatible con los anteriores ya que se ejecuta de una forma muy local al test y al servicio concreto, otras pruebas no se verán afectadas y podrán usar cualquiera de los métodos anteriores.

Obviamente este sistema vale tanto para métodos de interfaces de terceros como propias aunque requiriendo que la interfaz se puede inyectar a través del host, normalmente es lo habitual y en caso de que no lo sea no es mala idea envolverlo en una interfaz sobre todo sin son librerías de terceros.

Pensamiento lateral en pruebas unitarias: Servicios para unos pocos

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

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