Construire un cadre de tests Pester PowerShell évolutif

Si vous vous retrouvez à passer plus de temps à maintenir vos tests Pester qu’à en créer de nouveaux, cet article est pour vous. Dans cet article, je vais partager un projet sur lequel j’ai travaillé avec Devolutions.

Contexte

Nous avions un problème chez Devolutions. Le module PowerShell phare Devolutions.PowerShell manquait de tests Pester. Je sais, je sais. Ils avaient des tests en C#, mais c’est une autre histoire.

En tant que consultant pour Devolutions axé sur PowerShell, on m’a demandé de créer une suite de tests Pester à utiliser dans le pipeline CI/CD et à exécuter avant le déploiement en production. Pas de problème, pensais-je. J’ai utilisé le module quelques fois, et cela ne pouvait pas être si difficile. J’avais tort.

2025-01-29_09-27-59.png

Sans demande spécifique, juste « créez quelques tests », je me suis mis à construire des tests pour l’ensemble du module, seulement pour découvrir qu’il avait près de 500 commandes ! Cela allait prendre du temps.

Comme j’ai toujours voulu construire des solutions non seulement pour aujourd’hui mais pour le long terme, je ne voulais pas empiler une multitude de tests dans un seul script et en rester là. Ce script serait énorme !

Au lieu de cela, avant d’écrire un seul test, je voulais concevoir un cadre que je pourrais utiliser qui :

  1. Soit évolutif – Moi-même ou d’autres pourraient facilement ajouter des tests au cadre sans trop réfléchir.
  2. Pour éviter la duplication de code, ce projet doit être DRY (ne vous répétez pas). Je ne voulais pas dupliquer de code, donc j’ai plutôt utilisé des scripts partagés, des fonctions, etc.
  3. Pour utiliser des tests basés sur les données : Les tests basés sur les données favorisent la réutilisation du code en définissant des tests dans une structure de données, puis en faisant référence à cette structure au lieu de dupliquer le code de test pour chaque test.
  4. Être modulaire – Tous les tests doivent être divisés en une structure qui empêcherait de faire défiler du code inutile pour trouver ce que vous recherchez.
  5. Pour supporter les scénarios de configuration et de démontage de l’environnement – Ces tests sont des tests de bout en bout (E2E)/intégration/acceptation, peu importe comment vous les appelez. Ils testent l’utilisation par l’utilisateur final ; il n’y a pas de tests unitaires, donc ils doivent avoir diverses choses en place dans l’environnement pour s’exécuter.
  6. Pour supporter l’exécution de groupes de tests sans les exécuter tous en même temps.

Ces exigences mènent au cadre de test Pester pour Devolutions.

Prérequis

Si vous souhaitez utiliser ce cadre, je ne peux pas garantir qu’il fonctionnera à 100 % à moins que vous ne travailliez dans le même environnement que moi. Pour utiliser ce cadre, j’ai utilisé :

  • PowerShell v7.4.5 sur Windows
  • Pester 5.6.1

Cela fonctionnera toujours sur Windows PowerShell et les versions antérieures de Pester v5, mais il n’y a aucune garantie !

Aperçu du cadre

Ce cadre se compose de quatre composants : un script d’appelant, divers scripts de définition de tests, un script optionnel pour stocker des fonctions d’assistance et des scripts optionnels before all / before each. Chacun de ces composants est structuré comme ceci dans le système de fichiers :

📁 root/
   📄 caller.tests.ps1
   📄 helper_functions.ps1
   📁 test_definitions/
      📁 group1/
         📄 beforeall.tests.ps1
         📄 beforeeach.tests.ps1
         📄 core.tests.ps1
         📄 subgroup.tests.ps1

Lorsque vous invoquez le script d’appelant :

Invoke-Pester -Path root/caller.tests.ps1

Le script d’appelant :

  1. Trouve tous les scripts de définition de test dans chaque groupe.
  2. Trouve tous les scripts beforeeach et beforeall pour chaque groupe.
  3. Crée des contextes Pester pour chaque groupe et sous-groupe.
    contexte 'groupe' {
        contexte 'sous-groupe' {
    
        }
    }
  4. Invoque le script beforeall une fois avant que tout test ne s’exécute dans un groupe.
  5. Invoque le script beforeeach avant chaque test dans le groupe.
  6. Enfin, il invoque les assertions de test définies dans chaque définition de test.

