Las pruebas unitarias basicas con el atributo Fact cubren un caso por metodo. En un proyecto real, la misma logica suele probarse contra docenas de valores distintos, y muchos tests comparten un contexto caro de preparar, como una base de datos en memoria o un servidor de prueba. xUnit ofrece dos mecanismos dedicados a estas necesidades: los tests parametrizados con Theory y las fixtures compartidas con IClassFixture e ICollectionFixture.
Tests parametrizados con Theory
El atributo Theory marca un metodo de prueba como parametrizado. En lugar de ejecutarse una sola vez, xUnit ejecuta el metodo varias veces, una por cada conjunto de datos proporcionado. Cada ejecucion aparece como un test independiente en el informe.
El suministro de datos mas simple es el atributo InlineData, colocado encima del metodo. Cada InlineData representa un caso con sus argumentos.
using Xunit;
public class CalculadoraTests
{
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
[InlineData(-4, 4, 0)]
[InlineData(0, 0, 0)]
public void Sumar_DevuelveResultadoEsperado(int a, int b, int esperado)
{
var calc = new Calculadora();
int resultado = calc.Sumar(a, b);
Assert.Equal(esperado, resultado);
}
}
El mismo bloque de aserciones se ejecuta para cada linea de InlineData, cuadruplicando la cobertura con un esfuerzo minimo. Si uno de los casos falla, xUnit indica exactamente cuales argumentos provocaron el fallo.
InlineDatasolo admite constantes de compilacion: enteros, cadenas, booleanos, tipos de enum ynull. Para datos dinamicos se usan otras fuentes.
Fuentes de datos con MemberData
MemberData enlaza la theory con una propiedad, campo o metodo estatico que devuelve los casos. La fuente tiene que ser IEnumerable<object[]> o TheoryData<T> e IEnumerable<TheoryDataRow<T>>.
using Xunit;
public class FacturaTests
{
public static IEnumerable<object[]> CasosImpuestos =>
new List<object[]>
{
new object[] { 100m, 0.21m, 121m },
new object[] { 50m, 0.10m, 55m },
new object[] { 0m, 0.21m, 0m }
};
[Theory]
[MemberData(nameof(CasosImpuestos))]
public void CalcularTotal_AplicaImpuesto(decimal baseImponible, decimal iva, decimal esperado)
{
var factura = new Factura(baseImponible, iva);
decimal total = factura.CalcularTotal();
Assert.Equal(esperado, total);
}
}
La tipificacion estricta mejora si se emplea TheoryData<T1, T2, T3>, que evita el uso de arrays de object y aporta comprobacion de tipos en la fuente.
public static TheoryData<decimal, decimal, decimal> CasosImpuestosTipados =>
new()
{
{ 100m, 0.21m, 121m },
{ 50m, 0.10m, 55m },
{ 0m, 0.21m, 0m }
};
Las fuentes con MemberData son ideales para datos generados dinamicamente. Un metodo puede leer un CSV, combinar casos de prueba o calcular matrices de entradas y salidas antes de que xUnit ejecute los tests.
Fuentes de datos con ClassData
Cuando la lista de casos es muy larga o necesita logica para generarlos, ClassData permite ubicarla en una clase dedicada. La clase implementa IEnumerable<object[]> o extiende TheoryData<...>.
using System.Collections;
using System.Collections.Generic;
using Xunit;
public class CasosPrecios : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { "EUR", 100, 100 };
yield return new object[] { "USD", 100, 110 };
yield return new object[] { "GBP", 100, 115 };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class ConversorTests
{
[Theory]
[ClassData(typeof(CasosPrecios))]
public void Convertir_AplicaTasas(string moneda, decimal origen, decimal esperado)
{
var conversor = new Conversor();
decimal resultado = conversor.ADolares(origen, moneda);
Assert.Equal(esperado, resultado);
}
}
Separar los datos en una clase aporta dos ventajas claras. Primera, el test queda mas legible, centrado en su logica y no en tablas de valores. Segunda, los datos pueden reutilizarse entre varios tests sin duplicar la fuente.
flowchart LR
A[Theory] --> B{Fuente}
B --> C[InlineData]
B --> D[MemberData]
B --> E[ClassData]
C --> F[Test N veces]
D --> F
E --> F
IClassFixture para compartir contexto
Algunos tests comparten un contexto de preparacion costoso, como un servidor en memoria, una base de datos o un cliente HTTP. Crearlo en cada test multiplica el tiempo de ejecucion. IClassFixture<T> permite construir ese contexto una sola vez por clase y compartirlo entre todos sus tests, manteniendo el aislamiento entre clases.
Primero se define la fixture, una clase con la logica de preparacion y limpieza.
using System;
using Microsoft.Data.Sqlite;
public class BaseDatosFixture : IDisposable
{
public SqliteConnection Conexion { get; }
public BaseDatosFixture()
{
Conexion = new SqliteConnection("DataSource=:memory:");
Conexion.Open();
using var cmd = Conexion.CreateCommand();
cmd.CommandText = "CREATE TABLE Productos (Id INTEGER, Nombre TEXT);";
cmd.ExecuteNonQuery();
}
public void Dispose() => Conexion.Dispose();
}
Despues, la clase de tests implementa IClassFixture<T> y recibe la fixture por constructor.
using Xunit;
public class ProductoRepositorioTests : IClassFixture<BaseDatosFixture>
{
private readonly BaseDatosFixture _db;
public ProductoRepositorioTests(BaseDatosFixture db)
{
_db = db;
}
[Fact]
public void Insertar_AgregaRegistro()
{
var repo = new ProductoRepositorio(_db.Conexion);
repo.Insertar(new Producto(1, "Raton"));
Assert.Equal(1, repo.Contar());
}
}
xUnit crea una instancia de BaseDatosFixture antes de ejecutar el primer test de la clase y la libera llamando a Dispose al terminar todos los tests. Los tests siguen siendo independientes, pero la infraestructura se prepara una vez.
Cada clase de tests recibe su propia fixture. Para compartir una fixture entre varias clases hace falta la variante de coleccion.
ICollectionFixture para agrupar clases
Cuando varias clases de tests necesitan el mismo contexto, como un servidor en memoria o un entorno de integracion, se define una coleccion con ICollectionFixture<T>.
Primero se declara la definicion de coleccion con su fixture asociada.
using Xunit;
[CollectionDefinition("Base de datos")]
public class BaseDatosCollection : ICollectionFixture<BaseDatosFixture>
{
}
Despues, cada clase de tests que pertenezca a esa coleccion se marca con el atributo Collection y recibe la fixture por constructor como en el caso anterior.
[Collection("Base de datos")]
public class ProductoRepositorioTests
{
private readonly BaseDatosFixture _db;
public ProductoRepositorioTests(BaseDatosFixture db) => _db = db;
[Fact]
public void Contar_DevuelveCero_AlInicio() =>
Assert.Equal(0, new ProductoRepositorio(_db.Conexion).Contar());
}
[Collection("Base de datos")]
public class CategoriaRepositorioTests
{
private readonly BaseDatosFixture _db;
public CategoriaRepositorioTests(BaseDatosFixture db) => _db = db;
[Fact]
public void Contar_DevuelveCero_AlInicio() =>
Assert.Equal(0, new CategoriaRepositorio(_db.Conexion).Contar());
}
La fixture se crea una unica vez para toda la coleccion y se comparte entre clases. Los tests de la coleccion no se ejecutan en paralelo entre si, lo cual aporta un aislamiento predecible cuando el recurso no es thread-safe.
Aserciones y buenas practicas en Theory
Cuando se escriben tests parametrizados, es comun caer en la tentacion de reescribir la logica en la propia aserccion para comprobar los resultados. Esto produce tests que repiten la implementacion y no detectan errores. El objetivo de una Theory es variar entradas, no recalcular salidas.
Una regla util es precalcular los resultados esperados como constantes en las filas. El test queda declarativo y compara un valor literal con el producido por el codigo de produccion.
[Theory]
[InlineData("", true)]
[InlineData(" ", true)]
[InlineData("texto", false)]
[InlineData(null, true)]
public void EsVacio_DetectaAusenciaDeContenido(string? entrada, bool esperado)
{
Assert.Equal(esperado, CadenaUtil.EsVacio(entrada));
}
Cada linea es una afirmacion declarativa sobre el contrato del metodo. Si alguno de los casos cambia de comportamiento, el fallo localiza con precision la fila afectada.
Paralelismo y aislamiento
Por defecto, xUnit ejecuta tests de clases distintas en paralelo usando varios hilos, mientras que los tests de la misma clase comparten hilo y corren secuencialmente. IClassFixture es seguro porque cada clase tiene su fixture. ICollectionFixture deshabilita el paralelismo dentro de la coleccion para proteger recursos compartidos.
Cuando la suite crece, conviene decidir explicitamente que clases se paralelizan. Se puede configurar el comportamiento en un archivo de configuracion de xUnit para activar o desactivar paralelismo por proyecto.
{
"parallelizeAssembly": false,
"parallelizeTestCollections": true,
"maxParallelThreads": 4
}
Un exceso de paralelismo contra recursos compartidos causa tests inestables. La regla practica es aislar la infraestructura real en colecciones y mantener los tests de logica pura paralelizados al maximo.
Resumen de eleccion
La eleccion entre Fact y Theory depende de cuantos casos pruebe el metodo. Si son uno o dos, Fact es suficiente. Si son varios y comparten la misma logica, Theory es la opcion natural.
La eleccion entre InlineData, MemberData y ClassData depende del origen de los datos. Constantes literales se escriben con InlineData. Datos calculados con alguna logica se exponen via MemberData. Casos que se reutilizan entre varios tests o que tienen generacion compleja se encapsulan en ClassData.
La eleccion entre IClassFixture e ICollectionFixture depende del alcance del recurso compartido. Cuando un contexto se puede atar a una clase, IClassFixture mantiene el aislamiento. Cuando hace falta compartir un entorno real entre varias clases, ICollectionFixture agrupa los tests y serializa su ejecucion.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, C# es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de C#
Explora más contenido relacionado con C# y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Escribir tests parametrizados en xUnit con Theory e InlineData, proveer datos desde metodos o clases externas y reutilizar contextos caros mediante IClassFixture e ICollectionFixture.