Pide tu presupuesto ya!

Cómo agregar tiempos de espera para molestar las pruebas con los espacios de ejecución de PowerShell

¿Alguna vez ha tenido una prueba de Pester Hang indefinidamente, bloqueando toda su suite de prueba? Tal vez esté esperando una respuesta de red que nunca llega, o atascada en un bucle infinito. Sin el manejo adecuado del tiempo de espera, una prueba mala puede arruinar toda su tubería CI/CD.

En este artículo, aprenderá cómo implementar un manejo de tiempo de espera robusto para las pruebas Pester utilizando espacios de ejecución de PowerShell, asegurando que su suite de prueba siempre se complete en un plazo predecible.

Nota: Todos los fragmentos de código de este artículo están disponibles en una sola esencia en https://gist.github.com/adbertram/3a9ae607b073f3cabe86841fdd73b00d

Requisitos previos

Para seguir, necesitarás:

  • Windows 10 o Windows Server 2016+ (los ejemplos usan Windows, pero los conceptos se aplican a PowerShell Core en cualquier sistema operativo)
  • PowerShell 7+
  • Pester 5.0+ instalado (Install-Module -Name Pester -Force)
  • Comprensión básica de la estructura de prueba de Pester

Comprender el problema

Por defecto, las pruebas Pester se ejecutan sincrónicamente en la misma sesión de PowerShell. Si se cuelga una prueba, no hay un mecanismo incorporado para detenerlo después de un cierto tiempo. Esto se convierte en un problema crítico cuando:

  • Las pruebas interactúan con sistemas externos (bases de datos, API, acciones de archivo)
  • Las pruebas tienen una lógica compleja que podría bloquear
  • Las pruebas se ejecutan en tuberías automatizadas donde colgar significa implementaciones fallidas

Creemos un ejemplo simple que demuestre el problema:

Describe "Hanging Test Example" {
    It "Should complete but never does" {
        # Simulating a hanging operation
        while ($true) {
            Start-Sleep -Seconds 1
        }
        $true | Should -Be $true
    }
}

Ejecute esta prueba y se quedará para siempre. Tendrás que detenerlo manualmente con Ctrl+C.

La solución RunSpace y sus desafíos

Los espacios de ejecución de PowerShell le permiten ejecutar código en subprocesos aislados con control de tiempo de espera. Sin embargo, la implementación de espacios de ejecución para las pruebas Pester introduce varios desafíos:

Desafío 1: Aislamiento de alcance variable

Los espacios de ejecución no tienen acceso a variables desde su sesión principal. Considere esta prueba:

Describe "Variable Scope Problem" {
    BeforeAll {
        $script:testData = "Important Test Data"
    }

    It "Should access script variable" {
        # This would fail in a runspace - $script:testData doesn't exist there!
        $script:testData | Should -Be "Important Test Data"
    }
}

Desafío 2: carga de módulos

Los espacios de ejecución comienzan con una pizarra limpia. No tienen acceso a módulos cargados en su sesión principal:

# This works in your main session
Import-Module MyCustomModule

Describe "Module Loading Problem" {
    It "Should use custom cmdlet" {
        # This fails in a runspace - MyCustomModule isn't loaded there!
        Get-MyCustomData | Should -Not -BeNullOrEmpty
    }
}

Desafío 3: Pester’s Testdrive

TestDrive es una de las características más útiles de Pester, pero es específica de la sesión:

Describe "TestDrive Problem" {
    It "Should create file in TestDrive" {
        # In a runspace, TestDrive:\ doesn't exist!
        $testFile = "TestDrive:\test.txt"
        "test content" | Out-File $testFile
        Test-Path $testFile | Should -Be $true
    }
}

Desafío 4: Captura de transmisiones de salida

Las pruebas a menudo escriben en varias corrientes de PowerShell (advertencia, detallado, error). Capturarlos de un espacio de carrera requiere un manejo especial:

Describe "Stream Capture Problem" {
    It "Should capture warnings" {
        Write-Warning "This is a test warning"
        Write-Verbose "This is verbose output" -Verbose
        # How do we capture these in a runspace?
    }
}

Construyendo una solución de tiempo de espera robusta

Construyamos una solución integral que aborde todos estos desafíos:

