Skip to content

Instantly share code, notes, and snippets.

@adbertram
Created June 30, 2025 19:29
Show Gist options
  • Save adbertram/3a9ae607b073f3cabe86841fdd73b00d to your computer and use it in GitHub Desktop.
Save adbertram/3a9ae607b073f3cabe86841fdd73b00d to your computer and use it in GitHub Desktop.
Adding Timeouts to Pester Tests Using PowerShell Runspaces - Code Examples

Adding Timeouts to Pester Tests Using PowerShell Runspaces - Code Examples

This gist contains all the code examples from the blog post "Adding Timeouts to Pester Tests Using PowerShell Runspaces".

Problem Example: Hanging Test

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

Challenge Examples

Challenge 1: Variable Scope Problem

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

Challenge 2: Module Loading Problem

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

Challenge 3: TestDrive Problem

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

Challenge 4: Stream Capture Problem

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

Complete Solution: Invoke-PesterTestWithTimeout Function

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

Example 1: TestDrive Support

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

Example 2: Variable Access

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

Example 3: Stream Capture

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

Example 4: Real-World Database Test

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

Advanced Framework Integration

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

Prerequisites and Setup

To use these examples, you'll need:

  • Windows 10 or Windows Server 2016+ (examples use Windows, but concepts apply to PowerShell Core on any OS)
  • PowerShell 5.1 or PowerShell 7+
  • Pester 5.0+ installed: Install-Module -Name Pester -Force
  • Basic understanding of Pester test structure

Notes

  • The main function Invoke-PesterTestWithTimeout solves all four major challenges with runspace-based test execution
  • Variable scope isolation is handled by copying script variables to the runspace
  • Module loading issues are resolved by importing modules from the parent session
  • TestDrive functionality is restored by creating the drive in the runspace
  • Stream capture ensures all PowerShell output streams are properly forwarded

For the complete tutorial and explanation, see the full blog post.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment