Pruebas Unitarias en .Net Core con xUnit – 7 – Pruebas Basadas en Datos, Theory y InlineData

Pruebas Unitarias en .Net Core con xUnit – 7 – Pruebas Basadas en Datos, Theory y InlineData

Muchas veces, para poder asegurarnos de que nuestra clase funciona correctamente es necesario que probemos con más de un set de datos de entrada. La ejecución del método que vamos a validar puede que tome diferentes caminos o tenga diferentes resultados dependiendo de los parámetros de entrada. Es en estos casos cuando es más importante poder probar todas las posibilidades. Sin embargo, agregar una prueba por cada posibilidad, además de tedioso, provoca mucha duplicidad de código. Esto rompe el principio DRY (Don’t Repeat Yourself) que dice que todo código que hace lo mismo debe de existir una sola vez.

Para eso usamos Data Driven Tests, o Pruebas Basadas en Datos. Esto es tanto una práctica como una funcionalidad de los Unit Test Frameworks, que implementan la repetición de una prueba misma prueba, múltiples veces, en base a un grupo de datos a probar, con los que podemos manipular tanto los datos de entrada como los resultados esperados. Esto nos permite:

  1. Agregar más casos de prueba en un lugar central, sin tener que crear pruebas nuevas.
  2. Centralizar los datos a probar, y que se puedan compartir entre múltiples pruebas.

Para poder realizar estas pruebas con xUnit, hacemos lo siguiente.

El Problema

Vamos a usar un ejemplo anterior, la clase MensajeClientesService. Recordando el funcionamiento de la clase, esta recibe un mensaje a enviar a un cliente, y dependiendo de la configuración del cliente envía el correo o únicamente lo agrega a la base de datos. El código del método AddMensaje es el siguiente:

 
//MensajesClientesService.cs
public class MensajesClientesService
{
    IClienteService clienteService;
    ICorreosService correosService;
    IMensajeRepository mensajesRepository;

    //Primer Constructor
    public MensajesClientesService() 
    {
        clienteService = new ClienteService();
        correosService = new CorreosService();
        mensajesRepository = new MensajeRepository();
    }

    //Segundo Constructor
    public MensajesClientesService(IClienteService clnSrv, ICorreosService corSrv, IMensajeRepository msgRep) 
    {
        clienteService = clnSrv;
        correosService = corSrv;
        mensajesRepository = msgRep;
    }

    public Mensaje AddMensaje(Guid IdCliente, string Titulo, string Contenido) 
    {
        var msgRes = new Mensaje()
        {
            IdCliente = IdCliente,
            Titulo = Titulo,
            Contenido = Contenido,
            IdMensaje = Guid.NewGuid(),
            FechaMensaje = DateTime.Now,
        };

        var cliente = clienteService.GetCliente(IdCliente);
        msgRes.EMailCliente = cliente.EMail;

        if (cliente.EnviarCorreos && !String.IsNullOrWhiteSpace(cliente.EMail))
        {
            try
            {
                correosService.Send(msgRes.EMailCliente, msgRes.Titulo, msgRes.Contenido);
                msgRes.Enviado = true;
            }
            catch 
            {
                msgRes.Enviado = false;
            }
        }
        else
            msgRes.Enviado = false;

        mensajesRepository.Add(msgRes);

        return msgRes;
    }
}

El método en si es muy claro, y agregamos un pequeño cambio para agregar la siguiente lógica de negocio. El correo deberá ser enviado únicamente si el cliente tiene la propiedad EnviarCorreos = true, y si el cliente tiene un correo guardado. Ya que hay 2 variables que afectan la ejecución, tenemos las siguientes 4 posibilidades:

  • Cliente con EnviarCorreos = true, y con EMail – Se debe de enviar correo.
  • Cliente con EnviarCorreos = false, y con EMail – No se debe de enviar correo.
  • Cliente con EnviarCorreos = true, sin Email – No se debe de enviar correo.
  • Cliente con EnviarCorreos = false, sin Email – No se debe de enviar correo.

Y aquí es donde Data Driven Tests nos ayuda a probar estos 4 escenarios, sin tener que crear 4 pruebas independientes.

Theory y Asignación de Datos

Primero que nada, veremos el atributo [Theory]. Este atributo sustituye a [Fact] y le indica a xUnit que el método es una prueba controlada por datos. Con esto, xUnit sabe que deberá repetir la prueba por cada uno de los datos configurados.

