Contexte

A vous développeurs qui pratiquez le .NET, vous êtes-vous déjà posé cette question : « Comment diable peut-on faire des tests d’intégration en .NET localement ? ».

L’objectif de cet article va être de faire des tests intégrations en .NET. Ceux-ci vont utiliser une instance Docker en démarrant les conteneurs depuis le code. Le concept est repris de la librairie Testcontainers, en Java, qui permet de facilement démarrer des conteneurs Docker depuis le code.
Si vous ne connaissez pas cette librairie, il y a ce très bon article qui détaille son fonctionnement : 
https://www.liksi.tech/2020/11/12/isolez-vos-tests-sans-compromis-avec-testcontainers/

Pour les besoins de l’article, j’utilise une simple application .NET Core en mode console qui communique avec une base MongoDB.

Pré-requis

Dans le cadre de cet article, j’ai utilisé une machine sous windows avec une instance Docker dessus. En ce qui concerne l’implémentation dans le code, j’ai choisi une librairie pour communiquer avec Docker, Docker.DotNet, et le driver .NET pour communiquer avec MongoDB

Contraintes

Il est important de définir des contraintes pour l’implémentation, celles-ci permettent d’avoir un cadre. Pour cela, je me suis basé sur le principe FIRST (Fast, Independant, Repeatable, Self-Validating, Timely) :

  • Les tests doivent êtres indépendants
  • S’il n’y a pas d’instance Docker alors les tests sont ignorés.
  • Les tests doivent s’exécuter relativement rapidement, en moins d’une minute.
  • L’implémentation doit rester la plus simple possible
  • L’écriture des tests doit être rapide
  • Les tests ne doivent pas laisser de trace sur l’environnement et être reproductibles
  • Il doit être possible de rajouter des dépendances Docker facilement

Déroulé

Le processus de test s’effectue en trois étapes :

  1. mise en place de l’environnement local.
  2. exécution des tests.
  3. nettoyage de l’environnement local.

Implémentation

Vérification de l’instance docker

Afin de vérifier que la machine contient une instance Docker, j’ai choisi de créer un nouvel attribut xUnit.

L’attribut « FactIT » étend la classe FactAttribute, celle-ci contient une propriété Skip qui nous permet d’ignorer des tests sous certaines conditions.
Dans notre cas, si l’hôte ne contient pas d’instance Docker alors la propriété Skip est valorisée et les tests décorés par cet attribut sont ignorés par le framework xUnit.

Pour savoir si il y a une instance Docker locale, j’ai choisi d’exécuter une commande « List » via la librairie Docker.DotNet. Il est nécessaire de faire au moins une commande, car la simple création du client Docker.DotNet ne rend pas compte si l’instance Docker existe.

public class FactIT : FactAttribute
{
    public FactIT()
    {
        if(!HasDocker())
        {
            Skip = "Docker instance not found";
        }
    }

    private bool HasDocker()
    {
        try
        {
            DockerClient dockerClient = new DockerClientConfiguration().CreateClient();
            var containers = dockerClient.Containers.ListContainersAsync(new ContainersListParameters { Limit = 10 }).Result;
            Console.WriteLine("Local environment has a docker instance");
            return true;
        }
        catch (Exception e)
        {                
            Console.WriteLine(e);
            return false;
        }
    }
}

Récupération de l’image et démarrage du conteneur

Pour initier les différents conteneurs Docker, j’ai procédé en plusieurs étapes.

  1. Déclaration des images nécessaires pour les tests
  2. Récupération des images Docker
  3. Déclaration des conteneurs avec leur image source
  4. Démarrage des conteneurs

Déclaration des dépendances

Afin de déclarer les conteneurs, j’ai créé une nouvelle classe de paramétrage DockerImageModel. Elle contient quelques propriétés permettant la récupération, la création et le démarrage des conteneurs.

  • Name  nom du conteneur
  • Tag  version de l’image
  • SourceName  nom de l’image sur le hub docker
  • PortBindings  description des ports du conteneur

Les méthodes ToImageCreateParameter et ToCreateContainerParameters permettent de créer directement les objets nécessaires pour la récupération des images et la création des conteneurs.

public class DockerImageModel
{
    public string Name { get; set; }
    public string Tag { get; set; }
    public string SourceName { get; set; }
    public Dictionary<string, string> PortBindings { get; set; }

    public ImagesCreateParameters ToImageCreateParameter()
    {
        return new ImagesCreateParameters { FromImage = this.SourceName, Tag = this.Tag };
    }

