بناء إطار اختبار PowerShell Pester قابل للتوسيع

إذا وجدت نفسك تقضي وقتًا أطول في صيانة اختبارات Pester بدلاً من إنشاء اختبارات جديدة، فإن هذه التدوينة موجهة إليك. في هذه التدوينة، سأشارك مشروعًا كنت أعمل عليه مع Devolutions.

القصة الخلفية

كان لدينا مشكلة في Devolutions. كانت الوحدة الرئيسية PowerShell Devolutions.PowerShell تفتقر إلى اختبارات Pester. أعلم، أعلم. كان لديهم اختبارات C# ولكن هذه قصة أخرى.

بصفتي مستشارًا لـ Devolutions يركز على PowerShell، طُلب مني إنشاء مجموعة من اختبارات Pester لاستخدامها في خط أنابيب CI/CD وتشغيلها قبل نشر الإنتاج. لم تكن هناك مشكلة، فكرت. لقد استخدمت الوحدة بضع مرات، ولم يكن من المفترض أن تكون صعبة جدًا. كنت مخطئًا.

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

بدون أي طلب محدد، فقط “ابنِ بعض الاختبارات”، بدأت في بناء اختبارات للوحدة بأكملها، لأكتشف أنها تحتوي على ما يقرب من 500 أمر! كان من الواضح أن هذا سيستغرق بعض الوقت.

نظرًا لأنني كنت دائمًا أرغب في بناء حلول ليست فقط لليوم ولكن على المدى الطويل، لم أرد أن أضع مجموعة من الاختبارات في نص واحد وأعتبر أنني أنجزت الأمر. كان ذلك النص سيكون ضخمًا!

بدلاً من ذلك، قبل كتابة أي اختبار، أردت تصميم إطار عمل يمكنني استخدامه يكون:

  1. قابل للتوسع – يمكنني أو يمكن للآخرين بسهولة إضافة اختبارات إلى الإطار دون الكثير من التفكير.
  2. لمنع تكرار الكود، يحتاج هذا المشروع إلى أن يكون DRY (لا تكرر نفسك). لم أرد تكرار أي كود، لذا استخدمت بدلاً من ذلك نصوصًا مشتركة، وظائف، إلخ.
  3. لاستخدام الاختبارات المعتمدة على البيانات: تعزز الاختبارات المعتمدة على البيانات إعادة استخدام الكود من خلال تعريف الاختبارات في بنية بيانات، ثم جعل الاختبارات تشير إلى تلك البنية بدلاً من تكرار كود الاختبار لكل اختبار.
  4. كن وحدوياً – يجب تقسيم جميع الاختبارات إلى بنية ما تمنع الحاجة إلى التمرير عبر كود غير ضروري للعثور على ما تبحث عنه.
  5. لدعم إعداد البيئة وسيناريوهات التهيئة – هذه الاختبارات هي اختبارات شاملة (E2E)/اختبارات تكامل/اختبارات قبول، مهما كان ما تريده. إنها تختبر استخدام المستخدم النهائي؛ لا توجد اختبارات وحدات، لذا يجب أن يكون هناك أشياء مختلفة في البيئة لتشغيلها.
  6. لدعم تشغيل مجموعات من الاختبارات دون تشغيلها جميعًا دفعة واحدة.

تؤدي هذه المتطلبات إلى إطار اختبار Pester لشركة Devolutions.

المتطلبات المسبقة

إذا كنت ترغب في استخدام هذا الإطار، لا أستطيع ضمان أنه سيعمل بنسبة 100% ما لم تكن تعمل في نفس البيئة التي أعمل بها. لاستخدام هذا الإطار، استخدمت:

  • PowerShell v7.4.5 على نظام Windows
  • Pester 5.6.1

سيظل هذا يعمل على Windows PowerShell والإصدارات السابقة من Pester v5، ولكن لا توجد ضمانات!

نظرة عامة على الإطار

يتكون هذا الإطار من أربعة مكونات: نص استدعاء، نصوص تعريف اختبار مختلفة، نص اختياري لتخزين الدوال المساعدة، ونصوص اختيارية قبل الكل / قبل كل واحدة. كل من هذه المكونات هيكلها كالتالي في نظام الملفات:

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

عند استدعائك لنص الاستدعاء:

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

نص الاستدعاء:

  1. يجد جميع نصوص تعريف الاختبار في كل مجموعة.
  2. يجد جميع نصوص beforeeach و beforeall لكل مجموعة.
  3. ينشئ سياقات Pester لكل مجموعة وتحت مجموعة.
    السياق 'المجموعة' {
        السياق 'تحت المجموعة' {
    
        }
    }
  4. يستدعي نص beforeall مرة واحدة قبل تشغيل أي اختبار في المجموعة.
  5. يستدعي نص beforeeach قبل كل اختبار في المجموعة.
  6. أخيرًا، يستدعي تأكيدات الاختبار المحددة في كل تعريف اختبار.