Le script de test appelant

Le script appelant est le point d’invocation de Pester. C’est le script que Invoke-Pester appelle lorsque vous devez invoquer des tests dans le cadre.

Le script appelant est divisé en X zones :

Le bloc BeforeDiscovery

Une tâche du script appelant est de trouver toutes les définitions de test et à quoi sert la phase de découverte de Pester. À l’intérieur du bloc BeforeDiscovery du script, les définitions de test sont rassemblées, ainsi que tout script before each ou before all.

BeforeDiscovery {

    # Initialize hashtable to store all test definitions
    $tests = @{}

    # Iterate through test group directories (e.g. datasources, entries)
    Get-ChildItem -Path "$PSScriptRoot\test_definitions" -Directory -PipelineVariable testGroupDir | ForEach-Object {
        $testGroup = $testGroupDir.BaseName
        $tests[$testGroup] = @{}

        # Load beforeall script for test group if it exists
        # This script runs once before all tests in the group
        $testGroupBeforeAllScriptPath = Join-Path -Path $testGroupDir.FullName -ChildPath 'beforeall.ps1'
        if (Test-Path -Path $testGroupBeforeAllScriptPath) {
            $tests[$testGroup]['beforeall'] += . $testGroupBeforeAllScriptPath
        }

        # Load beforeeach script for test group if it exists
        # This script runs before each individual test in the group
        $testGroupBeforeEachScriptPath = Join-Path -Path $testGroupDir.FullName -ChildPath 'beforeeach.ps1'
        if (Test-Path -Path $testGroupBeforeEachScriptPath) {
            $tests[$testGroup]['beforeeach'] += . $testGroupBeforeEachScriptPath
        }

        # Load all test definition files in the group directory
        Get-ChildItem -Path $testGroupDir.FullName -Filter '*.ps1' -PipelineVariable testDefinitionFile | ForEach-Object {
            $tests[$testGroup][$testDefinitionFile.BaseName] = @()
            $tests[$testGroup][$testDefinitionFile.BaseName] += . $testDefinitionFile.FullName
        }
    }
}

Le bloc BeforeAll

Le bloc BeforeAll s’exécute pour rendre toutes les fonctions d’aide disponibles pour le script appelant ou les définitions de test. Dans Pester v5, cette tâche ne peut pas être dans BeforeDiscovery ; sinon, elle ne serait pas disponible pour les tests.

# Load helper functions used across all tests
BeforeAll {
    . (Join-Path $PSScriptRoot -ChildPath "_helper_functions.ps1")
}

Le bloc Describe et Contexts

Chaque configuration d’environnement dont vous avez besoin pour exécuter des tests est divisée en contextes avec des groupes de tests sous chacun d’eux.

Pour le module PowerShell de Devolutions, comme nous devons tester des cmdlets contre de nombreux types de sources de données, j’ai créé des contextes par source de données, mais vous pouvez utiliser n’importe quoi ici. Ensuite, en utilisant le paramètre ForEach de Pester, chaque dossier de définition de test est un contexte, tout comme chaque sous-groupe. Ensuite, chaque test est défini en dessous sous forme de blocs it.


Remarquez la méthode where() sur le bloc it. Where({ $_.environments -contains 'xxx' -or $_.environments.count -eq 0}). C’est ici que la structure de chaque définition entre en jeu. C’est à ce stade que nous désignons quels tests s’exécutent dans quel environnement.


# Main test container for all tests
Describe 'RDM' {

    # Tests that run against an environment
    Context 'Environment1' -Tag 'Environment1' {

        BeforeEach {
            ## Do stuff to execute before each test in this content. For example, this is used to set up a specific data source for Devolutions Remote Desktop Manager
        }

        # Clean up
        AfterAll {

        }

        # Run each test group against the environment
        Context '<_>' -ForEach $tests.Keys -Tag ($_) {
            $testGroup = $_

            # Run each test subgroup (core, properties, etc)
            Context '<_>' -ForEach $tests[$testGroup].Keys -Tag ($_) {
                $testSubGroup = $_

                # Run tests marked for this environment or all
                It '<name>' -ForEach ($tests[$testGroup][$testSubGroup]).Where({ $_.environments -contains 'xxx' -or $_.environments.count -eq 0}) {
                    & $_.assertion
                }
            }
        }
    }

    ## Other environments. If you have specific configurations some tests must have, you can define them here by creating more context blocks.
}

