Ir al contenido
wannacode

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:

Program.cs
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));
Salida
45
170

50 € 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
En esta lección escribimos tests unitarios. En C# la biblioteca que más se usa para ellos es xUnit.

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

Los tests de Discount están en una clase aparte, DiscountTest, en un proyecto de tests aparte:
DiscountTest.cs
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 a Fact y Assert.
  • 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:

Resultado
FALLA ExtraFivePercentFrom100
Assert.Equal() Failure: Values differ
Expected: 85
Actual: 90
OK TenPercentOff
Tests: 2, pasan: 1, fallan: 1

El 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:

Resultado
OK ExtraFivePercentFrom100
OK TenPercentOff
Tests: 2, pasan: 2, fallan: 0

Cada 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
  1. DiscountTest tiene dos tests. xUnit ejecuta cada uno por separado y enciende una luz a su lado.
  2. Test 1, preparar: un precio de 50 € y un 10% de descuento.
  3. Actuar: Discount.Apply(50, 10) devuelve 45.
  4. Comprobar: Assert.Equal compara el 45 esperado con el 45 devuelto. Son iguales, así que el test pasa.
  5. Test 2: exactamente 100 € y un 10%. Esperamos 85, porque desde 100 € hay otro 5%.
  6. La condición en el método es price > 100, y 100 no es mayor que 100. El método devuelve 90.
  7. Esperado 85, devuelto 90: el test falla y dice exactamente eso.
  8. Corriges la condición a price >= 100 y ejecutas los tests otra vez.
  9. Ahora 100 cumple la condición y el método devuelve 85.
  10. 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.Tests junto a Shop. Visual Studio y Rider muestran todos los tests en Test Explorer: haces clic en «Run» y ves el resultado.
Estructura
Shop/Discount.cs el código
Shop/Shop.csproj
Shop.Tests/DiscountTest.cs sus tests
Shop.Tests/Shop.Tests.csproj aquí se añade xUnit
  • El proyecto de tests se crea con dotnet new xunit, y dotnet test lo compila y ejecuta los tests:
Comandos
dotnet new xunit -o Shop.Tests un proyecto de tests nuevo
dotnet add Shop.Tests reference Shop los tests ven el código
dotnet test compila y ejecuta todos los tests
dotnet test --filter "FullyQualifiedName~Discount"
solo los tests con Discount en el nombre
  • xUnit es un paquete en el archivo .csproj del proyecto de tests. La plantilla añade los paquetes, y dotnet add reference añade el enlace al código:
Shop.Tests.csproj
<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 Main están en un solo programa. Main ejecuta los tests a través de la interfaz de programación de xUnit (AssemblyRunner del 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:
El ejecutor
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));
}
}
Respuesta

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:

Account.cs
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;
}
}
AccountTest.cs
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);
}
}
Salida
OK CanPayWhatIsThere
OK WithdrawTooMuch
Tests: 2, pasan: 2, fallan: 0
  • Assert.True y Assert.False comprueban 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 en List<string>. El código se pasa como lambda: Assert.Throws lo 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>:

AccountTest.cs
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));
}
}
Salida
FALLA ExactType
Assert.Throws() Failure: Exception type was not an exact match
Expected: typeof(System.Exception)
Actual: typeof(InsufficientFundsException)
OK TypeOrDerived
Tests: 2, pasan: 1, fallan: 1

La idea

Cada test empieza de cero

Aquí los dos tests comparten una cuenta con 100 €:

AccountTest.cs
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);
}
}
Salida
FALLA retirar 70 € deja 30 €
Faltan 10 €
OK retirar 40 € deja 60 €
Tests: 2, pasan: 1, fallan: 1

account 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:

AccountTest.cs
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);
}
}
Salida
OK retirar 40 € deja 60 €
OK retirar 70 € deja 30 €
Tests: 2, pasan: 2, fallan: 0
El constructor se llama antes de cada test, porque cada test recibe un objeto nuevo. DisplayName 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
Más cosas de xUnit que te vas a encontrar:
xUnit
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 resultado

La 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:

DiscountTest.cs
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));
}
}
Salida
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: 0

Cada 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:

DeliveryTest.cs
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));
}
}
Salida
FALLA Price(total: 50, expected: 0)
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 4
OK 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.

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:

PromoTest.cs
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"));
}
}
  1. 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;
}
}
Salida
FALLA CodeIsCaseInsensitive
Assert.Equal() Failure: Values differ
Expected: 10
Actual: 0
FALLA KnownCode
Assert.Equal() Failure: Values differ
Expected: 10
Actual: 0
OK UnknownCode
Tests: 3, pasan: 1, fallan: 2
  1. 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;
}
}
Salida
OK CodeIsCaseInsensitive
OK KnownCode
OK UnknownCode
Tests: 3, pasan: 3, fallan: 0
  1. 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»:

DeliveryTest.cs
[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"
Salida
OK ExtraFivePercentFrom100
OK Price(total: 20, expected: 4)
OK Price(total: 50, expected: 0)
OK Price(total: 80, expected: 0)
OK TenPercentOff
OK retirar 40 € deja 60 €
OK retirar 70 € deja 30 €
Tests: 7, pasan: 7, fallan: 0
Má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:
PriceInUsdTest.cs
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());
}
}
Salida
OK ConvertsWithTodaysRate
Tests: 1, pasan: 1, fallan: 0

new 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:
Comprobaciones
Assert.NotEqual(a, b) si son distintos
Assert.Same(a, b) si es el mismo objeto, no solo iguales
Assert.NotSame(a, b)
Assert.Null(x), Assert.NotNull(x)
Assert.Equal(list1, list2) dos listas (o arrays) con los mismos elementos en el mismo orden
Assert.Contains(x, items), Assert.DoesNotContain(x, items)
Assert.Empty(items), Assert.Single(items)
Assert.InRange(x, low, high) x está entre low y high
Assert.Matches(pattern, text) el texto coincide con una expresión regular
Assert.IsType<T>(x) el objeto es exactamente del tipo T
Assert.ThrowsAny<T>(() => ...) una excepción del tipo T o de una subclase suya
Assert.Fail("mensaje") el test falla al instante

Ejercicio

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 €:

Resultado contra la versión rota
FALLA Apply(price: 100, percent: 0, expected: 95)
Assert.Equal() Failure: Values are not within tolerance 0.001
Expected: 95
Actual: 100
FALLA Exactly100GetsTheExtra
Assert.Equal() Failure: Values are not within tolerance 0.001
Expected: 85
Actual: 90
OK 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: 2

2. 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 InsufficientFundsException con 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:

Grades.cs
class Grades
{
public static double Average(int[] grades)
{
int sum = 0;
foreach (int g in grades)
{
sum += g;
}
return sum / grades.Length;
}
}
Escribe dos tests: la media de 4 y 6 es 5,0, y la media de 5 y 6 es 5,5 (con una diferencia permitida de 0,001). Uno de ellos falla:
Resultado
FALLA AverageWithAFraction
Assert.Equal() Failure: Values are not within tolerance 0.001
Expected: 5.5
Actual: 5
OK WholeAverage
Tests: 2, pasan: 1, fallan: 1

Encuentra la causa y corrige el método, para que pasen los dos.

Test

Ponte a prueba

1. ¿Qué comprueba un test unitario?
2. El test falla con «Expected: 85» y «Actual: 90». ¿Qué devolvió el método?
3. ¿Por qué la cuenta se crea en el constructor de la clase de tests y no una sola vez en un campo static?
4. ¿Cómo compruebas que Withdraw(130) lanza InsufficientFundsException?
5. ¿Cuál es el orden en TDD?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·