نص اختبار المتصل

نص المتصل هو نقطة استدعاء Pester. إنه النص الذي يستدعيه Invoke-Pester عندما تحتاج إلى استدعاء أي اختبارات داخل الإطار.

ينقسم نص المتصل إلى مجالات X:

كتلة BeforeDiscovery

أحد مهام نص المتصل هو العثور على جميع تعريفات الاختبار وما هي مرحلة اكتشاف Pester. داخل كتلة BeforeDiscovery الخاصة بالنص، يتم جمع تعريفات الاختبار، بالإضافة إلى أي نصوص قبل كل أو قبل الكل.

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
        }
    }
}

كتلة BeforeAll

تعمل كتلة BeforeAll لجعل أي دوال مساعدة متاحة لنص المتصل أو تعريفات الاختبار. في Pester v5، لا يمكن أن تكون هذه المهمة في BeforeDiscovery؛ وإلا فلن تكون متاحة للاختبارات.

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

كتلة Describe و Contexts

كل تكوين بيئة تحتاجه لتشغيل الاختبارات يتم تقسيمه إلى سياقات مع مجموعات اختبارات تحت كل منها.

لـ Devolutions PowerShell module، نظرًا لأننا بحاجة لاختبار cmdlets ضد العديد من أنواع مصادر البيانات المختلفة، قمت بإنشاء سياقات حسب مصدر البيانات، ولكن يمكنك استخدام أي شيء هنا. ثم، باستخدام معلمة ForEach في Pester، كل مجلد تعريف اختبار هو سياق، كما هو الحال مع كل مجموعة فرعية. ثم، يتم تعريف كل اختبار في الأسفل كـ it كتل.


لاحظ طريقة where() على كتلة it. Where({ $_.environments -contains 'xxx' -or $_.environments.count -eq 0}). هنا تأتي بنية كل تعريف إلى اللعب. هذه هي النقطة التي نحدد فيها أي الاختبارات تعمل في أي بيئة.


# 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.
}

تعريفات الاختبار

بعد ذلك، لديك الجزء الأكثر أهمية: الاختبارات! يتم إنشاء الاختبارات في مجموعات فرعية (subgroup.tests.ps1) داخل كل مجلد مجموعة ويجب إنشاؤها كمصفوفة من جداول التجزئة بالهيكل التالي:

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

هنا، يمكنك تحديد البيئات في البرنامج النصي المستدعي لتنفيذ النصوص. على سبيل المثال، إذا كان لديك بيئة لكل مصدر بيانات استخدمته لـ Devolutions RDM، فإن بيئتي هي xml، sqllite، إلخ.

وظائف المساعدة

أخيرًا، لدينا نص وظيفة المساعدة. يحتوي هذا النص على وظائف نقوم بتضمينها في كتلة BeforeAll في البرنامج النصي المستدعي. هنا يمكنك وضع أي وظائف ستعيد استخدامها. في مثالي، لدي وظائف لإعداد مصادر البيانات وإزالة جميعها.

# 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}
}

كيفية بناء إطار العمل الخاص بك

هل يبدو هذا الإطار مفيدًا؟ هل تعتقد أنه سيكون مفيدًا لمنظمتك؟ إذا كان الأمر كذلك، إليك بعض النصائح والأسئلة التي يجب التفكير فيها.

  1. كم عدد الاختبارات التي تتوقع أنك ستحتاج إليها؟ هل يكفي سيناريو الاستخدام الخاص بك لدعم هذا الإطار بدلاً من مجرد عدد قليل من نصوص الاختبار؟
  2. قبل كتابة أي PowerShell، صف كيف تتوقع تعريف المجموعات والمجموعات الفرعية. اجعلها تجريدًا منطقيًا لأي عناصر تختبرها.
  3. فكر في البيئات والتكوين الذي ستحتاجه لكل مجموعة من الاختبارات؛ أنشئ تلك كـ سياقات في نص الاستدعاء.
  4. بينما تبني تعريفات الاختبار، إذا وجدت نفسك تكرر الشيفرة، أنشئ دالة لذلك وضعها في helper_functions.ps1.

يتطلب بناء إطار اختبار Pester قابل للتوسع تخطيطًا وتنظيمًا دقيقين، ولكن الفوائد تستحق الجهد. من خلال اتباع الهيكل الموضح في هذه المقالة – بتصميمه المعياري، ومكوناته القابلة لإعادة الاستخدام، ومعالجته المرنة للبيئة – يمكنك إنشاء حل اختبار قوي ينمو مع احتياجاتك. سواء كنت تختبر وحدات PowerShell، أو تكوينات البنية التحتية، أو التطبيقات المعقدة، يوفر هذا الإطار قاعدة صلبة للحفاظ على جودة الشيفرة وموثوقيتها عبر منظمتك.

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