function Invoke-PesterTestWithTimeout {
    <#
    .SYNOPSIS
    Executes a Pester test script block with timeout protection in an isolated runspace.

    .DESCRIPTION
    Runs test code in a separate runspace with proper variable injection, module loading,
    stream capture, and TestDrive support.

    .PARAMETER ScriptBlock
    The test code to execute.

    .PARAMETER TimeoutSeconds
    Maximum seconds to wait for test completion.

    .PARAMETER TestName
    Name of the test for error reporting.

    .EXAMPLE
    Invoke-PesterTestWithTimeout -ScriptBlock { 1 + 1 | Should -Be 2 } -TimeoutSeconds 30 -TestName "Math Test"
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,

        [Parameter(Mandatory)]
        [int]$TimeoutSeconds,

        [Parameter(Mandatory)]
        [string]$TestName
    )

    # Create initial session state
    $iss = [initialsessionstate]::CreateDefault()

    # Import Pester for Should cmdlets
    $iss.ImportPSModule("Pester")

    # Import any modules from the parent session
    $loadedModules = Get-Module | Where-Object { $_.Name -ne 'Pester' }
    foreach ($module in $loadedModules) {
        if ($module.Path) {
            $iss.ImportPSModule($module.Path)
        }
    }

    # Create runspace
    $runspace = [runspacefactory]::CreateRunspace($iss)
    $runspace.Open()

    # Challenge 1 Solution: Copy variables to runspace
    $scriptVars = Get-Variable -Scope Script -ErrorAction SilentlyContinue
    foreach ($var in $scriptVars) {
        # Skip automatic and system variables
        if ($var.Name -notmatch '^(_|PS|Host|PID|PWD|null|true|false)' -and
            $var.Options -notmatch 'ReadOnly|Constant') {
            try {
                $runspace.SessionStateProxy.SetVariable($var.Name, $var.Value)
            } catch {
                # Some variables can't be set - continue
            }
        }
    }

    # Challenge 3 Solution: Inject TestDrive path
    if (Test-Path Variable:TestDrive) {
        $runspace.SessionStateProxy.SetVariable("TestDrive", $TestDrive)
    }

    # Create PowerShell instance
    $powershell = [powershell]::Create()
    $powershell.Runspace = $runspace

    # Load helper functions into runspace
    $helperScript = @'
# Make TestDrive:\ work in the runspace
if ($TestDrive) {
    $null = New-PSDrive -Name TestDrive -PSProvider FileSystem -Root $TestDrive -Scope Global -ErrorAction SilentlyContinue
}

# Helper to write to appropriate streams
function Write-StreamOutput {
    param($Message, $Stream)
    switch ($Stream) {
        'Warning' { Write-Warning $Message }
        'Verbose' { Write-Verbose $Message -Verbose }
        'Debug' { Write-Debug $Message -Debug }
        'Information' { Write-Information $Message }
    }
}
'@

    [void]$powershell.AddScript($helperScript)
    [void]$powershell.AddScript($ScriptBlock.ToString())

    try {
        # Start async execution
        Write-Verbose "Starting test '$TestName' with ${TimeoutSeconds}s timeout"
        $handle = $powershell.BeginInvoke()

        # Wait for completion or timeout
        if (-not $handle.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000)) {
            Write-Warning "Test '$TestName' exceeded timeout, stopping execution"
            $powershell.Stop()
            throw "Test '$TestName' timed out after $TimeoutSeconds seconds"
        }

        # Get results
        $result = $powershell.EndInvoke($handle)

        # Challenge 4 Solution: Capture and replay all streams

        # Warning stream
        if ($powershell.Streams.Warning.Count -gt 0) {
            foreach ($warning in $powershell.Streams.Warning) {
                Write-Warning $warning.Message
            }
        }

        # Verbose stream
        if ($powershell.Streams.Verbose.Count -gt 0) {
            foreach ($verbose in $powershell.Streams.Verbose) {
                Write-Verbose $verbose.Message
            }
        }

        # Error stream
        if ($powershell.Streams.Error.Count -gt 0) {
            # Collect all errors first
            $errors = @($powershell.Streams.Error)

            foreach ($error in $errors) {
                Write-Verbose "Captured error: $($error.Exception.Message)"
            }

            # Throw the first error
            throw $errors[0].Exception.Message
        }

        # Information stream
        if ($powershell.Streams.Information.Count -gt 0) {
            foreach ($info in $powershell.Streams.Information) {
                Write-Information $info.MessageData
            }
        }

        # Debug stream
        if ($powershell.Streams.Debug.Count -gt 0) {
            foreach ($debug in $powershell.Streams.Debug) {
                Write-Debug $debug.Message
            }
        }

        # Copy variables back from runspace
        $getVarsCommand = [powershell]::Create()
        $getVarsCommand.Runspace = $runspace
        $getVarsCommand.AddScript("Get-Variable -Scope Script -ErrorAction SilentlyContinue")

        $runspaceVars = $getVarsCommand.Invoke()
        $getVarsCommand.Dispose()

        foreach ($var in $runspaceVars) {
            if ($var.Name -notmatch '^(_|PS|Host|PID|PWD|null|true|false)' -and
                -not (Get-Variable -Name $var.Name -Scope Script -ErrorAction SilentlyContinue)) {
                try {
                    Set-Variable -Name $var.Name -Value $var.Value -Scope Script -Force
                } catch { }
            }
        }

        return $result

    } finally {
        # Cleanup
        if ($powershell) {
            $powershell.Dispose()
        }
        if ($runspace -and $runspace.RunspaceStateInfo.State -eq 'Opened') {
            $runspace.Close()
            $runspace.Dispose()
        }
    }
}