Para poder asignar los datos se van a utilizar, es necesario que incluyamos un atributo para indicar de donde vienen los datos, estos pueden ser cualquiera de los siguientes:

  • [InlineData] – Este atriburo se utiliza para poner los datos directamente en la prueba. En el atributo se incluyen explícitamente los datos que se van a probar. En caso de requerir múltiples pruebas se utiliza el mismo atributo múltiples veces. La primera limitante de InlineData es que únicamente proporciona los datos para la prueba en cuestión, no se pueden compartir con otras pruebas. La segunda limitante es que solamente puedes asignar valores, no variables. Es decir, si tienes una variable global que quieres mandar en el InlineData, no se puede, tienes que mandar el valor directamente.
  • [MemberData] – Este atributo nos permite alimentar la prueba con una propiedad o método IEnumerable<object[]>, la cual a su vez contendrá los datos necesarios para la prueba. Esta propiedad o método puede venir de una clase externa, por lo que nos permite utilizar el mismo set de datos para múltiples pruebas.
  • [-CutomDataAttribute-] – Existe una tercera opción llamada CustomData Attribute. Esta nos permite crear una clase, hacer que herede de DataAttribute, que obliga a la implementación del método GetData(). GetData deberá regresar un IEnumerable<object[]> que proveerá los datos de la prueba. Una vez creada simplemente usamos el nombre de nuestra clase como atributo en la función, igual que [InlineData] o [MemberData].

A continuación, veremos cómo usar [InlineData]. En el siguiente post veremos [MemberData] y Custom Data Attribute.

Anotación InlineData

La anotación InlineData sirve para alimentar datos directamente a la prueba. Esta recibe como parámetro un arreglo de objetos, el cual debe de tener el mismo tipo y numero que los parámetros de entrada de la prueba unitaria. Aquí vemos uno de los primeros cambios entre [Fact] y [Theory]. Mientras los métodos en los que usamos Fact, no deben de tener ningún parámetro de entrada, en Theory debemos de incluir los parámetros que van a cambiar. Estos parámetros pueden usarse, tanto como para alimentar la prueba, como para alimentar resultados esperados. Veamos el ejemplo:

 
//MensajesClientes_Should_Inline.cs
public class MensajesClientesService_Should_Inline
{
    List<Cliente> clientes;
    ClienteServiceMock clnSrvMock;
    CorreosServiceMock corSrvMock;
    MensajeRepositoryMock msgRepo;

    public MensajesClientesService_Should_Inline() 
    {
        clientes = new List<Cliente>() 
        {
            new Cliente()
            {
                IdCliente = Guid.NewGuid(),
                EMail = "juan.rulfo@juanrulfo.com",
                EnviarCorreos = true,
            },
            new Cliente()
            {
                IdCliente = Guid.NewGuid(),
                EMail = "pedro.paramo@juanrulfo.com",
                EnviarCorreos = false,
            },
            new Cliente()
            {
                IdCliente = Guid.NewGuid(),
                EnviarCorreos = true,
            },
            new Cliente()
            {
                IdCliente = Guid.NewGuid(),
                EnviarCorreos = false,
            },
        };

        clnSrvMock = new ClienteServiceMock(clientes);
        corSrvMock = new CorreosServiceMock(true);
        msgRepo = new MensajeRepositoryMock();
    }


    [Theory]
    [InlineData(0, true)]
    [InlineData(1, false)]
    [InlineData(2, false)]
    [InlineData(3, false)]
    public void AgregarMensaje_HappyPath(int arrayId, bool seEnvioCorreo)
    {
        //Arrange
        var idCliente = clientes[arrayId].IdCliente;
        var titulo = "Bienvenido a nuestro Servicio.";
        var contenido = "Hola. Muchas gracias por usar nuestro servicio.";

        var sut = new MensajesClientesService(clnSrvMock, corSrvMock, msgRepo);

        //Act
        var respuesta = sut.AddMensaje(idCliente, titulo, contenido);

        //Assert
        Assert.Equal(titulo, respuesta.Titulo);
        Assert.Equal(contenido, respuesta.Contenido);
        Assert.Equal(idCliente, respuesta.IdCliente);
        Assert.Equal(seEnvioCorreo, respuesta.Enviado);
    }
}

Veamos primero constructor de la prueba:

  • En el constructor creamos el listado de Clientes, uno para cada uno de los 4 escenarios posibles.
  • Posteriormente se inicializan las dependencias que se van a inyectar. Se alimenta el listado de clientes a ClientesServiceMock que es con el que va a trabajar. ClientesServiceMock recibe esta lista para simular la interacción con el servicio.

