xUnit con Theory y fixtures compartidas

Avanzado
C#
C#
Actualizado: 21/04/2026

Las pruebas unitarias básicas con el atributo Fact cubren un caso por método. En un proyecto real, la misma lógica 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 método de prueba como parametrizado. En lugar de ejecutarse una sola vez, xUnit ejecuta el método varias veces, una por cada conjunto de datos proporcionado. Cada ejecución aparece como un test independiente en el informe.

El suministro de datos más simple es el atributo InlineData, colocado encima del método. 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 línea de InlineData, cuadruplicando la cobertura con un esfuerzo mínimo. Si uno de los casos falla, xUnit indica exactamente cuáles argumentos provocaron el fallo.

InlineData solo admite constantes de compilación: enteros, cadenas, booleanos, tipos de enum y null. Para datos dinámicos se usan otras fuentes.

Fuentes de datos con MemberData

MemberData enlaza la theory con una propiedad, campo o método estático 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 tipificación estricta mejora si se emplea TheoryData<T1, T2, T3>, que evita el uso de arrays de object y aporta comprobación 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 dinámicamente. Un método 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 lógica 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 más legible, centrado en su lógica 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 preparación costoso, como un servidor en memoria, una base de datos o un cliente HTTP. Crearlo en cada test multiplica el tiempo de ejecución. 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 lógica de preparación 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();
}

Después, 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 colección.

ICollectionFixture para agrupar clases

Cuando varias clases de tests necesitan el mismo contexto, como un servidor en memoria o un entorno de integración, se define una colección con ICollectionFixture<T>.

Primero se declara la definición de colección con su fixture asociada.

using Xunit;

[CollectionDefinition("Base de datos")]
public class BaseDatosCollection : ICollectionFixture<BaseDatosFixture>
{
}

Después, cada clase de tests que pertenezca a esa colección 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 única vez para toda la colección y se comparte entre clases. Los tests de la colección no se ejecutan en paralelo entre sí, lo cual aporta un aislamiento predecible cuando el recurso no es thread-safe.

Aserciones y buenas prácticas en Theory

Cuando se escriben tests parametrizados, es común caer en la tentación de reescribir la lógica en la propia aserción para comprobar los resultados. Esto produce tests que repiten la implementación y no detectan errores. El objetivo de una Theory es variar entradas, no recalcular salidas.

Una regla útil es precalcular los resultados esperados como constantes en las filas. El test queda declarativo y compara un valor literal con el producido por el código de producción.

[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 línea es una afirmación declarativa sobre el contrato del método. Si alguno de los casos cambia de comportamiento, el fallo localiza con precisión 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 colección para proteger recursos compartidos.

Cuando la suite crece, conviene decidir explícitamente qué clases se paralelizan. Se puede configurar el comportamiento en un archivo de configuración 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 práctica es aislar la infraestructura real en colecciones y mantener los tests de lógica pura paralelizados al máximo.

Resumen de elección

La elección entre Fact y Theory depende de cuántos casos pruebe el método. Si son uno o dos, Fact es suficiente. Si son varios y comparten la misma lógica, Theory es la opción natural.

La elección entre InlineData, MemberData y ClassData depende del origen de los datos. Constantes literales se escriben con InlineData. Datos calculados con alguna lógica se exponen vía MemberData. Casos que se reutilizan entre varios tests o que tienen generación compleja se encapsulan en ClassData.

La elección 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 ejecución.

Alan Sastre - Autor del tutorial

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 métodos o clases externas y reutilizar contextos caros mediante IClassFixture e ICollectionFixture.