    public CreateContainerParameters ToCreateContainerParameters()
    {
        var openPorts = new Dictionary<string, EmptyStruct>();
        var hostConfig = new HostConfig();

        if (PortBindings != null && PortBindings.Count > 0)
        {
            var hostMapping = new Dictionary<string, IList<PortBinding>>();

            PortBindings.Keys.ToList().ForEach(keyPort =>
            {
                openPorts.Add(PortBindings[keyPort], new EmptyStruct());
                hostMapping.Add(PortBindings[keyPort], new List<PortBinding> { new PortBinding { HostIP = "127.0.0.1", HostPort = keyPort } });
            });

            hostConfig = new HostConfig { PortBindings = hostMapping };
        }

        return new CreateContainerParameters
        {
            Image = $"{this.SourceName}:{this.Tag}",
            Name = this.Name,
            HostConfig = hostConfig,
            ExposedPorts = openPorts
        };
    }            
}

Fixture

Les Fixture, sous xUnit, permettent une déclaration de Setup/CleanUp partagées pour tous les tests d’une même classe. Les Fixtures peuvent être partagées pour plusieurs classes de test via une collection xUnit.

Pour initialiser les dépendances sous Docker j’ai donc déclaré une classe DockerFixture qui sera utilisée dans une collection xUnit.
Cette classe implémente IDisposable pour des raisons que nous verrons plus tard.

Elle instancie une liste de DockerModel pour la déclaration des conteneurs et un objet de type DockerClient pour permettre de communiquer avec l’instance Docker locale. Le type DockerClient provient de la librairie Docker.DotNet.

public class DockerFixture : IDisposable
{
    private readonly DockerClient dockerClient;

    private readonly List<DockerImageModel> images = new List<DockerImageModel>
    {
        new DockerImageModel
        {
            Name = "mongodb-test-it", SourceName = "mongo", Tag = "4.2", PortBindings = new Dictionary<string, string> { { "27017", "27017" } }
        }
    };

    public DockerFixture()
    {
        dockerClient = new DockerClientConfiguration().CreateClient();          
    }
}

Démarrer les conteneurs

Ensuite, nous pouvons démarrer les conteneurs.
Dans le code suivant, vous pouvez observer la récupération d’une image puis la création d’un conteneur et enfin son démarrage. Cette méthode est appelée depuis le constructeur de la classe DockerFixture.

public class DockerFixture : IDisposable
{
    public DockerFixture()
    {
        images.ForEach(StartContainerFromImage); 
    }

    private void StartContainerFromImage(DockerImageModel image)
    {
        var containers = dockerClient.Containers
            .ListContainersAsync(new ContainersListParameters { All = true })
            .GetAwaiter()
            .GetResult();
        if (!containers.Any(container => container.Names
                .Select(name => name.Replace("/", ""))
                .Contains(image.Name)))
        {
            // création de l’image
            dockerClient.Images
                .CreateImageAsync(image.ToImageCreateParameter(), null, GetProgressHandler())
                .GetAwaiter()
                .GetResult();
            // création du conteneur
            dockerClient.Containers
                .CreateContainerAsync(image.ToCreateContainerParameters())
                .GetAwaiter()
                .GetResult();
            // démarrage du conteneur
            dockerClient.Containers.StartContainerAsync(image.Name, null)
                .GetAwaiter()
                .GetResult();
        }
    }
}

La méthode suivante permet d’afficher l’évolution de la récupération d’une image docker

private Progress<JSONMessage> GetProgressHandler()
{
    return new Progress<JSONMessage>((m) =>
    {
         Console.WriteLine(JsonConvert.SerializeObject(m));
    });
}

Nettoyage de l’environnement

Toujours dans la même classe, DockerFixture, la méthode Dispose(), implémentation de l’interface IDisposable, contient du code pour le nettoyage de l’environnement.
En effet, une fois tous les tests d’une même collection terminés, le code présent dans la méthode Dispose est appelé par le framework de test xUnit.
Le code appelle l’instance Docker locale en faisant une commande stop puis remove sur chaque conteneur.

public class DockerFixture : IDisposable
{
    public void Dispose()
    {
        images.ForEach(image =>
        {
            dockerClient.Containers
                .StopContainerAsync(
                    image.Name,
                    new ContainerStopParameters { WaitBeforeKillSeconds = 5 },
                    CancellationToken.None)
                .GetAwaiter().GetResult();
    
            dockerClient.Containers
                .RemoveContainerAsync(
                    image.Name,
                    new ContainerRemoveParameters { Force = true },
                    CancellationToken.None)
                .GetAwaiter().GetResult();
        });
    }
}

Utilisation d’une collection xUnit 

Tous les tests sont déclarés dans une collection xUnit. Les collections sont nécessaires car elles nous permettent de lier une classe de test au code de préparation DockerFixture.

[CollectionDefinition(DockerCollection.Name)]
public class DockerCollection : ICollectionFixture<DockerFixture>
{
    public const string Name = "Docker-Collection";
}

Les collections s’utilisent avec un attribut au-dessus des classes de tests.
A noter que tous les tests déclarés dans une même collection sont exécutés à la suite et ne peuvent pas être exécutés en parrallèle.