Y ahora sí, para entender la prueba:

  • Primero veamos que el método tiene 2 parámetros de entrada.
    • El primero, arrayId, es un entero, y este lo usamos para saber que objeto del listado vamos a probar. Utilizamos los números ordinales del arreglo directamente ya que no podemos mandar simplemente el Id de cada cliente, al menos que lo pusieras de manera manual, lo cual es tedioso y repite código.
    • El segundo seEnvioCorreo, este es el valor que esperamos de respuesta para cada uno de los casos. Este valor lo utilizaremos en el último Assert.
  • Agregamos el atributo [Theory] al método, informando que va a ser una Data Driven Test.
  • Agregamos todos los datos en los atributos de [InlineData]. Recuerda que deben tener el mismo número de objetos, que el número de parámetros de entrada, y que estos solo pueden ser valores, no variables.
  • Arrange
    • En lo primero hacemos es sacar el Guid del cliente en base al arrayId. Con esto podemos mandar el Id correcto al método que se está probando.
    • Inicializamos MensajesClientesService e inyectamos los Mocks de las dependencias.
  • Act
    • Ejecutamos la función.
  • Assert
    • Aquí, primero nos aseguramos de que el objeto tenga los valores correctos, lo cual no cambia en base a los datos de entrada.
    • El ultimo Assert es el importante. Veamos como este comparamos el valor de seEnvioCorreo a respuesta.Enviado. Ya que este valor sabemos que va a cambiar dependiendo de la lógica de negocios, alimentamos un valor diferente para asegurarnos que funciona bien cada uno de los casos.

Igual que cuando ejecutamos múltiples pruebas unitarias dentro de una misma clase, el orden en las que se van a ejecutar las pruebas no está asegurado. Por lo que nunca debes hacer una prueba que dependa de la ejecución previa de la misma prueba, o de a la ejecución de cualquier otra prueba. La prueba debe ser unitaria.

Cuando ejecutamos las pruebas. Podemos ver que el Test Explorer nos muestra en el listado, una instancia por cada elemento de información. Es decir, nos lo muestra como 4 pruebas diferentes. Esto nos ayuda a encontrar los casos específicos en los que la prueba no funciono. El orden en el que aparecen en el listado del Test Explorer si corresponde al orden de las líneas [InlineData].

En general es fácil ver las limitantes que [InlineData] tiene. Los parametros solo pueden ser de valor, no variables. Y solo se pueden usar los datos en la misma. Sin embargo, es muy útil cuando tenemos que probar funciones sin dependencias, que tienen únicamente parámetros de entrada y parámetros de salida, funciones de uso más general como Helpers.

En el siguiente post veremos cómo usar [MemberData] y Custom Data Attribute, que son atributos que utilizarás más por la funcionalidad y flexibilidad que te dan. Y como verás, tampoco son muy complicadas de implementar.

Resumen

Cuando tenemos funciones, cuyo resultado puede cambiar dependiendo de sus parámetros de entrada, podemos utilizar la funcionalidad de Pruebas Basadas en Datos, o Data Driven Tests. Las Pruebas Basadas en Datos nos permiten ejecutar la misma prueba múltiples veces, cada vez con un grupo de datos diferente. Estos datos los recibe nuestra función de prueba como parámetros de entrada, o parámetros de validación, permitiéndonos probar múltiples escenarios con una misma prueba, evitando así repetición de código innecesario. Por lo pronto recuerda:

  • xUnit implementa Pruebas Basadas en Datos utilizando el atributo [Theory], en vez de [Fact], este le indica que la prueba se ejecutará muchas veces, una vez por cada uno de los datos proporcionados.
  • Cuando usamos [Theory], la función de las pruebas debe de recibir como parámetros los datos que cambiarán en cada ejecución.
  • Cuando usamos [Theory] en xUnit, podemos alimentar los datos con los atributos [InlineData]. [MemberData], o creando una clase que herede de DataAttribute e implemente GetData().
  • El atributo [InlineData] nos permite implementar diferentes datos de entrada como atributos directos de la función.
  • [InlineData] recibe el mismo número de parámetros que la función a probar, a manera de un arreglo de objetos. Estos objetos tienen que ser de valor, no pueden ser variables.

Repositorio del proyecto:

https://github.com/dansuarmar/unitTestXunitBasics/tree/main/P7%20-%20Data%20Driven%20Tests

This Post Has One Comment

Deja un comentario

Close Menu