Définitions des Tests

Ensuite, vous avez la partie la plus essentielle : les tests ! Les tests sont créés dans des sous-groupes (subgroup.tests.ps1) à l’intérieur de chaque dossier de groupe et doivent être créés sous forme de tableau de tables de hachage avec la structure suivante :

@(
    @{
        'name' = 'creates a thing'
        'environments' = @() ## Nothing means all environments
        'assertion' = {
            ## Teating something
            $true | should -Be $true
        }
    }
)

Ici, vous pouvez définir les environnements dans le script appelant pour exécuter les scripts. Par exemple, si vous avez un environnement pour chaque source de données que j’ai utilisée pour Devolutions RDM, mes environnements sont xml, sqllite, etc.

Fonctions d’Aide

Enfin, nous avons le script des fonctions d’aide. Ce script contient des fonctions que nous incluons dans le bloc BeforeAll dans le script appelant. C’est ici que vous pouvez mettre toutes les fonctions que vous allez réutiliser. Dans mon exemple, j’ai des fonctions pour configurer des sources de données et les supprimer toutes.

# Helper function to remove all entries from the current data source
# Used for cleanup in test scenarios
function Remove-AllEntries {
    try {
        # Get all entries and their IDs
        # Using ErrorAction SilentlyContinue to handle case where no entries exist
        $entries = @(Get-RDMEntry -ErrorAction SilentlyContinue)

        # If no entries found, just return silently
        # No cleanup needed in this case
        if ($entries.Count -eq 0) {
            return
        }

        # Delete entries one at a time
        # Using foreach loop to handle errors for individual entries
        foreach ($entry in $entries) {
            try {
                # Remove entry and refresh to ensure UI is updated
                Remove-RDMEntry -ID $entry.ID -Refresh -ErrorAction Stop
            } catch [System.Management.Automation.ItemNotFoundException] {
                # Silently ignore if entry is already gone
                # This can happen if entry was deleted by another process
                continue
            }
        }
    } catch {
        # Only warn about unexpected errors
        # Ignore "Connection not found" as this is expected in some cases
        if ($_.Exception.Message -ne 'Connection not found.') {
            Write-Warning "Error during cleanup: $_"
        }
    }
}

# Helper function to remove all data sources and their files
# Used for cleanup in test scenarios
function Remove-AllDatasources {
    # Remove any data sources currently loaded in memory
    # This ensures clean state for tests
    Get-RDMDataSource | ForEach-Object { Remove-RDMDataSource -DataSource $_ }

    # Delete any existing data source folders on disk
    # These are identified by GUIDs in the RDM application data folder
    Get-ChildItem $env:LOCALAPPDATA\Devolutions\RemoteDesktopManager | 
        Where-Object { $_.Name -match '^[0-9a-fA-F\-]{36}
}

Comment Construire Votre Cadre

Ce framework semble-t-il utile? Pensez-vous qu’il serait bénéfique pour votre organisation? Si oui, voici quelques conseils et questions à prendre en compte.

  1. Combien de tests prévoyez-vous d’avoir besoin? Votre cas d’utilisation est-il suffisant pour soutenir ce framework au lieu de simplement quelques scripts de test?
  2. Avant d’écrire du code PowerShell, décrivez comment vous prévoyez de définir des groupes et des sous-groupes. Faites-en une abstraction logique des éléments que vous testez.
  3. Pensez aux environnements et à la configuration dont vous auriez besoin pour chaque ensemble de tests; créez-les en tant que contextes dans le script appelant.
  4. En construisant les définitions de test, si vous vous retrouvez à répéter du code, créez une fonction pour cela et placez-la dans helper_functions.ps1.

La construction d’un framework de test Pester évolutif nécessite une planification et une organisation minutieuses, mais les avantages en valent la peine. En suivant la structure décrite dans cet article – avec sa conception modulaire, ses composants réutilisables et sa gestion d’environnement flexible – vous pouvez créer une solution de test robuste qui évolue avec vos besoins. Que vous testiez des modules PowerShell, des configurations d’infrastructure ou des applications complexes, ce framework fournit une base solide pour maintenir la qualité du code et la fiabilité dans toute votre organisation.

Source:
https://adamtheautomator.com/powershell-pester-testing-framework/