[Collection(DockerCollection.Name)]
public class UserRepositoryTests

Exécution des tests 

Code à tester

Le code que nous allons tester est une classe permettant d’intéragir avec une source de données d’utilisateurs.

On utilise une classe User pour décrire le modèle de données

public class User
{
    [BsonId]
    public Guid Guid { get; set; }

    [BsonElement("firstName")]
    public string FirstName { get; set; }

    [BsonElement("lastName")]
    public string LastName { get; set; }
}

La classe UserRepository implémente deux méthodes :

  • Une méthode d’insertion InsertAsync
  • Une méthode de récupération par Guid GetAsync

Elle utilise le driver MongoDB pour se connecter et déclare une collection « user ».

public class UserRepository
{
    protected readonly IMongoCollection<User> collection;
 
    public UserRepository(IOptions<MongoSetting> setting)
    {
        var client = new MongoClient(new MongoClientSettings { Server = new MongoServerAddress(setting.Value.Host, setting.Value.Port) });
        var database = client.GetDatabase(setting.Value.DatabaseName);
        collection = database.GetCollection<User>("user");
    }
 
    public async Task InsertAsync(User entity)
    {
        await collection.InsertOneAsync(entity);
    }
 
    public async Task<User> GetAsync(Guid guid)
    {
        return (await collection.FindAsync(Builders<User>.Filter.Eq(x => x.Guid, guid))).FirstOrDefault();
    }
}

Tester un repository

J’ai choisi d’implémenter un simple test d’exemple pour la classe UserRepository. Il permet de vérifier l’insertion et la récupération d’un utilisateur.

[Collection(DockerCollection.Name)]
public class UserRepositoryTests
{
    private readonly UserRepository userRepository;

    public UserRepositoryTests()
    {
        userRepository = new UserRepository(Options.Create(new MongoSetting { Host = "localhost", Port = 27017, DatabaseName = "user" }));
    }

    [Fact]
    public async Task Should_InsertAsync()
    {
        var userGuid = Guid.NewGuid();
        await userRepository.InsertAsync(new User { Guid = userGuid, FirstName = "foo", LastName = "bar" });

        var user = await userRepository.GetAsync(userGuid);
        Assert.NotNull(user);
        Assert.Equal("foo", user.FirstName);
        Assert.Equal("bar", user.LastName);
    }
}

Compte-rendu d’exécution

Voici un compte rendu d’exécution des tests.
L’exécution complète du processus prend 11,8 secondes. Ce résultat comprend le démarrage des conteneur, l’exécution des tests et le nettoyage.

A savoir que je dispose déjà de l’image MongoDB sur mon instance Docker. Le temps de téléchargement de l’image est dépendant de la connexion internet de la machine et peut être plus ou moins long.

[xUnit.net 00:00:02.13]   Discovered:  user.IT
========== Fin de la découverte de tests : 1 tests trouvés en 4 s ==========
Démarrage de la série de tests
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.4.3+1b45f5407b (64-bit .NET 5.0.14)
[xUnit.net 00:00:00.80]   Starting:    user.IT
[xUnit.net 00:00:11.82]   Finished:    user.IT
========== Série de tests achevée : 1 tests (1 réussi(s), 0 non réussi(s), 0 ignoré(s)) exécutés en 11,8 s ==========

Conclusion

Pour conclure, utiliser une instance Docker pour des tests d’intégration est tout à fait possible en .NET, on a donc démontré l’hypothèse de départ.

Le code utilise une instance Docker et démarre les différentes dépendances. Les tests sont reproductibles, il est simple d’ajouter des dépendances et aussi d’autres cas de tests. Enfin, l’exécution des tests est rapide.

Cependant, il y a plusieurs points que l’on peut noter :

  • Le code de préparation est assez volumineux et complexe
  • Les tests s’exécutent de manière successive, pas de parallélisation possible
  • Il est compliqué d’exporter l’approche utilisée pour une CI, par exemple sur Gitlab les conteneurs ne sont pas démarrés dans le même Network et ne peuvent être contactés.

Pour finir, on remarque aujourd’hui que la philosphie apportée par Testcontainers est très populaire en ce moment. C’est notamment grâce à Docker qui nous permet de facilement construire un environnement isolé.

J’ajoute qu’il existe une implémentation en .NET de la librairie Testcontainers appelée Dotnet-Testcontainers. Elle prend, cependant, certaines libertés par rapport à la version Java et elle n’est pas développée par les mêmes personnes. Cette librairie pourrait nous permettre de simplifier la déclaration des conteneurs et pouvoir exécuter les tests en parallèles.

Liens

Lien de la librairie Dotnet-Testcontainers https://github.com/HofmeisterAn/dotnet-testcontainers Le code utilisé pour l’article est disponible ici https://github.com/code-fab/article-test-integration Voici les liens pour récupérer les différents outils