Migrando de MSTest a xUnit. Test parametrizados

¡Hola!
Llegamos al último post de la serie Migrando de MSTest a xUnit. En el primero vimos los cambios más básicos y cómo cambia el ciclo de vida de los test, en el segundo los principales atributos y esta vez toca ver los test parametrizados. Yo, personalmente, nunca había utilizado este tipo de test hasta que hace unos días, haciendo la kata Roman Numerals Kata los utilicé por primera vez.

Este tipo de test son muy útiles cuando nos encontramos situaciones en las que estamos duplicando el mismo test simplemente porque queremos probar diferentes valores. Vamos a imaginarnos que estamos haciendo test para comprobar una función que nos indique si un número es primo o no (el código de la función lo he sacado de aquí). Si no utilizásemos test parametrizados, nos encontraríamos situaciones como la siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    [TestClass]
    public class PrimeFunctionShould
    {
        [TestMethod]
        public void BeTrueFor_Two()
        {
            Assert.IsTrue(IsPrime(2));
        }
 
        [TestMethod]
        public void BeTrueFor_Three()
        {
            Assert.IsTrue(IsPrime(3));
        }
 
        [TestMethod]
        public void BeTrueFor_Five()
        {
            Assert.IsTrue(IsPrime(5));
        }
 
        [TestMethod]
        public void BeFalseFor_One()
        {
            Assert.IsFalse(IsPrime(1));
        }
 
        [TestMethod]
        public void BeFalseFor_Four()
        {
            Assert.IsFalse(IsPrime(4));
        }
    }

¿Qué otras opciones tendríamos? Bueno, podríamos hacer varios Asserts en un único test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    [TestClass]
    public class PrimeFunctionShould
    {
        [TestMethod]
        public void BeTrueFor_Two_Three_Five()
        {
            Assert.IsTrue(IsPrime(2));
            Assert.IsTrue(IsPrime(3));
            Assert.IsTrue(IsPrime(5));
        }
 
        [TestMethod]
        public void BeFalseFor_One_Four()
        {
            Assert.IsFalse(IsPrime(1));
            Assert.IsFalse(IsPrime(4));
        }
    }

Esto tiene un problema claro; en cuanto falle un Assert el test va a fallar, por lo que tendremos resultados que sí sean ciertos dentro del test y hayan sido ocultados porque un Assert ha fallado. Es más, realmente que cuanto un Assert falle se va a lanzar una excepción y los Asserts que haya justo debajo no se van a llegar a ejecutar.
¿Qué solución nos ofrece MSTets para hacer test parametrizados? Pues siendo realistas ninguna. O más bien ninguna como las típicas que ofrecen los diferentes frameworks de test. La opción que tenemos es usar el atributo [DataSource] como vimos en la anterior entrada. Pero a mí, personalmente, no me acaba de convencer esta opción. Dejando de lado todos los preámbulos que necesitamos para que se crear el test, para este tipo de casos no me agrada tener los datos en un archivo o base de datos. Seguramente esta opinión sea errónea por mi falta de experiencia en general en esto del Data-Driven Test y sea algo totalmente razonable. La opción que se comenta aquí utilizando MSTestHacks me suena mucho mejor.
En este punto MSTest V2 llega al rescate. A partir de esta versión ya se han incluido los test parametrizados gracias al atributo [DataRow]. Tiene el siguiente aspecto:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    [TestClass]
    public class PrimeFunctionShould
    {
        [DataTestMethod]
        [DataRow(2)]
        [DataRow(3)]
        [DataRow(5)]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.IsTrue(IsPrime(candidate));
        }
 
        [DataTestMethod]
        [DataRow(1)]
        [DataRow(4)]
        public void BeFalseFor_One_Four(int candidate)
        {
            Assert.IsFalse(IsPrime(candidate));
        }
    }

Como vemos es muy sencillo. Decoramos el test con el atributo [DataRow] junto con el valor (o valores) que necesitemos y añadimos el/los parámetro/s al test. Para dejarlo un poco más claro vamos a ver cómo sería con más de un parámetro:

1
2
3
4
5
6
7
    [DataTestMethod]
    [DataRow(5, 10, 15)]
    [DataRow(2, 1, 3)]
    public void SumTwoValuesTest(int x, int y, int result)
    {
        Assert.AreEqual(result, x + y);
    }

Si has estado atento, te habrás dado cuenta de que no estamos utilizando el atributo [TestMethod], en su lugar hemos decorado el test con [DataTestMethod]. Realmente con [TestMethod] también se ejecutaría correctamente el test, pero se recomienda utilizar este nuevo atributo para los test parametrizados.
Otro atributo con el que nos encontramos en MSTest V2 para hacer test parametrizados es [DynamicData] . Este atributo no está en las primeras versiones de MSTest V2, pero (creo) a partir de la versión 1.2.0 ya está disponible.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    [TestClass]
    public class PrimeFunctionShould
    {
        public static IEnumerable<object[]> GetPrimeNumbers()
        {
            yield return new object[] { 2 };
            yield return new object[] { 3 };
            yield return new object[] { 5 };
        }
 
        [DataTestMethod]
        [DynamicData(nameof(GetPrimeNumbers), DynamicDataSourceType.Method)]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.IsTrue(IsPrime(candidate));
        }
    }

Como vemos, definimos un método GetPrimerNumbers que devolverá un IEnumerable<object[]> donde indicaremos nuestros datos. También tenemos la opción de utilizar una propiedad en vez de un método:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    [TestClass]
    public class PrimeFunctionShould
    {
        public static IEnumerable<object[]> PrimeNumbers
        {
            get
            {
                yield return new object[] { 2 };
                yield return new object[] { 3 };
                yield return new object[] { 5 };
            }
        }
 
        [DataTestMethod]
        [DynamicData(nameof(PrimeNumbers), DynamicDataSourceType.Property)]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.IsTrue(IsPrime(candidate));
        }
    }

Lo único que cambia es que hemos utilizado DynamicDataSourceType.Property en vez de DynamicDataSourceType.Method y, obviamente, ahora nuestros datos los definimos en una propiedad y no como un método.
Seguramente nos interese mover estos datos a una clase fuera del test y para eso DynamicData nos permite definir esto utilizando una sobrecarga diferente en la que especificamos el tipo de esa clase contenedor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
    public class PrimeData
    {
        public static IEnumerable<object[]> GetPrimeNumbers()
        {
            yield return new object[] { 2 };
            yield return new object[] { 3 };
            yield return new object[] { 5 };
        }
 
        public static IEnumerable<object[]> PrimeNumbers
        {
            get
            {
                yield return new object[] { 2 }; ;
                yield return new object[] { 3 }; ;
                yield return new object[] { 5 }; ;
            }
        }
    }
 
    [TestClass]
    public class PrimeFunctionShould
    {      
        [DataTestMethod]
        [DynamicData(nameof(PrimeData.PrimeNumbers),typeof(PrimeData), DynamicDataSourceType.Property)]
        public void BeTrueFor_Two_Three_Five_WithProperty(int candidate)
        {
            Assert.IsTrue(IsPrime(candidate));
        }
 
        [DataTestMethod]
        [DynamicData(nameof(PrimeData.GetPrimeNumbers), typeof(PrimeData), DynamicDataSourceType.Method)]
        public void BeTrueFor_Two_Three_Five_WithMethod(int candidate)
        {
            Assert.IsTrue(IsPrime(candidate));
        }
    }

DynamicData lo vamos a utilizar principalmente cuando queremos reutilizar un conjunto de datos, o cuando queremos crear datos no constantes u objetos complejos. Realmente sí que podemos crear datos no constantes con DataRow, pero se pierde bastante claridad.
Por último, MSTest V2 nos proporciona una última alternativa que es crear nuestro propio atributo que implemente ITestDataSource. Vamos directos al ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    public class PrimeDataSourceAttribute:Attribute, ITestDataSource
    {
        public IEnumerable<object[]> GetData(MethodInfo methodInfo)
        {
            yield return new object[] { 2 };
            yield return new object[] { 3 };
            yield return new object[] { 5 };
        }
 
        public string GetDisplayName(MethodInfo methodInfo, object[] data)
        {
            if (data == null)
                return null;
 
            return string.Concat(methodInfo.Name," - ",  string.Join(",", data));
        }
    }
 
    [TestClass]
    public class PrimeFunctionShould
    {      
        [DataTestMethod]
        [PrimeDataSource]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.IsTrue(IsPrime(candidate));
        }
    }

Al implementar ITestDataSource tenemos que añadir dos métodos, GetData y GetDisplayName, donde en GetData proporcionamos los datos que se pasarán como parámetros al test y GetDisplayName será el nombre de test para cada fila de datos.
Visto lo que se puede hacer con MSTest V2 (en MSTest estamos muy limitados en este aspecto) vamos a revisar xUnit. En primer lugar hay un cambio con respecto a un test normal, debemos utilizar el atributo [Theory] en vez de [Fact]. El test parametrizado más básico es [InlineData], que viene a ser equivalente a [DataRow] en MSTest V2.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    public class PrimeFunctionShould
    {
        [Theory]
        [InlineData(2)]
        [InlineData(3)]
        [InlineData(5)]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.True(IsPrime(candidate));
        }
 
        [Theory]
        [InlineData(1)]
        [InlineData(4)]
        public void BeFalseFor_One_Four(int candidate)
        {
            Assert.False(IsPrime(candidate));
        }
    }

Nada reseñable con respecto a MSTest V2. Utilizamos el atributo [InlineData] y le pasamos los datos que queramos, que serán recibidos en los parámetros del test. Vemos un ejemplo de cómo pasar varios parámetros:

