Loupe

Xunit - exécuter du code avant et après le passage de tous vos tests : global teardown / assemblyFixture

Xunit est un framework de tests unitaires très utilisé dans le monde .NET. Ce SDK propose de quoi créer un contexte réutilisable dans les différents tests par collection ou par classe de tests. Dans cet article, nous verrons comment créer un contexte pour l'assembly entière, fonctionnalité qui n'est pas proposée par défaut.

Si vous voulez un petit rappel sur les tests unitaires, pensez à lire l'article de Vivien

Fixture

Un contexte de test est appelé Fixture dans le vocabulaire xunit. Dans les fait, c'est une classe C# toute bête dont une instance sera donnée à chaque exécution de test. Il s'agit de l'outil idéal pour mettre en cache certaines ressources : plutôt que de les créer pour chaque test, vous les mettez en cache dans une fixture. 

Une fixture est une classe C# (initialisée dans son constructeur sans injection de dépendance). De plus, si elle implémente IDisposable, xUnit appellera la méthode Dispose après la dernière utilisation de la fixture. Si une initialisation asynchrone est nécessaire, alors je vous propose d'utiliser l'interface IAsyncLifetime sur vos tests et d'appeler une méthode InitAsync propre à votre fixture. Elle sera appelée avant l’exécution de chaque test. Un exemple est donné plus bas dans cet article.

