Migrando de MSTest a xUnit. Atributos

¡Hola!
Después de ver algunas cosas básicas y cómo cambia el ciclo de vida de los test, hoy vamos a echar un vistazo a los atributos básicos y cómo cambian de MSTest a xUnit. Antes de que se me olvide, Francisco (un crack del testing) me comentó que por lo visto ha salido ya hace unos meses una nueva versión de MSTest, MSTest V2 y yo sin saberlo, así que es posible que algunas cosas que haya comentado o vaya a comentar hayan cambiado con esta nueva versión :(, aunque es una buena noticia que MSTest siga evolucionando.

Atributos básicos

En la anterior entrada vimos el cambio más básico en cuanto a atributos. Pasamos de [TestClass] y [TestMethod] para indicar una clase de test y un test en MSTest, a que no sea necesario decorar la clase con ningún atributo y [Fact] para decorar un test en xUnit.

Cuando en MSTest queremos ignorar un test, decoramos el método con el atributo [Ignore] pudiendo añadir o no, un mensaje que especifique el motivo de que sea ignorado:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    [TestClass]
    public class MSTestShould
    {
        [TestMethod]
        [Ignore]        public void IgnoreTestWithoutMessage()
        {            
        }
 
        [TestMethod]
        [Ignore("Este test se ignora porque nos apetece")]        public void IgnoreTestWithMessage()
        {            
        }
    }

Para reproducir esto en xUnit nos vamos a ahorrar un atributo nuevo, simplemente tenemos que añadir texto a la propiedad Skip del atributo Fact:

1
2
3
4
5
6
7
8
9
10
11
12
    public class XUnitShould
    {
        [Fact(Skip = " ")]        public void IgnoreTestWithoutMessage()
        {
        }
 
        [Fact(Skip = "Este test se ignora porque nos apetece")]        public void IgnoreTestWithMessage()
        {
        }
    }

Si nos fijamos bien, en el primero de los casos he añadido un espacio en blanco a la propiedad Skip debido a que no he encontrado la forma (por lo menos directa) de no especificar ningún texto. Si hubiese añadido «» (cadena vacía), el test se seguiría ejecutando.

Ahora vamos a revisar las categorías de los test. Aquí ya se vuelven a notar diferencias, y vamos intuyendo poco a poco como MSTest aporta herramientas de forma más directa y xUnit apuesta por la flexibilidad (crea tú las herramientas si las necesitas). En MSTest existe un atributo llamado [TestCategory] que recibe un parámetro (string) donde se especifica la categoría, y nos sirve para poder definir diferentes categorías de test, muy útil para ver con un simple vistazo metadatos de los test y poder excluir determinadas categorías cuando creamos sesiones de test (por ejemplo, si cada vez que subimos cambios se ejecutan los test en el servidor, es posible que nos interese que algunos de ellos se excluyan). De forma muy simple son así:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    [TestClass]
    public class MSTestShould
    {
        [TestMethod]
        [TestCategory("Integration")]        public void IntegrationTest()
        {
            Thread.Sleep(5000);
        }
 
        [TestMethod]
        [TestCategory("Critic")]        public void CriticTest()
        {
        }
    }

Si echamos un vistazo a la ventana del explorador de pruebas y agrupamos por Rasgo, vemos el resultado:

Vemos que es un poco feo, estamos harcodeando las categorías, podemos refinarlo de varias formas, añadiendo constantes por ejemplo, pero una forma muy chula es la que explican aquí, lo que hace es crear un nuevo atributo a partir de TestCategory llamado TestTraits; en el constructor de TestTraits le pasas los traits que quieras (a partir de un enum), y, como TestCategory te permite sobreescribir la lista de categorías que se le aplican a la prueba (TestCategories), devuelve los strings correspondientes a los Traits especificados en el constructor. El código anterior pasaría a:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    [TestClass]
    public class MSTestShould
    {
        [TestMethod]
        [TestTraits(Trait.Integration)]        public void IntegrationTest()
        {
            Thread.Sleep(5000);
        }
 
        [TestMethod]
        [TestTraits(Trait.Critic)]        public void CriticTest()
        {
        }
    }

Donde Trait, como decimos, es una enumeración:

1
2
3
4
5
    public enum Trait
    {
        Integration,
        Critic,
    }

¿Cómo aplicar categorías en xUnit? Pues realmente no hay un atributo o una forma de hacerlo como tal, pero podemos hacer uso del atributo [Trait] (el equivalente a [TestProperty] de MSTest), que se utiliza para especificar un rasgo de un test dando un par nombre/valor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    public class XUnitShould
    {
        [Fact]
        [Trait("Category","Integration")]        public void IntegrationTest()
        {
            Thread.Sleep(5000);
        }
 
        [Fact]
        [Trait("Category", "Critic")]        public void CriticTest()
        {
        }
    }

Si echamos un vistazo al explorador de pruebas, vemos el resultado:

Vamos a darle una vuelta más. Tenemos «Category» y «Integration/Critic» harcodeado. Vamos a intentar aprovechar esa flexibilidad que nos proporciona xUnit. Lo primero que vamos a hacer es crear el enum con nuestros Traits, un atributo nuevo Category y un Discoverer.

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
    public class CategoryDiscoverer : ITraitDiscoverer
    {
        public IEnumerable<KeyValuePair<string, string>> GetTraits(IAttributeInfo traitAttribute)
        {
            var name = traitAttribute.GetNamedArgument<string>("Name");
            yield return new KeyValuePair<string, string>("Category", name);
        }
    }
 
    [TraitDiscoverer("XUnitTest.CategoryDiscoverer", "XUnitTest")]
    [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
    public class CategoryAttribute : Attribute, ITraitAttribute
    {
        public CategoryAttribute(Trait trait)
        {
            this.Name = Enum.GetName(typeof(Trait), trait); 
        }
        public string Name { get; }
     }
 
    public enum Trait
    {
        Integration,
        Critic,
    }

Antes que nada ojo, en la línea 10 está puesto a mano el nombre del tipo y ensamblado, se debería hacer de otra forma. Dicho esto, el atributo CategoryAttribute recibe en el constructor un Trait (podría ser un número variable de Traits con params, pero no lo quería complicar demasiado) y establece en su propiedad Name, el string asociado al Trait recibido. Si nos fijamos bien, CategoryAttribute implementa la interfaz marcadora ITraitAttribute (aprovecho para recomendaros este post), y lo decoramos con el atributo TraitDiscoverer especificando que como descubridor del rasgo utilice CategoryDiscoverer. Este último elemento CategoryDiscoverer implementa ITraitDiscoverer y va a recibir la información relacionada con CategoryAttribute, de ella extraemos el valor de la propiedad Name (si recordamos, relacionada con el enum Trait), y va a devolver el par «Category» «Trait», que será lo que veremos en nuestro explorador de test. Ahora, nuestra clase de test quedaría mucho mejor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    public class XUnitShould
    {
        [Fact]
        [Category(Trait.Integration)]
        public void IntegrationTest()
        {
            Thread.Sleep(5000);
        }
 
        [Fact]
        [Category(Trait.Critic)]
        public void CriticTest()
        {
        }
    }

Podemos hacer una implementación basada exclusivamente en atributos (como se hace aquí), donde definimos un atributo por cada categoría de test:

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
38
39
40
41
42
43
44
45
    public class CategoryDiscoverer : ITraitDiscoverer
    {
        public IEnumerable<KeyValuePair<string, string>> GetTraits(IAttributeInfo traitAttribute)
        {
            var name = traitAttribute.GetNamedArgument<string>("Name");
            var info = traitAttribute.GetNamedArgument<string>("Info");
            yield return new KeyValuePair<string, string>("Category", name);
            if(!string.IsNullOrWhiteSpace(info))
                yield return new KeyValuePair<string, string>(name, info);
        }
    }
 
    [TraitDiscoverer("XUnitTest.CategoryDiscoverer", "XUnitTest")]
    [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
    public class CategoryAttribute : Attribute, ITraitAttribute
    {
        public CategoryAttribute(string category)
        {
            this.Name = category;
        }
        public CategoryAttribute(string category, string info):this(category)
        {
            this.Name = category;
            this.Info = info;
        }
        public string Name { get; }
        public string Info { get; }
    }
 
    public class IntegrationAttribute:CategoryAttribute
    {
        public IntegrationAttribute() : base("Integration")
        {
        }
        public IntegrationAttribute(string info) : base("Integration", info)
        {
        }
    }
 
    public class CriticAttribute : CategoryAttribute
    {
        public CriticAttribute() : base("Critic")
        {
        }
    }

Ahora nuestros test quedarían:

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 XUnitShould
    {
        [Fact]
        [Integration("blablabla test")]        public void IntegrationTest()
        {
            Thread.Sleep(5000);
        }
 
 
        [Fact]
        [Integration("bribriblibli test")]        public void OtherIntegrationTest()
        {
            Thread.Sleep(5000);
        }
 
        [Fact]
        [Critic]        public void CriticTest()
        {
            Thread.Sleep(5000);
        }
    }

Como vemos, se ha añadido la posibilidad de incluir más información en la categoría Integration. Echamos un vistazo a nuestro explorador de pruebas:

Por cierto, para ir viendo los cambios en la ventana del explorador de pruebas, podemos limpiar la solución y compilar el proyecto que nos interese. Si por algún motivo no os reconoce los test, puede ser que tengáis que limpiar algunos archivos temporales (https://xunit.github.io/docs/getting-started-desktop.html ver sección Running tests with Visual Studio) o no habéis definido algo bien por ejemplo en el ITraitDiscoverer.

Vamos con otro atributo disponible en MSTest [ExpectedException]. Este atributo se utiliza para indicar que un test va a generar algún tipo de excepción, capturar esa excepción y, en base al tipo de la excepción generada, hace que el test pase o no:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    [TestClass]
    public class MSTestShould
    {
        [TestMethod]
        [ExpectedException(typeof(DivideByZeroException))]        public void ThrowDivideByZeroException_When_DivideByZero()
        {
            int zero = 0;
            int result = 3 / zero;
        }
 
        [TestMethod]
        [ExpectedException(typeof(Exception), AllowDerivedTypes = true)]        public void ThrowException_When_DivideByZero()
        {
            int zero = 0;
            int result=3 / zero;
        }
    }

Aquí sí ha habido una mejora sustancial en MSTest V2. Realmente decorar con un atributo un Assert es raro. Si a esto le añadimos que tenemos más de un atributo decorando el método, perdemos totalmente la legibilidad del test y aquello del Arrange/Act/Assert también queda perdido.. Siempre quedaba la opción de añadir un try catch feo en el cuerpo del test, o crear un Assert propio para intentar suplir la carencia. Con MSTest V2 ya hay Assert propio:

1
2
3
4
5
6
7
8
9
10
    [TestClass]
    public class MSTestShould
    {
        [TestMethod]
        public void ThrowDivideByZeroException_When_DivideByZero()
        {
            int zero = 0;
            Assert.ThrowsException<DivideByZeroException>(() => 3 / zero);        }
    }

Lo único que se echa de menos es que no hay nada como AllowDerivedTypes para que el test anterior pase, por ejemplo, con un Exception (en vez de DivideByZeroException), pero por otra parte, además de que es muy posible que no sea una buena idea hacerlo, es preferible perder eso y ganar el Assert.
En xUnit igual que en MSTest V2 hay Assert para hacerlo. Realmente hay más de uno, los vemos a continuación:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    public class XUnitShould
    {
        [Fact]
        public void ThrowDivideByZeroException_When_DivideByZero()
        {
            int zero = 0;
            Assert.Throws<DivideByZeroException>(() => 3 / zero);        }
 
        [Fact]
        public void ThrowException_When_DivideByZero()
        {
            int zero = 0;
            Assert.ThrowsAny<Exception>(() => 3 / zero);        }
    }

Aquí sí tenemos la opción de comprobar que una excepción es del mismo tipo o un tipo derivado con ThrowsAny. También hey versión no genérica del Assert.Throws:

1
2
3
4
5
6
7
8
9
    public class XUnitShould
    {
        [Fact]
        public void ThrowDivideByZeroException_When_DivideByZero()
        {
            int zero = 0;            
            Assert.Throws(typeof(DivideByZeroException), () => 3 / zero);        }
    }

El siguiente atributo lo utilizamos para determinar el tiempo máximo que debe de durar un test antes de que se ponga en rojo, en MSTest es el atributo [Timeout]. Recibe un único parámetro donde se le indica el tiempo máximo en milisegundos, antes de que el test falle.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    [TestClass]
    public class MSTestShould
    {
        [TestMethod]
        [Timeout(500)]        public void Sleeplessness()
        {
            Thread.Sleep(400);
            Assert.IsTrue(true);
        }
 
        [TestMethod]
        [Timeout(500)]        public void Sleepyhead()
        {
            Thread.Sleep(501);
            Assert.IsTrue(true);
        }
    }

Si ejecutamos los test anteriores, el segundo obviamente va a fallar, ya que antes de finalizar el test se ha excedido el tiempo máximo de 500 ms, ojo, que es antes de finalice el test, no de que se haga el Assert.
Aquí xUnit (por lo menos en la versión que estoy utilizando) no soporta tiempos de espera. Esto es por lo visto debido a la paralelización (https://github.com/xunit/xunit/issues/217). Punto extra para MSTest 🙂

Por último vamos a ver el atributo DataSource disponible en MSTest. Este atributo se utiliza para Data-Driven Test. No había utilizado nunca este tipo de test, así que es posible que diga alguna barbaridad. Según he podido ver, uno de sus usos es para hacer test parametrizados, es decir, le damos desde algún origen de datos (base de datos, archivo xml, etc.) una entrada y un resultado y verificamos que nuestro código cumple esas expectativas. Esto, en MSTest se haría de la siguiente forma (para una base de datos SQL Server):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    [TestClass]
    public class MSTestShould
    {
        public TestContext TestContext { get; set; } 
        [TestMethod]
        [DataSource("System.Data.SqlClient",@"Data Source=LUIS_PORTATIL\SQLEXPRESS;Initial Catalog=Maths;Integrated Security=True;","Sum",DataAccessMethod.Sequential)]        public void Add_Integers()
        {
            int firstNumber = (int)TestContext.DataRow["a"];
            int secondNumber = (int)TestContext.DataRow["b"];
            int expectedResult = (int)TestContext.DataRow["result"];
 
            int actualResult = firstNumber + secondNumber;
 
            Assert.AreEqual(expectedResult, actualResult);
        }
    }

El ejemplo anterior realmente no es válido, ya que no estamos comprobando nada de nuestro código, más bien justo lo contrario, parece que estamos comprobando que los datos de la base de datos son correctos, y creo que este no es el objetivo de este tipo de test. En cualquier caso, estamos especificando con el atributo DataSource nuestra cadena de conexión, la tabla a la que hacemos referencia y que nos devuelva las filas de la tabla de forma secuencial; para acceder a esos datos lo hacemos a través de la propiedad TestContext que hemos puesto para ese fin y que será donde se vuelque la información de nuestro origen de datos. No obstante parece que esto ha cambiado (a mejor) en MSTest V2.
En el caso de xUnit la cosa es un poco diferente, en vez de decorar el método con [Fact], utilizaremos [Theory]. El atributo [Theory] se utiliza para indicar que un test (o varios) son correctos para un conjunto particular de datos. En nuestro caso vamos a intentar simular el atributo DataSource de MSTest a través del atributo [DataAttribute], que se utiliza como origen de datos y contiene un método GetData() que podemos sobreescribir. El código que se muestra a continuación es una adaptación cutre de este.

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
38
39
40
    [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
    public class SqlServerDataAttribute : DataAttribute
    {
        const string sqlWithTrust = "Provider=SQLOLEDB; Data Source={0}; Initial Catalog={1}; Integrated Security=SSPI;";
        private readonly IDataAdapter dataAdapter;
 
        public SqlServerDataAttribute(string serverName,
            string databaseName,
            string selectStatement)
        {
            this.dataAdapter=new OleDbDataAdapter(selectStatement, string.Format(CultureInfo.InvariantCulture, sqlWithTrust, serverName, databaseName)); 
        }
 
        public override IEnumerable<object[]> GetData(MethodInfo methodUnderTest)
        {
            DataSet dataSet = new DataSet();
 
            try
            {
                this.dataAdapter.Fill(dataSet);
 
                foreach (DataRow row in dataSet.Tables[0].Rows)
                    yield return ConvertParameters(row.ItemArray);
            }
            finally
            {
                if (this.dataAdapter is IDisposable disposable)
                    disposable.Dispose();
            }
        }
        private object[] ConvertParameters(object[] values)
        {
            object[] result = new object[values.Length];
 
            for (int idx = 0; idx < values.Length; idx++)
                result[idx] = values[idx];
 
            return result;
        }
    }

Como vemos, lo único que hacemos es crear un atributo nuevo llamado [SqlServerDataAttribute] que recibe el nombre del servidor, la base de datos y la sentencia que queremos ejecutar y, sobreescribiendo el método GetData devolvemos los datos desde la base de datos. Insisto, echad un vistazo al código original, porque el anterior es una versión simplificada. Ahora, nuestro código del test quedaría así:

1
2
3
4
5
6
7
8
9
10
    public class XUnitShould
    {
        [Theory]        [SqlServerData(@"LUIS_PORTATIL\SQLEXPRESS", "Maths", "SELECT * FROM Sum")]        public void Add_Integers(int firstNumber, int secondNumber, int expectedResult)
        {
            int actualResult = firstNumber + secondNumber;
            Assert.Equal(expectedResult, actualResult);
        }
    }

Claramente es un forma mucho más flexible que la que nos ofrece MSTest (aunque ya comento que en MSTest V2 ha cambiado).

Conclusiones

Volvemos a ver, igual que en el anterior post, que ciertas cosas sí cambian de MSTest a xUnit y, donde MSTest nos ofrece herramientas, xUnit nos ofrece el poder hacerlas de forma más o menos sencilla. Se pone de manifiesto que xUnit es bastante más potente aunque puede ser un arma de doble filo; se supone que los test son nuestra documentación ejecutable y si no hacemos las cosas bien y aislamos bien esa flexibilidad que nos ofrece, podemos acabar añadiendo ruido al test, perder legibilidad y necesitar conocer demasiado la herramienta (xUnit) para interpretar los test, algo nada deseable.

¡Un saludo!

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

One thought on “Migrando de MSTest a xUnit. Atributos”

Deja un comentario

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