Tests con xUnit
En resumen: Un test unitario es código que comprueba un trozo pequeño, normalmente un método, con cada cambio; en C# se escribe con xUnit, [Fact] y Assert.Equal.
Problema
El descuento que se rompió en silencio
La regla de la tienda es: el descuento va en porcentaje, y en una compra de 100 € o más se suma otro 5%. Escribes el método y lo pruebas a mano:
class Discount{ // Descuento en porcentaje. Desde 100 € se suma otro 5%. public static double Apply(double price, int percent) { if (price > 100) { percent = percent + 5; } return price * (100 - percent) / 100; }}
Console.WriteLine(Discount.Apply(50, 10));Console.WriteLine(Discount.Apply(200, 10));4517050 € con un 10% son 45 €, y 200 € con un 15% son 170 €. Parece correcto y lo publicas. Una semana después, un cliente con una compra de exactamente 100 € paga 90 € en lugar de 85 €. La condición es price > 100, y debería ser price >= 100.
Nadie probó exactamente 100 €. Y con cada cambio en el método tienes que probar otra vez todos los casos a mano.
La idea
Código que comprueba código
En lugar de mirar la salida con tus propios ojos, escribes métodos pequeños que llaman a tu código y comparan el resultado con el esperado. Eso es un test. Ejecutas todos los tests con un solo clic, después de cada cambio, y te dicen qué se ha roto.
| Tipo | Qué comprueba | Ejemplo |
|---|---|---|
| Test unitario | un trozo pequeño, normalmente un método, separado del resto | Discount.Apply(100, 10) devuelve 85 |
| De integración | cómo funcionan las piezas juntas: con una base de datos, un archivo, otro servicio | el pedido se guarda en la base de datos y se vuelve a leer |
| De extremo a extremo (end-to-end) | toda la aplicación, tal como la usa una persona | el cliente añade al carrito, paga y recibe un correo |
Por qué vale la pena:
- el error se ve enseguida, mientras aún recuerdas qué cambiaste, y no cuando el cliente se queja;
- el test muestra cómo se usa el código: qué caso devuelve qué;
- puedes ordenar el código sin miedo. Si los tests siguen en verde después del cambio, el comportamiento no ha cambiado.
Más precisamente, para curiosos
Hay otros tipos de tests: funcionales (si el programa cumple los requisitos), de rendimiento (cuántas peticiones aguanta), de seguridad (si tiene agujeros) y de interfaz de usuario (los botones, los formularios). Todos se apoyan en los tests unitarios: son los más numerosos, los más rápidos y los más baratos de mantener.
Además de xUnit, en .NET verás NUnit ([Test], [SetUp]) y MSTest ([TestMethod], [TestInitialize]). Las ideas son las mismas. Aquí usamos xUnit: es también lo que trae la plantilla dotnet new xunit.Míralo
El primer test
Discount están en una clase aparte, DiscountTest, en un proyecto de tests aparte:using Xunit;
public class DiscountTest{ [Fact] public void TenPercentOff() { Assert.Equal(45.0, Discount.Apply(50, 10)); }
[Fact] public void ExtraFivePercentFrom100() { Assert.Equal(85.0, Discount.Apply(100, 10)); }}[Fact]delante del método le dice a xUnit «esto es un test, ejecútalo». Estas etiquetas entre corchetes se llaman atributos. Cómo los encuentra xUnit lo verás en la última lección del módulo.- La clase de tests es
public: xUnit solo ejecuta tests de clases públicas.using Xunit;arriba del todo da acceso aFactyAssert. Assert.Equal(esperado, real)compara los dos valores. Si son distintos, el test falla.
Ejecutamos los dos tests contra el código de la primera tarjeta:
FALLA ExtraFivePercentFrom100 Assert.Equal() Failure: Values differ Expected: 85 Actual: 90OK TenPercentOffTests: 2, pasan: 1, fallan: 1El primer test pasa. El segundo falla y dice exactamente qué ha pasado: esperaba 85, y el método ha devuelto 90. En la salida de xUnit, el valor esperado es Expected, y el devuelto es Actual. Corriges la condición a price >= 100, los ejecutas otra vez y los dos están en verde:
OK ExtraFivePercentFrom100OK TenPercentOffTests: 2, pasan: 2, fallan: 0Cada test hace tres cosas: prepara los valores, llama al código y comprueba el resultado.
public class DiscountTest{ [Fact] public void TenPercentOff() { Assert.Equal(45.0, Discount.Apply(50, 10)); }
[Fact] public void ExtraFivePercentFrom100() { Assert.Equal(85.0, Discount.Apply(100, 10)); }}Paso a paso, en palabras
- DiscountTest tiene dos tests. xUnit ejecuta cada uno por separado y enciende una luz a su lado.
- Test 1, preparar: un precio de 50 € y un 10% de descuento.
- Actuar: Discount.Apply(50, 10) devuelve 45.
- Comprobar: Assert.Equal compara el 45 esperado con el 45 devuelto. Son iguales, así que el test pasa.
- Test 2: exactamente 100 € y un 10%. Esperamos 85, porque desde 100 € hay otro 5%.
- La condición en el método es price > 100, y 100 no es mayor que 100. El método devuelve 90.
- Esperado 85, devuelto 90: el test falla y dice exactamente eso.
- Corriges la condición a price >= 100 y ejecutas los tests otra vez.
- Ahora 100 cumple la condición y el método devuelve 85.
- Esperado y devuelto coinciden. Los dos tests están en verde.
Más precisamente, para curiosos
- En un proyecto real, los tests están en un proyecto aparte junto al código, por ejemplo
Shop.Testsjunto aShop. Visual Studio y Rider muestran todos los tests en Test Explorer: haces clic en «Run» y ves el resultado.
Shop/Discount.cs el códigoShop/Shop.csprojShop.Tests/DiscountTest.cs sus testsShop.Tests/Shop.Tests.csproj aquí se añade xUnit- El proyecto de tests se crea con
dotnet new xunit, ydotnet testlo compila y ejecuta los tests:
dotnet new xunit -o Shop.Tests un proyecto de tests nuevodotnet add Shop.Tests reference Shop los tests ven el códigodotnet test compila y ejecuta todos los testsdotnet test --filter "FullyQualifiedName~Discount" solo los tests con Discount en el nombre- xUnit es un paquete en el archivo
.csprojdel proyecto de tests. La plantilla añade los paquetes, ydotnet add referenceañade el enlace al código:
<ItemGroup> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /></ItemGroup><ItemGroup> <ProjectReference Include="../Shop/Shop.csproj" /></ItemGroup>- Aquí, en el sitio, los tests y
Mainestán en un solo programa.Mainejecuta los tests a través de la interfaz de programación de xUnit (AssemblyRunnerdel paquete xunit.runner.utility), uno a uno y con el nombre del método sin la clase, e imprime los resultados ordenados por nombre. Están ordenados porque xUnit no promete el orden de los tests. Los mensajes de error son los de xUnit, con sangría debajo del nombre del test:
using System.Reflection;using Xunit;using Xunit.Runners;
class Program{ static void Main() { RunTests(); }
// Ejecuta los tests de este programa uno a uno e imprime los resultados, ordenados por nombre. static void RunTests(string category = null) { var results = new List<string>(); int failed = 0; using var runner = AssemblyRunner.WithoutAppDomain(Assembly.GetExecutingAssembly().Location); if (category != null) { runner.TestCaseFilter = test => test.Traits.TryGetValue("Category", out var values) && values.Contains(category); } runner.OnTestPassed = test => results.Add("OK " + test.TestDisplayName); runner.OnTestFailed = test => { failed++; results.Add("FALLA " + test.TestDisplayName + "\n " + test.ExceptionMessage.ReplaceLineEndings("\n ")); }; runner.Start(new AssemblyRunnerStartOptions { Parallel = false, MethodDisplay = TestMethodDisplay.Method }); while (runner.Status != AssemblyRunnerStatus.Idle) { Thread.Sleep(10); } results.Sort(string.CompareOrdinal); foreach (string result in results) { Console.WriteLine(result); } Console.WriteLine("Tests: " + results.Count + ", pasan: " + (results.Count - failed) + ", fallan: " + failed); }}Pruébalo
IVA con double
El precio con IVA es el precio por 1,2. Dos tests:
¿Qué mostrarán los tests?
class Vat{ public static double WithVat(double price) { return price * 1.2; }}
using Xunit;
public class VatTest{ [Fact] public void Coffee() { Assert.Equal(12.0, Vat.WithVat(10)); }
[Fact] public void Tea() { Assert.Equal(3.6, Vat.WithVat(3)); }}3 por 1,2 en un double no es exactamente 3,6. Los números decimales se guardan en binario, y en las cuentas se pierde una parte diminuta. Assert.Equal para double compara de forma exacta, así que el test falla:
FALLA Tea Assert.Equal() Failure: Values differ Expected: 3.6000000000000001 Actual: 3.5999999999999996OK CoffeeTests: 2, pasan: 1, fallan: 1double es exactamente 3,6. Para números decimales hay un tercer argumento: la diferencia permitida. Assert.Equal(3.6, Vat.WithVat(3), 0.001) pasa, porque la diferencia es menor que 0,001. Cuidado con un número entero en ese lugar: Assert.Equal(3.6, Vat.WithVat(3), 2) significa «redondea los dos a 2 decimales».La idea
Sí o no, y una excepción esperada
Además de la igualdad, a menudo compruebas una condición, o que el código se niega. Aquí está Account del módulo «Errores y excepciones», con un método nuevo, CanPay:
class InsufficientFundsException : Exception{ public InsufficientFundsException(int shortfall) : base("Faltan " + shortfall + " €") { }}
class Account{ public int Balance { get; private set; }
public Account(int balance) { Balance = balance; }
public bool CanPay(int amount) { return amount <= Balance; }
public void Withdraw(int amount) { if (amount <= 0) { throw new ArgumentException("El importe debe ser positivo"); } if (amount > Balance) { throw new InsufficientFundsException(amount - Balance); } Balance -= amount; }}using Xunit;
public class AccountTest{ [Fact] public void CanPayWhatIsThere() { var account = new Account(100); Assert.True(account.CanPay(100)); Assert.False(account.CanPay(101)); }
[Fact] public void WithdrawTooMuch() { var account = new Account(100); var e = Assert.Throws<InsufficientFundsException>(() => account.Withdraw(130)); Assert.Equal("Faltan 30 €", e.Message); Assert.Equal(100, account.Balance); }}OK CanPayWhatIsThereOK WithdrawTooMuchTests: 2, pasan: 2, fallan: 0Assert.TrueyAssert.Falsecomprueban una condición.Assert.Throws<Tipo>(código)falla si el código no lanza una excepción de ese tipo. El tipo va entre< >, como enList<string>. El código se pasa como lambda:Assert.Throwslo ejecuta y captura la excepción. Después la devuelve, para que compruebes también el mensaje (e.Message).
El segundo test comprueba también algo que se olvida fácilmente: después de la negativa, el saldo es el mismo.
Assert.Throws<T> exige una excepción exactamente del tipo T, no de una subclase suya. InsufficientFundsException hereda de Exception, pero Assert.Throws<Exception> falla. Para «este tipo o una subclase suya» existe Assert.ThrowsAny<T>:
public class AccountTest{ [Fact] public void ExactType() { var account = new Account(100); Assert.Throws<Exception>(() => account.Withdraw(130)); }
[Fact] public void TypeOrDerived() { var account = new Account(100); Assert.ThrowsAny<Exception>(() => account.Withdraw(130)); }}FALLA ExactType Assert.Throws() Failure: Exception type was not an exact match Expected: typeof(System.Exception) Actual: typeof(InsufficientFundsException)OK TypeOrDerivedTests: 2, pasan: 1, fallan: 1La idea
Cada test empieza de cero
Aquí los dos tests comparten una cuenta con 100 €:
public class AccountTest{ private static Account account = new Account(100);
[Fact(DisplayName = "retirar 40 € deja 60 €")] public void Withdraw40() { account.Withdraw(40); Assert.Equal(60, account.Balance); }
[Fact(DisplayName = "retirar 70 € deja 30 €")] public void Withdraw70() { account.Withdraw(70); Assert.Equal(30, account.Balance); }}FALLA retirar 70 € deja 30 € Faltan 10 €OK retirar 40 € deja 60 €Tests: 2, pasan: 1, fallan: 1account es un campo static: una cuenta para todos los tests. xUnit no promete el orden de los tests, y aquí ejecutó primero el test de 40 €. Al segundo le quedan 60 €, y retira 70 €. Si el orden fuera el contrario, fallaría el otro. Un test que depende de otro es una trampa. xUnit no tiene un atributo aparte para la preparación: para cada test crea un objeto nuevo de la clase de tests. Por eso la cuenta es un campo normal, y se crea en el constructor:
using Xunit;
public class AccountTest{ private readonly Account account;
public AccountTest() { account = new Account(100); }
[Fact(DisplayName = "retirar 40 € deja 60 €")] public void Withdraw40() { account.Withdraw(40); Assert.Equal(60, account.Balance); }
[Fact(DisplayName = "retirar 70 € deja 30 €")] public void Withdraw70() { account.Withdraw(70); Assert.Equal(30, account.Balance); }}OK retirar 40 € deja 60 €OK retirar 70 € deja 30 €Tests: 2, pasan: 2, fallan: 0DisplayName en [Fact] le da al test un nombre con palabras. Se muestra en el resultado en lugar del nombre del método.Más precisamente, para curiosos
constructor antes de cada test (un objeto nuevo para cada test)Dispose() de IDisposable después de cada test (limpieza)IClassFixture<T> un objeto T común para todos los tests de la clase[Fact(Skip = "...")] el test no se ejecuta por ahora[Trait("Category", "fast")] una etiqueta, para ejecutar solo una parte de los tests[MemberData(nameof(Prices))] las filas vienen de una propiedad o un método static[ClassData(typeof(PriceRows))] las filas vienen de una clase aparte[Collection("Base de datos")] las clases de una colección no se ejecutan a la vez (xUnit ejecuta las distintas clases de tests en paralelo)ITestOutputHelper imprimir desde el test, y se ve en el resultadoLa idea
Una comprobación, muchos casos
Cuatro casos para el descuento son cuatro métodos casi iguales. [Theory] es un test con parámetros, y cada [InlineData] es una fila «precio, porcentaje, esperado» con la que xUnit ejecuta el método:
using Xunit;
public class DiscountTest{ [Theory] [InlineData(50, 10, 45.0)] [InlineData(99, 0, 99.0)] [InlineData(100, 10, 85.0)] [InlineData(200, 20, 150.0)] public void Apply(double price, int percent, double expected) { Assert.Equal(expected, Discount.Apply(price, percent)); }}OK Apply(price: 100, percent: 10, expected: 85)OK Apply(price: 200, percent: 20, expected: 150)OK Apply(price: 50, percent: 10, expected: 45)OK Apply(price: 99, percent: 0, expected: 99)Tests: 4, pasan: 4, fallan: 0Cada fila es un test aparte con su propio resultado. xUnit arma su nombre con el método y los valores de los parámetros. Un caso nuevo es una fila nueva, no un método nuevo. Los casos importantes están en los bordes: exactamente 100 €, 99 €, 0%.
Pruébalo
Encuentra el error con el test
El envío cuesta 4 €, y desde 50 € es gratis. El test:
public class DeliveryTest{ [Theory] [InlineData(20, 4)] [InlineData(50, 0)] [InlineData(80, 0)] public void Price(int total, int expected) { Assert.Equal(expected, Delivery.Price(total)); }}FALLA Price(total: 50, expected: 0) Assert.Equal() Failure: Values differ Expected: 0 Actual: 4OK Price(total: 20, expected: 4)OK Price(total: 80, expected: 0)Tests: 3, pasan: 2, fallan: 1¿En qué línea del código está el error?
Elige la línea con el error.
El test que falla dice: en un pedido de exactamente 50 €, el envío es de 4 €, y debería ser 0. Es decir, 50 no cumple la condición. total > 50 deja fuera el propio 50. Debería ser total >= 50. El test señala el caso, y el código muestra la causa.
La idea
Primero el test
La caja tiene que aceptar el código promocional OTOÑO10 para un 10%, da igual si está en minúsculas o en mayúsculas. Esta vez escribes primero los tests:
public class PromoTest{ [Fact] public void KnownCode() { Assert.Equal(10, Promo.Percent("OTOÑO10")); }
[Fact] public void CodeIsCaseInsensitive() { Assert.Equal(10, Promo.Percent("otoño10")); }
[Fact] public void UnknownCode() { Assert.Equal(0, Promo.Percent("NINGUNO")); }}- Rojo. Por ahora el método devuelve 0. Los tests fallan, y eso está bien: significa que de verdad comprueban algo.
class Promo{ public static int Percent(string code) { return 0; }}FALLA CodeIsCaseInsensitive Assert.Equal() Failure: Values differ Expected: 10 Actual: 0FALLA KnownCode Assert.Equal() Failure: Values differ Expected: 10 Actual: 0OK UnknownCodeTests: 3, pasan: 1, fallan: 2- Verde. Escribes el código más simple con el que pasan:
class Promo{ public static int Percent(string code) { if (code.ToUpper() == "OTOÑO10") { return 10; } return 0; }}OK CodeIsCaseInsensitiveOK KnownCodeOK UnknownCodeTests: 3, pasan: 3, fallan: 0- Ordenar. Ahora puedes mover los códigos a un Dictionary, añadir otros nuevos y ordenar el método. Los tests te avisarán si rompes algo.
Esto es TDD (test-driven development, desarrollo guiado por tests). Escribir primero el test te obliga a decidir qué tiene que hacer exactamente el método, antes de pensar en cómo.
Más precisamente
Grupos de tests y objetos falsos
xUnit no tiene suites. Un grupo de tests es una etiqueta ([Trait]) en las clases, y ejecutar el grupo es un filtro. Los tests del envío con la etiqueta «Caja»:
[Trait("Category", "Caja")]public class DeliveryTest{ [Theory] [InlineData(20, 4)] [InlineData(50, 0)] [InlineData(80, 0)] public void Price(int total, int expected) { Assert.Equal(expected, Delivery.Price(total)); }}DiscountTest y AccountTest tienen la misma etiqueta. El comando ejecuta solo los tests con la etiqueta. La salida es del ejecutor del sitio, que filtra por la misma etiqueta (TestCaseFilter). VatTest de la tarjeta del IVA está en el mismo programa, pero no tiene etiqueta y no se ejecuta:
dotnet test --filter "Category=Caja"OK ExtraFivePercentFrom100OK Price(total: 20, expected: 4)OK Price(total: 50, expected: 0)OK Price(total: 80, expected: 0)OK TenPercentOffOK retirar 40 € deja 60 €OK retirar 70 € deja 30 €Tests: 7, pasan: 7, fallan: 0Más precisamente, para curiosos
- A veces el código depende de algo que no quieres llamar en un test: el tipo de cambio de un banco, un pago, un correo. Entonces le pasas un objeto falso (mock) que devuelve lo que tú le digas. La biblioteca más conocida para esto en .NET es Moq:
using Moq;using Xunit;
public interface IExchangeRates{ double EurTo(string currency);}
class Converter{ private readonly IExchangeRates rates;
public Converter(IExchangeRates rates) { this.rates = rates; }
public double ToUsd(double eur) { return eur * rates.EurTo("USD"); }}
public class PriceInUsdTest{ [Fact] public void ConvertsWithTodaysRate() { var rates = new Mock<IExchangeRates>(); rates.Setup(r => r.EurTo("USD")).Returns(1.10);
var converter = new Converter(rates.Object); Assert.Equal(11.0, converter.ToUsd(10), 0.001); rates.Verify(r => r.EurTo("USD"), Times.Once()); }}OK ConvertsWithTodaysRateTests: 1, pasan: 1, fallan: 0new Mock<IExchangeRates>() crea un objeto que implementa la interfaz, Setup(...).Returns(...) dice qué devolver, y Verify comprueba que el método se llamó exactamente una vez (Times.Once()). rates.Object es el propio objeto falso, que le pasas a Converter. La interfaz es public, porque Moq crea por su cuenta una clase que la implementa. Aquí, en el sitio, el test se ejecuta de verdad.
- Más comprobaciones además de Assert.Equal, Assert.True y Assert.Throws:
Assert.NotEqual(a, b) si son distintosAssert.Same(a, b) si es el mismo objeto, no solo igualesAssert.NotSame(a, b)Assert.Null(x), Assert.NotNull(x)Assert.Equal(list1, list2) dos listas (o arrays) con los mismos elementos en el mismo ordenAssert.Contains(x, items), Assert.DoesNotContain(x, items)Assert.Empty(items), Assert.Single(items)Assert.InRange(x, low, high) x está entre low y highAssert.Matches(pattern, text) el texto coincide con una expresión regularAssert.IsType<T>(x) el objeto es exactamente del tipo TAssert.ThrowsAny<T>(() => ...) una excepción del tipo T o de una subclase suyaAssert.Fail("mensaje") el test falla al instanteEjercicio
Escribe los tests
Resuelve los ejercicios en tu editor, con xUnit en un proyecto de tests (dotnet new xunit).
1. La calculadora de descuentos. Escribe DiscountTest para Discount.Apply de la primera tarjeta. Cubre estos casos: 80 € con un 10% son 72 €, 100 € con un 0% son 95 €, 150 € con un 20% son 112,50 €, 0 € con un 30% son 0 € y exactamente 100 € con un 10% son 85 €. Ejecútalos también contra la versión rota con price > 100. Tienen que fallar exactamente los dos casos con 100 €:
FALLA Apply(price: 100, percent: 0, expected: 95) Assert.Equal() Failure: Values are not within tolerance 0.001 Expected: 95 Actual: 100FALLA Exactly100GetsTheExtra Assert.Equal() Failure: Values are not within tolerance 0.001 Expected: 85 Actual: 90OK Apply(price: 0, percent: 30, expected: 0)OK Apply(price: 150, percent: 20, expected: 112.5)OK Apply(price: 80, percent: 10, expected: 72)Tests: 5, pasan: 3, fallan: 22. La cuenta. Escribe AccountTest para Account de la tarjeta «Sí o no, y una excepción esperada». Crea la cuenta con 100 € en AccountTest(). Comprueba que:
- retirar 30 € deja 70 €;
- se puede retirar todo, y queda 0;
- retirar 150 € lanza
InsufficientFundsExceptioncon el mensaje «Faltan 50 €», y el saldo sigue en 100 €; - 0 y -20 € lanzan
ArgumentException(un [Theory] con [InlineData(0)] y [InlineData(-20)]).
3. El error en la nota media. El método parece estar bien:
class Grades{ public static double Average(int[] grades) { int sum = 0; foreach (int g in grades) { sum += g; } return sum / grades.Length; }}FALLA AverageWithAFraction Assert.Equal() Failure: Values are not within tolerance 0.001 Expected: 5.5 Actual: 5OK WholeAverageTests: 2, pasan: 1, fallan: 1Encuentra la causa y corrige el método, para que pasen los dos.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Lanzar excepciones y crear las tuyas · Listas · Dictionary y HashSet · Lambdas y delegados
- Continúa en: Hilos · Reflexión y atributos · Las pruebas como red de seguridad · Refactorización en pasos pequeños
- Ejercicios sobre el tema: Programas en el mundo real