Ejemplos prácticos

Veamos cómo esto resuelve nuestros desafíos:

Ejemplo 1: soporte de prueba de prueba

Describe "TestDrive with Timeout" {
    It "Should work with TestDrive in runspace" {
        $testScript = {
            # TestDrive now works!
            $testFile = Join-Path $TestDrive "test.txt"
            "Hello from runspace" | Out-File $testFile

            Test-Path $testFile | Should -Be $true
            Get-Content $testFile | Should -Be "Hello from runspace"
        }

        Invoke-PesterTestWithTimeout `
            -ScriptBlock $testScript `
            -TimeoutSeconds 10 `
            -TestName "TestDrive Example"
    }
}

Ejemplo 2: acceso variable

Describe "Variable Scope with Timeout" {
    BeforeAll {
        $script:testData = @{
            Server = "TestServer"
            Database = "TestDB"
        }
    }

    It "Should access script variables in runspace" {
        $testScript = {
            # Script variables are now available!
            $script:testData | Should -Not -BeNullOrEmpty
            $script:testData.Server | Should -Be "TestServer"

            # Create new variable in runspace
            $script:newData = "Created in runspace"
        }

        Invoke-PesterTestWithTimeout `
            -ScriptBlock $testScript `
            -TimeoutSeconds 10 `
            -TestName "Variable Scope Test"

        # New variables are copied back!
        $script:newData | Should -Be "Created in runspace"
    }
}

Ejemplo 3: Captura de la corriente

Describe "Stream Capture with Timeout" {
    It "Should capture all output streams" {
        $testScript = {
            Write-Warning "This is a warning from the test"
            Write-Verbose "Verbose output here" -Verbose
            Write-Information "Information message"

            # The test continues normally
            $result = 1 + 1
            $result | Should -Be 2
        }

        # Capture the streams
        $warnings = @()
        $verboseOutput = @()

        Invoke-PesterTestWithTimeout `
            -ScriptBlock $testScript `
            -TimeoutSeconds 10 `
            -TestName "Stream Capture Test" `
            -WarningVariable warnings `
            -Verbose:$true -VerboseVariable verboseOutput

        # Verify streams were captured
        $warnings.Count | Should -BeGreaterThan 0
        $warnings[0] | Should -BeLike "*warning from the test*"
    }
}

Ejemplo 4: Prueba de base de datos del mundo real

Describe "Database Operations with Timeout" {
    BeforeAll {
        $script:connectionString = "Server=localhost;Database=TestDB;Integrated Security=true;"
    }

    It "Should query database within timeout" {
        $testScript = {
            $connection = New-Object System.Data.SqlClient.SqlConnection
            $connection.ConnectionString = $script:connectionString

            try {
                Write-Verbose "Opening database connection..."
                $connection.Open()

                $command = $connection.CreateCommand()
                $command.CommandText = "SELECT COUNT(*) FROM Users"
                $command.CommandTimeout = 5

                $count = $command.ExecuteScalar()
                Write-Verbose "Found $count users"

                $count | Should -BeGreaterOrEqual 0
            }
            finally {
                if ($connection.State -eq 'Open') {
                    $connection.Close()
                }
            }
        }

        Invoke-PesterTestWithTimeout `
            -ScriptBlock $testScript `
            -TimeoutSeconds 10 `
            -TestName "Database Query Test" `
            -Verbose
    }
}

Uso avanzado: la integración del marco de prueba

Aquí le mostramos cómo integrar el manejo de tiempo de espera en todo su marco de prueba:

# PesterConfig.ps1
$global:TestConfig = @{
    DefaultTimeout = 30
    LongRunningTimeout = 300
    QuickTestTimeout = 5
}

function Invoke-TestWithTimeout {
    param(
        [scriptblock]$Test,
        [int]$Timeout = $global:TestConfig.DefaultTimeout,
        [string]$Name
    )

    # Add automatic retry logic for flaky tests
    $attempts = 0
    $maxAttempts = 3

    while ($attempts -lt $maxAttempts) {
        $attempts++
        try {
            Write-Verbose "Attempt $attempts of $maxAttempts for test: $Name"

            Invoke-PesterTestWithTimeout `
                -ScriptBlock $Test `
                -TimeoutSeconds $Timeout `
                -TestName $Name

            # Success - exit the retry loop
            break
        }
        catch {
            if ($attempts -eq $maxAttempts) {
                # Final attempt failed
                throw
            }

            # Log the failure and retry
            Write-Warning "Test '$Name' failed on attempt $attempts. Retrying..."
            Start-Sleep -Seconds 2
        }
    }
}

# Usage in tests
Describe "Production Test Suite" {
    It "Should complete quick operation" {
        Invoke-TestWithTimeout -Name "Quick Test" -Timeout $global:TestConfig.QuickTestTimeout -Test {
            # Fast test logic here
            "fast" | Should -Be "fast"
        }
    }

    It "Should handle long-running process" {
        Invoke-TestWithTimeout -Name "Long Process" -Timeout $global:TestConfig.LongRunningTimeout -Test {
            # Long-running test logic here
            Start-Sleep -Seconds 2
            $true | Should -Be $true
        }
    }
}

Mejores prácticas

  1. Establezca tiempos de espera apropiados: No use el mismo tiempo de espera para todas las pruebas. Las pruebas unitarias rápidas pueden necesitar 5 segundos, mientras que las pruebas de integración pueden necesitar minutos.
  2. Eventos de tiempo de espera de registro: Siempre registre cuando se salga de prueba. Esto ayuda a identificar pruebas problemáticas en registros de CI/CD.
  3. Limpiar recursos: Use los bloques de prueba/finalmente en su código de prueba para garantizar que los recursos se limpien incluso si la prueba se desprende.
  4. Monitorear el uso de la memoria: Los espacios de ejecución pueden consumir memoria. Asegure una eliminación adecuada para evitar fugas de memoria en pruebas largas.
  5. Manejar características específicas de Pester: Recuerde manejar características especiales de Pester como Set-ITResult para pruebas omitidas.

Conclusión

Agregar protección de tiempo de espera a las pruebas de Pester utilizando espacios de ejecución resuelve el problema de la prueba de ahorcamiento al introducir nuevos desafíos en torno al alcance variable, la carga del módulo y la captura de la corriente. La solución presentada aquí aborda todos estos desafíos, lo que le brinda un marco robusto para ejecutar grandes suites de prueba de manera confiable.

Con el manejo adecuado del tiempo de espera, sus pruebas siempre se completarán en un plazo predecible, lo que hace que sus tuberías de CI/CD sean más confiables y los comentarios de su prueba más rápido.

Obtenga el código: todos los fragmentos de código de este artículo están disponibles en una sola esencia en https://gist.github.com/adbertram/3a9ae607b073f3cabe86841fdd73b00d

Written by

Leave a comment