Une fixture peut être partagée entre tous les tests d'une même classe ou entre tous les tests d'une même collection. Pour rappel, par défaut, une collection est crée par classe de tests. Une instance de la classe de test est construite juste avant l'exécution de chaque méthode de test puis détruite juste après. En d'autres termes (les très bons d'Olivier pour être précis), chaque méthode de test s'exécute dans une instance spécialement créé pour cette exécution. Créer soi-même la classe de fixture dans le constructeur de la classe de test revient donc à créer la fixture autant de fois qu'il y a de méthodes de test. L'avantage que l'on cherche à avoir est de créer une seule instance de fixture pour toutes les méthodes de test d'une même classe.

Il est possible de demander plusieurs fixtures par test. Dans la suite de l'article, nous utiliserons cette définition de fixture qui ne fait rien.

public class InfiniteSquareFixture : IDisposable
{
    public string UniqueId { get; set; }
    public string TestInstanceId { get; set; }
    public InfiniteSquareFixture()
    {
        Init();
    }

    private void Init()
    {
        UniqueId = Guid.NewGuid().ToString("N");
    }

    public Task InitAsync()
    {
        TestInstanceId = Guid.NewGuid().ToString("N");
        return Task.CompletedTask;
    }

    public void Dispose()
    {
    }
}

Contexte de test par classe : IClassFixture

Pour demander la création d'une fixture pour tous les tests d'une classe, il faut apposer l'interface IClassFixture<TypeFixture> sur toutes vos classes de tests. La fixture (de type TypeFixture) sera passée en constructeur de la classe de test.

public class MaClasseDeTests : IClassFixture<InfiniteSquareFixture>
{
    private readonly InfiniteSquareFixture _fixture;
    public MaClasseDeTests(InfiniteSquareFixture fixture)
    {
        _fixture = fixture;
    }
    [Fact]
    public async Task Docker_Sert_Il_a_quelque_chose()
    {
        var uniqueFixtureId = _fixture.UniqueId;
    }
    [Fact]
    public async Task Un_autre_Test()
    {
        // on aura le même id
        var uniqueFixtureId = _fixture.UniqueId;
    }
}

Si vous souhaitez appeler une méthode d'initialisation avant l'execution de chaque test, il suffit d'implémenter l'interface IAsyncLifetime sur votre classe de test qui vous demandera d'implémenter InitializeAsync et DisposeAsync. Ces deux méthodes seront appelées automatiquement par xunit.

public class MaClasseDeTests : 
   IClassFixture<InfiniteSquareFixture>, 
   IAsyncLifetime
{
    private readonly InfiniteSquareFixture _fixture;
    public MaClasseDeTests(InfiniteSquareFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task Docker_Sert_Il_a_quelque_chose()
    {
        // on aura pour chaque test le même id
        var uniqueFixtureId = _fixture.UniqueId;
        // on aura un identifiant unique par test
        var uniqueTestId = _fixture.UniqueId;
    }

    [Fact]
    public async Task Un_autre_Test()
    {
        // on aura pour chaque test le même id
        var uniqueFixtureId = _fixture.UniqueId;
        // on aura un identifiant unique par test
        var uniqueTestId = _fixture.UniqueId;
    }

    public Task InitializeAsync()
    {
        return _fixture.InitAsync();
    }

    public async Task DisposeAsync()
    {
        _fixture.Dispose();
    }
}

Contexte de test par collection : ICollectionFixture

Pour demander la création d'une fixture pour tous les tests d'une collection, il faut commencer par créer une classe définissant la collection en lui apposant l'attribut CollectionDefinition avec le nom de la collection que l'on souhaite.

[CollectionDefinition("MaCollection")]
public class CollectionClass 
{
}

Il faut ensuite apposer l'interface ICollectionFixture<TypeFixture> sur cette classe servant à définir la collection. La fixture (de type TypeFixture) sera passée en constructeur de chaque classe de test de cette collection.

[CollectionDefinition("MaCollection")]
public class CollectionClass : ICollectionFixture<InfiniteSquareFixture>
{
}

Pour placer un test dans une collection, il faut lui apposer l'attribut... Collection

[Collection("MaCollection")]
public class MaClasseDeTests 
{
    private readonly InfiniteSquareFixture _fixture;
    public MaClasseDeTests(InfiniteSquareFixture fixture)
    {
        _fixture = fixture;
    }
}

Contexte de test par assembly 

J'ai récemment eu ce besoin pour nettoyer des ressources créées en fonction des besoins de chaque test et que je gardais en mémoire pour être réutilisées peu importe la collection/classe où se trouvait le test en ayant besoin. À la fin de l’exécution de tous les tests, j'avais donc intérêt à nettoyer les ressources plutôt qu'à les laisser créées dans le cloud... En me tournant vers la documentation, je me suis vite rendu compte que je ne trouvais rien.... 

Il existe bien un package Nuget mais il n'est pas mis à jour depuis 2017 et nécessite de passer la fixture dans chaque constructeur de test, ce qui est fastidieux lorsque l'on a plusieurs centaines de classes de tests. En étudiant les samples mis à disposition sur le github, j'ai pu trouver une solution fonctionnelle et pas si compliquée (lol) à mettre en place une fois connue :

  1. Créer notre propre framework de tests en dérivant de celui d'Xunit (de la classe XunitTestFramework donc),
  2. Fournir un "exécuteur de test" de notre cru dans la méthode CreateExecutor.
  3. Demander l'utilisation de notre framework de test à l'aide de l'attribut TestFramework.
  4. Créer et utiliser un attribut AssemblyFixture que l'on apposera sur nos assemblies de tests.

Simple donc..... Commençons donc par la fin en définissant notre attribut AssemblyFixture. Son seul paramètre sera le type de la fixture spécifique à chaque assembly de test. 

[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public class AssemblyFixtureAttribute : Attribute
{
    public AssemblyFixtureAttribute(Type fixtureType)
    {
        FixtureType = fixtureType;
    }
    public Type FixtureType { get; private set; }
}

Ensuite, définissons un exécuteur de tests. Celui-ci dérive de XunitTestFrameworkExecture et surcharge la méthode RunTestsCase pour obtenir tous les attributs de type AssemblyFixture sur les assemblies testées, les instancier avant l'execution des tests et appeler la méthode Dispose après les tests.

public class MonXunitTestFrameworkExecutor 
: XunitTestFrameworkExecutor
{
    public MonXunitTestFrameworkExecutor(
        AssemblyName assemblyName,
        ISourceInformationProvider sourceInformationProvider,
        IMessageSink diagnosticMessageSink)
        : base( 
           assemblyName, 
           sourceInformationProvider, 
           diagnosticMessageSink)
    {
    }
    protected override async void RunTestCases(
        IEnumerable<IXunitTestCase> testCases,
        IMessageSink executionMessageSink,
        ITestFrameworkExecutionOptions executionOptions)
    {

        // Instantiate all the fixtures
        // Instantiate all the fixtures
        var fixtures = ((IReflectionAssemblyInfo)TestAssembly.Assembly)
      .Assembly
            .GetCustomAttributes(typeof(AssemblyFixtureAttribute), false)
            .Cast<AssemblyFixtureAttribute>()
            .Select(fAttr => Activator.CreateInstance(fAttr.FixtureType))
            .ToList();
            
        using (var assemblyRunner = new XunitTestAssemblyRunner(
            TestAssembly,
            testCases,
            DiagnosticMessageSink,
            executionMessageSink,
            executionOptions))
        {
            await assemblyRunner.RunAsync().ConfigureAwait(false);
        }

        foreach (var instanceOfFixture in fixtures)
        {
            if (instanceOfFixture is IDisposable asDisposable)
            {
                asDisposable.Dispose();
            }
        }
    }
}

Reste ensuite à créer notre framework de test en lui fournissant une instance de notre classe MonXunitTestFrameworkExecutor. Très simple au final :

public class MonXunitTestFramework : XunitTestFramework
{
    public MonXunitTestFramework(IMessageSink messageSink)
        : base(messageSink)
    {
    }
    protected override ITestFrameworkExecutor 
          CreateExecutor(AssemblyName assemblyName)
    {
        return new MonXunitTestFrameworkExecutor(
            assemblyName,
            SourceInformationProvider,
            DiagnosticMessageSink);
    }
}

C'est maintenant que la magie opère, on va brancher le tout ensemble en ajoutant deux attributs sur notre assembly de test :

using Xunit;

[assembly: TestFramework(
   "Namespace.AFactoringXunitTestFramework",
   "NomDeLAssembly")]

[assembly:
    Namespace.AssemblyFixture( typeof(Namespaxe.InfiniteSquareFixture))]

 

 

 

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus