Blog Arolla

Tester un repository Azure Cosmos DB

Les repositories sont un composant important de toute application mettant en jeu la persistance de données. Ils embarquent souvent des requêtes complexes, et pourtant ils font rarement l’objet de tests. Un test de repository doit s’adresser à une vraie base de données et devient donc un test d’intégration. Il faut placer la base dans un état connu, et postérieurement faire le ménage. Je montre ici comment le faire avec Cosmos DB. Nous verrons ensuite comment exécuter ces tests en intégration continue dans un pipeline Azure Devops.

Présentation d’Azure Cosmos DB

Cosmos DB est une base de données NoSql disponible sur le cloud Azure.
Il s’agit d’une base PaaS (Platform as a Service) : vous n’avez pas à gérer de serveurs. Si on dispose d’une souscription Azure, il suffit de quelques clics pour créer un compte Cosmos DB prêt à l’emploi. Il est aussi possible de créer une souscription Azure d’essai qui donne un accès limité à Cosmos DB.
Cosmos DB est multi-API. Chaque API définit une organisation des données. Les API disponibles sont : Core (SQL), MongoDB, Gremlin (Graphe), Cassandra, et Table.
Nous utiliserons l’API Core, aussi appelée SQL API, qui permet d’accéder aux documents avec un langage inspiré de SQL. Un compte Cosmos DB contient une ou plusieurs bases de données dans lesquelles on peut créer des collections. Les données sont enregistrées sous forme de documents json dans les collections.

Préparation de l’environnement

SDK Dotnet Core

Le code est écrit en C# et s’exécute sous .NET Core 2.2, il fonctionnera donc sur Windows, MacOS et de nombreuses distributions Linux. Installez le SDK .NET Core si vous ne l’avez pas déjà fait. Le code est transposable à d’autres plateformes pour lesquelles le client SQL API est disponible, notamment Java, Node et Python.

Cosmos DB

Pour la base Cosmos DB, le plus simple est d’utiliser l’émulateur Cosmos DB. Celui-ci peut être installé localement ou dans un conteneur Docker Windows. Les identifiants de connexion inclus dans le code sont ceux de l’émulateur, il n’y a donc pas à les modifier si vous utilisez l’émulateur.
Malheureusement, l’émulateur ne fonctionne que sous Windows. Pour tester avec d’autres OS, créez un compte gratuit sous Azure qui vous donnera accès à Cosmos DB. Pour utiliser une base Cosmos DB dans Azure, remplacez les identifiants dans le fichier appSettings.json par ceux de votre base.

Récupérer le code

Si vous ne souhaitez pas saisir le code en recopiant l’article, vous pouvez le récupérer sur github : https://github.com/ervilho/testCosmosDb

Initialiser le projet

Si vous démarrez from scratch, vous pouvez initialiser le projet en lançant le script init.ps1 ou en tapant les commandes suivantes :

dotnet new xunit --name cosmosDbtests # Crée le projet
cd cosmosDbtests # va dans le dossier du projet

dotnet add package Microsoft.Azure.DocumentDb.Core # Ajoute le SDK CosmosDb
dotnet add package NFluent # Ajoute la librairie d'assertions

# Ajoute les librairies de Configuration dotnet
dotnet add package Microsoft.Extensions.Configuration 
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables

Une fois le projet initialisé, on peut compiler et lancer les tests par la commande dotnet test

Le code à tester

Nous souhaitons tester une classe MeasureRepository qui comporte une méthode GetMeasures. Cette méthode envoie tous les objets Measure présents dans une collection CosmosDb.
Measure représente un type de mesure physique avec un code, un nom et une unité, par exemple { "h", "Hauteur", "m" } pour une hauteur en mètres.

Créer la classe Measure

Ajoutons le fichier Measure.cs avec :

public class Measure
{
  public string Code { get; set; }
  public string Name { get; set; }
  public string Unit { get; set; }
}

Créer le repository

Pour accéder aux données de la collection le repository utilise :
- Une instance de DocumentClient pour effectuer les requêtes
- L’Uri de la collection à utiliser
GetMeasures utilise une requête SQL pour récupérer l’ensemble des mesures présentes dans la collection.

public class MeasureRepository {
  private readonly DocumentClient _documentClient;
  private readonly Uri _documentCollectionUri;

  public MeasureRepository(
    DocumentClient documentClient,
    Uri documentCollectionUri)
  {
    _documentClient = documentClient;
    _documentCollectionUri = documentCollectionUri;
  }

  public async Task<IEnumerable> GetMeasures()
  {
    var query = _documentClient.CreateDocumentQuery(
      _documentCollectionUri,
      "SELECT * FROM c WHERE c.pk = 'measure' ORDER BY c.Code");
    var measures = await query.AsDocumentQuery().ExecuteNextAsync();
    return measures;
  }
}

Écriture du test

La classe de test

Nous allons écrire une classe de test MeasureRepositoryTest pour tester notre méthode.

public class MeasureRepositoryTest : IClassFixture
{
  private readonly CosmosDbFixture _fixture;

  public MeasureRepositoryTest(CosmosDbFixture fixture)
  {
    _fixture = fixture;
  }
}

Pour initialiser la collection avant l’exécution nous allons créer une classe CosmosDbFixture et déclarer que la classe de test implémente IClassFixture. L’interface IClassFixture ne comporte aucune méthode, mais indique à xUnit d’effectuer les opérations suivantes :
- Avant l’exécution des tests, créer une instance de CosmosDbFixture et l’injecter dans le constructeur de MeasureRepositoryTest
- Exécuter les tests
- Après l’exécution des tests, appeler la méthode Dispose de CosmosDbFixture si elle implémente IDisposable.
Dans le constructeur de CosmosDbFixture nous appellerons le code d’initialisation et dans Dispose, le code de nettoyage.
Nous conservons fixture dans un champ privé car elle expose le client Cosmos DB nécessaire pour le repository.
Nous nous retrouvons donc avec quatre classes :
- Measure représente une mesure issue de la base
- MeasureRepository la classe à tester
- MeasureRepositoryTest la classe contenant le test
- CosmosDbFixture qui gère l’initialisation et le nettoyage de la base de données

La classe CosmosDbFixture

CosmosDbFixture va créer et initialiser la collection, et exposer l’Uri de la collection et l’instance de DocumentClient nécessaires au repository.

public class CosmosDbFixture : IDisposable
{
    private readonly string _database;
    private readonly string _collection;

    public CosmosDbFixture()
    {
        _database = "test";
        _collection = $"test_{Guid.NewGuid()}";
        DocumentCollectionUri = UriFactory.CreateDocumentCollectionUri(_database, _collection);

        DocumentClient = GetDocumentClient();
        CreateDatabaseAsync().Wait();
        CreateCollectionAsync().Wait();
        PopulateCollectionAsync().Wait();
    }

    public Uri DocumentCollectionUri { get; }

    public void Dispose()
    {
        DeleteCollectionAsync().Wait();
        DocumentClient.Dispose();
    }
}

Le rôle des méthodes est le suivant :
- GetDocumentClient : lit la configuration dans appSettings.json et instancie DocumentClient
- CreateDatabaseAsync : crée la base de données _database si elle n’existe pas encore
- CreateCollectionAsync : crée la collection _collection
- PopulateCollectionAsync : lit le fichier measures.json et écrit les documents dans la collection
- DeleteCollectionAsync : supprime la collection _collection
- Dispose : appelle DeleteCollectionAsync et dispose DocumentClient

GetDocumentClient

Cette méthode instancie l’objet DocumentClient qui est notre point d’accès à Cosmos DB. Il sera exposé en tant que propriété par CosmosDbFixture et injecté dans le repository. Il permet aussi de créer la base et les collections dans les méthodes suivantes.

    private DocumentClient GetDocumentClient()
    {
        var builder = new ConfigurationBuilder()
            .AddJsonFile("appSettings.json")
            .AddEnvironmentVariables();
        var configuration = builder.Build();

        return new DocumentClient(new Uri(
             configuration["cosmosDbEndpoint"]),
             configuration["cosmosDbAuthKey"]);
    }

Dans cette méthode nous créons d’abord un objet builder qui permet de récupérer la configuration à partir du fichier appSettings.json et des variables d’environnement. Si une variable d’environnement est présente sa valeur écrasera celle du fichier de configuration. Ceci va nous servir dans le cadre de l’intégration continue.
Une fois l’objet configuration obtenu, nous créons un DocumentClient à partir des propriétés cosmosDbEndpoint et cosmosDbAuthKey de la configuration.

CreateDatabaseAsync
Cette méthode crée la base Cosmos DB si elle n’existe pas. Nous appelons simplement la méthode CreateDatabaseIfNotExistsAsync du DocumentClient. La ligne « Check » est une assertion de la librairie NFluent qui vérifie que le résultat de la méthode est un code de succès (OK si la base existait déjà, Created si elle a été créée)

    private async Task CreateDatabaseAsync()
    {
        var database = new Database { Id = _database };
        var response = await DocumentClient.CreateDatabaseIfNotExistsAsync(database);
        Check.That(new[] { response.StatusCode }).IsOnlyMadeOf(HttpStatusCode.OK, HttpStatusCode.Created);
    }

CreateCollectionAsync

Cette méthode crée la collection. Elle est un peu plus complexe que les précédentes car la collection nécessite de nombreux paramètres.

    private async Task CreateCollectionAsync()
    {
        var databaseUri = UriFactory.CreateDatabaseUri(_database);

        var pks = new Collection(new[] { "/pk" });
        var partitionKeyDefinition = new PartitionKeyDefinition() { Paths = pks };
        var indexingPolicy = new IndexingPolicy(new Index[]
        {
                new RangeIndex(DataType.Number, -1),
                new RangeIndex(DataType.String, -1),
        });
        var collection = new DocumentCollection()
        {
            Id = _collection,
            PartitionKey = partitionKeyDefinition,
            IndexingPolicy = indexingPolicy
        };
        var options = new RequestOptions() { OfferThroughput = 400 };
        var response = await DocumentClient.CreateDocumentCollectionAsync(
                                         databaseUri, collection, options);
        var code = response.StatusCode;
        Check.That(code).IsEqualTo(HttpStatusCode.Created);
    }

partitionKeyDefinition définit la clé de partition pour la collection. Les collections Cosmos DB n’ont pas de limite de taille, mais le moteur les partitionne en partitions de 10 Go maximum. Ici nous déclarons que le partitionnement doit se faire sur la propriété « pk » des documents. Tous les documents qui ont la même valeur pour la propriété « pk » seront donc placés dans la même partition.
indexingPolicy définit la stratégie d’indexation des données pour la collection. Ici nous déclarons que les propriétés de type Number et String doivent être indexées en Range et sans limite de précision (-1) afin de permettre des recherches par des clauses WHERE avec des comparaisons, comme par exemple SELECT * FROM c WHERE c.Name > ‘J’. Cette requête échouerait si String n’était pas un RangeIndex.
Nous créons ensuite un objet DocumentCollection qui déclare le nom de la collection (Id), la clé de partitionnement et la stratégie d’indexation.
Options déclare la capacité de la base : OfferThroughput est le nombre d’unités de requête (RUs) qui peuvent être traitées simultanément. Une unité de requête correspond à 1 ko en lecture et 0,2 ko en écriture. 400 est le nombre minimum de RUs possibles pour une collection, et on peut monter jusqu’à plusieurs centaines de milliers... mais les RUs servent aussi d’unité de facturation !
Finalement nous appelons CreateDocumentCollectionAsync avec tous les objets créés précédemment pour réaliser la création de la collection.
La dernière ligne vérifie que la création a réussi.

ReadJsonFile

Cette méthode lit le fichier measures.json qui contient les données à insérer dans la collection.

    private JArray ReadJsonFile()
    {
        var serializer = new JsonSerializer();

        using (var sr = new StreamReader("./measures.json"))
        {
            using (var jsonTextReader = new JsonTextReader(sr))
            {
                return (JArray)serializer.Deserialize(jsonTextReader);
            }
        }
    }

PopulateCollectionAsync

PopulateCollectionAsync appelle ReadJsonFile pour récupérer les données et les insère dans la collection.

        private async Task PopulateCollectionAsync()
        {
            var documents = ReadJsonFile();

            foreach (JObject document in documents)
            {
                var options = new RequestOptions() { 
                PartitionKey = new PartitionKey((string)document.GetValue("pk")) };
                await DocumentClient.CreateDocumentAsync(
                         DocumentCollectionUri, document, options);
            }
        }

D’abord nous appelons ReadJsonFile pour récupérer la liste des documents sous la forme d’un tableau Json.
Nous itérons ensuite sur les documents pour les créer un par un. Pour chaque document, donc, nous créons un objet PartitionKey qui indique dans quelle partition le document doit être inséré. Cet objet est créé à partir de la propriété « pk » de notre document. Ensuite il n’y a plus qu’à appeler CreateDocumentAsync avec l’Uri de la collection, le document lui-même et les options.

Dispose et DeleteCollectionAsync

Ces méthodes servent au nettoyage après l’exécution des tests. Dispose est appelé par xUnit lorsque tous les tests ont été exécutés.

    public void Dispose()
    {
        DeleteCollectionAsync().Wait();
        DocumentClient.Dispose();
    }

    private async Task DeleteCollectionAsync()
    {
        var response = await DocumentClient.DeleteDocumentCollectionAsync(DocumentCollectionUri);
        Check.That(response.StatusCode).Equals(HttpStatusCode.NoContent);
    }

Dispose appelle DeleteCollectionAsync() qui supprime la collection que nous avons créée, puis DocumentClient.Dispose pour libérer les ressources retenues par DocumentClient.
DeleteCollectionAsync appelle simplement DeleteDocumentCollectionAsync pour supprimer la collection, puis vérifie le résultat.

Le corps du test

Finalement, nous pouvons écrire le test lui-même. Nous instancions le repository, appelons la méthode et vérifions le résultat.

    [Fact]
    public async Task GetMeasuresTest()
    {
        // Arrange
        var expectedMeasures = new[] {
                new Measure{ Code = "I", Name= "Current", Unit="A" },
                new Measure{ Code = "U", Name= "Voltage", Unit="V" }
            };

        var subject = new MeasureRepository(
            _fixture.DocumentClient,
            _fixture.Database,
            _fixture.Collection);

        // Act
        var actual = (await subject.GetMeasures()).ToArray();

        // Assert
        Check.That(actual).HasSize(expectedMeasures.Length);
        for (int i = 0; i < expectedMeasures.Length; i++)
        {
            Check.That(actual[i]).HasFieldsWithSameValues(expectedMeasures[i]);
        }
    }

Finalement, nous pouvons exécuter le test par dotnet test et il passe !

Intégration continue avec Azure Devops

Maintenant que nous avons un test qui passe en local, il s’agit de le faire passer en intégration continue. Azure Pipelines, le composant « Build & Release » d’Azure DevOps, nous permet de créer facilement un pipeline d’intégration continue pour un projet dotnet core, qui va compiler le code et exécuter les tests.
Pour la base de données nous avons deux choix :
- Utiliser l’émulateur dans le cadre du build d’intégration continue
- Utiliser une véritable base Cosmos DB
Utiliser l’émulateur dans le build d’intégration continue
Il existe une extension qui permet d’utiliser l’émulateur dans un build. L’extension permet d’ajouter la tâche « Run Azure Cosmos DB Emulator container » dans votre build. Comme l’émulateur a toujours les mêmes endpoint et authkey, il n’est même pas nécessaire de modifier la configuration.
Cependant :
- Comme l’émulateur ne fonctionne que sous Windows, le build doit forcément s’exécuter sur un agent Windows, ou un agent « Windows Container »
- Sur un agent « hosted », l’initialisation de l’émulateur est très longue, et même tombe en timeout. Il vaudrait donc mieux réserver ce cas à l’utilisation d’un agent privé.
- La tâche est en preview et n’est pas définitive.
Utiliser une vraie base Cosmos DB
Au lieu d’utiliser l’émulateur, on peut créer un compte Cosmos DB dédié à l’intégration continue. Comme en local, le test créera une base dans le compte si elle n’existe pas, puis des collections dans la base.
En fin de test, les collections créées sont supprimées par la méthode Dispose de CosmosDbFixture.
Pour mettre en œuvre cette solution :
- Créez un compte Azure Cosmos DB, notez-le endpoint et la clé.
- Dans Azure Devops, créez un pipeline de build avec le template « ASP.NET Core ». On obtient ceci :

  • Cliquez sur Variables et ajoutez les variables cosmosDbEndpoint et cosmosDbAuthKey avec pour valeur l’endpoint et la clé de votre compte Cosmos DB.

  • Exécutez le build. Durant l’exécution, les variables du pipeline sont accessibles en tant que variables d’environnement. CosmosDbFixture va utiliser ces variables d’environnement en priorité sur les valeurs définies dans le fichier appSettings.json, et le test passe.

Conclusion

Cette méthode nous permet d’écrire et d’exécuter des tests automatisés en isolation sur les repositories Cosmos DB. Nous pouvons ainsi valider que nos requêtes sont correctes et assurer la non-régression.
La méthode est transposable à d’autres bases de données, à partir du moment où on est capable d’initialiser les tables assez rapidement pour pouvoir exécuter les tests.
Donc, n’hésitez plus et testez vos repositories !

Plus de publications

Comments are closed.