1
2
3
4
5
6
7
    [Theory]
    [InlineData(5, 10, 15)]
    [InlineData(2, 1, 3)]
    public void SumTwoValuesTest(int x, int y, int result)
    {
        Assert.Equal(result, x + y);
    }

Siguiendo con [DynamicData], su equivalente en xUnit sería [ClassData] y [MemberData]. [ClassData] se utiliza para definir una clase que contenga datos; lo único que tenemos que hacer es que nuestra clase de datos implemente IEnumerable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    public class PrimeData : IEnumerable<object[]>
    {
        public IEnumerator<object[]> GetEnumerator()
        {
            yield return new object[] { 2 };
            yield return new object[] { 3 };
            yield return new object[] { 5 };
        }
 
        IEnumerator IEnumerable.GetEnumerator()
        {
            return GetEnumerator();
        }
    }
 
    public class PrimeFunctionShould
    {
        [Theory]
        [ClassData(typeof(PrimeData))]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.True(IsPrime(candidate));
        }
    }

Podemos apreciar que es muy similar a ITestDataSource de MSTest V2 que utilizábamos para definir la clase con nuestros datos de prueba. Seguimos viendo [MemberData], que nos ofrece un poco más de flexibilidad que [ClassData]. Vendría ser el equivalente a [DynamicData] y nos ofrece la posibilidad de que el origen de datos venga desde una propiedad estática, un campo estático o un método estático (que podría tener parámetros):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    public class PrimeFunctionShould
    {
        public static IEnumerable<object[]> PrimeNumbers
        {
            get
            {
                yield return new object[] { 2 }; ;
                yield return new object[] { 3 }; ;
                yield return new object[] { 5 }; ;
            }
        }
 
        [Theory]
        [MemberData(nameof(PrimeNumbers))]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.True(IsPrime(candidate));
        }
    }

Esta es su versión más básica (y muy similar a MSTest V2) donde nuestro origen de datos proviene de una propiedad estática. [MemberData] también nos da la opción, como decimos, de que pueda ser un método estático y éste contenga parámetros:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    public class PrimeFunctionShould
    {
        public static IEnumerable<object[]> GetPrimeNumbers(int numberOfValues)
        {
            var primeNumbers = new List<object[]> { new object[] { 2 }, new object[] { 3 }, new object[] { 5 } };
            return primeNumbers.Take(numberOfValues);
        }
 
        [Theory]
        [MemberData(nameof(GetPrimeNumbers),parameters:2)]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.True(IsPrime(candidate));
        }
    }

Seguramente no sea el mejor ejemplo para verle la utilidad… Lo que estamos haciendo es indicar que pase como parámetro el número 2 a nuestra función GetPrimeNumbers, que se encarga de devolver el número de números primos que indique ese parámetro, en resumen, que sólo se ejecutará el test con el valor 2 y 3 (el 5 no se ejecutará). Por último, es posible que queramos una funcionalidad similar a [MemberData], pero tener nuestros datos en una clase diferente que nuestro test. Para ello [MemberData] nos proporciona una propiedad para indicar el tipo del que queremos obtener nuestros datos, que es MemberType:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    public class PrimeData
    {
        public static IEnumerable<object[]> GetPrimeNumbers(int numberOfValues)
        {
            var primeNumbers = new List<object[]> { new object[] { 2 }, new object[] { 3 }, new object[] { 5 } };
            return primeNumbers.Take(numberOfValues);
        }
    }
 
    public class PrimeFunctionShould
    {
        [Theory]
        [MemberData(nameof(PrimeData.GetPrimeNumbers), parameters: 2, MemberType = typeof(PrimeData))]
        public void BeTrueFor_Two_Three_Five(int candidate)
        {
            Assert.True(IsPrime(candidate));
        }
    }

Manteniendo la funcionalidad que vimos en el ejemplo anterior, hemos sacado los datos a una clase diferente.
Una última opción que no he comentado es [DataAttribute]. Esta opción sería similar a lo que teníamos en MSTest y que comenté al principio del post, que es para sacar los datos a una base de datos, archivo xml, etc., y la expliqué en el post anterior.

Conclusiones

Después de este repaso por los test parametrizados, creo que podemos decir, dejando de lado MSTest, que MSTest V2 no tiene demasiado que envidiar a xUnit, o eso creo 🙂 Ha quedado al menos una opción por ver en xUnit, que es TheoryData que utiliza genéricos y nos ayuda a dar un tipo concreto a nuestros datos y no tener que utilizar IEnumerable<object[]> (os dejo un par de pistas aquí y aquí).

¡Un saludo!

Otros post:
Migrando de MSTest a xUnit
Migrando de MSTest a xUnit. Atributos
Migrando de MSTest a xUnit. Test parametrizados

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *