DEV Community

Zoltan Toma
Zoltan Toma

Posted on • Originally published at zoltantoma.com on

Migrating Integration Tests to Pester: A PowerShell Testing Journey

The Testing Problem

The vagrant-wsl2-provider had integration tests. They worked. They ran actual Vagrant commands and verified WSL2 distributions got created properly. But they were all custom PowerShell scripts with manual error handling, inconsistent output, and no real test framework.

Here’s what a typical test looked like:

try {
    Write-Host "Test: Starting VM..." -ForegroundColor Yellow
    vagrant up
    if ($LASTEXITCODE -ne 0) {
        throw "vagrant up failed"
    }
    Write-Host "[PASS] VM started" -ForegroundColor Green
} catch {
    Write-Host "[FAIL] Test failed" -ForegroundColor Red
    exit 1
}

Enter fullscreen mode Exit fullscreen mode

It worked, but it was painful to maintain. Each test file had its own error handling, cleanup logic, and output formatting. And when something failed, you got a wall of red text with no clear indication of which specific assertion broke.

Time to modernize.

Why Pester?

Pester is PowerShell’s native testing framework. It’s been around since 2011, and version 5.x brought major improvements. More importantly, it’s what PowerShell developers actually use.

The benefits were clear:

  • Structured tests - Describe, Context, It blocks instead of ad-hoc scripts
  • Better assertions - Should -Be instead of manual if ($LASTEXITCODE -ne 0)
  • Proper output - Test results with timing, pass/fail counts, and detailed failure info
  • BeforeAll/AfterAll - Consistent setup and cleanup
  • Skip support - Conditionally skip tests (critical for admin-required tests)

Plus, Pester integrates with CI/CD tools, has good VS Code support, and produces JUnit XML reports if you need them.

The Migration Strategy

I didn’t want to rewrite everything from scratch. The existing tests worked and covered real functionality. So the plan was:

  1. Move to test/pester/ directory - Keep legacy tests in test/integration/ for reference
  2. Convert one test at a time - Start simple, learn patterns, iterate
  3. Keep the same examples/ structure - Tests still run against actual Vagrantfiles
  4. Add proper cleanup - BeforeAll/AfterAll for consistent environment
  5. Handle Windows quirks - Admin privileges, path issues, PowerShell redirection

Test Structure: The Pester Way

Here’s what the basic test structure looked like after conversion:

BeforeAll {
    $script:ExampleDir = Join-Path $PSScriptRoot "..\..\examples\basic"
    Push-Location $script:ExampleDir

    # Cleanup before tests
    vagrant destroy -f 2>$null | Out-Null
}

AfterAll {
    # Cleanup after all tests
    vagrant destroy -f 2>$null | Out-Null
    Pop-Location
}

Describe "Vagrant WSL2 Provider - Basic Operations" {
    Context "When creating a new VM" {
        It "Should successfully run 'vagrant up --provider=wsl2'" {
            vagrant up --provider=wsl2
            $LASTEXITCODE | Should -Be 0
        }

        It "Should create WSL distribution" {
            $wslList = (wsl -l -v | Out-String) -replace '\0', ''
            $wslList | Should -Match "vagrant-wsl2-basic"
        }
    }

    Context "When destroying the VM" {
        It "Should successfully run 'vagrant destroy -f'" {
            vagrant destroy -f
            $LASTEXITCODE | Should -Be 0
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Clean. Readable. Each It block is a single assertion. Context blocks group related tests. And BeforeAll/AfterAll handle setup and cleanup.

Challenge 1: Administrator Privileges

Several tests require administrator privileges - creating VHDs for data disks, configuring network adapters, mounting disks in WSL2. The old tests just checked permissions and exited with a message:

if (-not $isAdmin) {
    Write-Host "SKIPPED: Administrator required"
    exit 0
}

Enter fullscreen mode Exit fullscreen mode

But with Pester, you want the tests to show up in the results, not just silently exit. The solution was Pester’s -Skip parameter:

# Check admin rights early for informative message
$isAdminCheck = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)

if (-not $isAdminCheck) {
    Write-Host ""
    Write-Host "=== Data Disk Test SKIPPED ===" -ForegroundColor Yellow
    Write-Host "Reason: Administrator privileges required" -ForegroundColor Yellow
    Write-Host ""
    Write-Host "Data disk features require:" -ForegroundColor Gray
    Write-Host " - VHD creation (New-VHD cmdlet)" -ForegroundColor Gray
    Write-Host " - WSL disk mounting (wsl --mount)" -ForegroundColor Gray
    Write-Host ""
    Write-Host "To run this test, please restart PowerShell as Administrator" -ForegroundColor Cyan
    Write-Host ""
}

Describe "Vagrant WSL2 Provider - Data Disk" -Skip:(-not $isAdminCheck) -Tag @('RequiresAdmin') {
    # Tests run only if admin
}

Enter fullscreen mode Exit fullscreen mode

This way:

  • Non-admin users see a clear message explaining why tests are skipped
  • Tests appear in Pester output as skipped (not hidden)
  • The -Tag @('RequiresAdmin') lets you filter tests by privilege level
  • Running as admin? Tests execute normally

Claude: The admin check happens at file scope before Pester even loads, so the message appears immediately. Then Pester sees the -Skip flag and marks all tests as skipped. Best of both worlds.

Challenge 2: Directory Management Hell

This one was subtle. The tests need to run in the example directory (where the Vagrantfile lives). Easy enough:

BeforeAll {
    Push-Location $script:ExampleDir
}

Enter fullscreen mode Exit fullscreen mode

But here’s the trap: if the test is skipped due to admin privileges, BeforeAll still runs. And if you put the Push-Location after the admin check with an early return, the directory never changes, but the tests try to run anyway.

Result? vagrant commands fail with “A Vagrant environment or target machine is required” because you’re in the wrong directory.

The fix was to always Push-Location, even for skipped tests:

BeforeAll {
    $script:ExampleDir = Join-Path $PSScriptRoot "..\..\examples\data-disk"

    # Store admin status for skip condition
    $script:isAdmin = $isAdminCheck

    # Always push location, even if skipping (for proper cleanup)
    Push-Location $script:ExampleDir

    if (-not $script:isAdmin) {
        return # Skip setup, but directory is already changed
    }

    # Cleanup before tests (only if admin)
    vagrant destroy -f 2>$null
}

AfterAll {
    if ($script:isAdmin) {
        # Cleanup after all tests
        vagrant destroy -f 2>$null
    }

    # Always pop location
    Pop-Location
}

Enter fullscreen mode Exit fullscreen mode

Now the directory state is always consistent, whether tests run or not.

Challenge 3: PowerShell Output Redirection

I’ve been writing PowerShell for years and I still get this wrong.

The old tests had things like:

$output = vagrant up --provider=wsl2 2>&1

Enter fullscreen mode Exit fullscreen mode

Looks reasonable, right? Redirect stderr to stdout, capture everything. Except in PowerShell, 2>&1 at the end of a command doesn’t work the way Bash users expect.

For cleanup commands piped to Out-Null, you need:

vagrant destroy -f 2>$null | Out-Null

Enter fullscreen mode Exit fullscreen mode

Not:

vagrant destroy -f 2>&1 | Out-Null # Wrong!

Enter fullscreen mode Exit fullscreen mode

The 2>$null redirects stderr to null, and | Out-Null discards stdout. Together they suppress all output.

And for capturing multi-line output to match against patterns:

$ipCheck = (wsl -d vagrant-wsl2-networking-demo -- ip addr show eth0) -join "`n"
$ipCheck | Should -Match "192.168.33.10"

Enter fullscreen mode Exit fullscreen mode

The -join "n"` is critical. Without it, you’re matching against an array, and Pester only shows the first element in error messages. With the join, you get a single string with all lines, and regex matching works across the entire output.

Claude: We spent a solid 20 minutes debugging why IP address checks were failing, only to discover the output was there, just on line 4 instead of line 1. Classic PowerShell array behavior.

The Fast/Slow Distribution Test

The AllDistributions test validates which WSL distributions work with the provider. There are 22 distributions available from wsl -l -o. Testing all of them takes forever.

So I added a parameter to control test scope:

`
param(
[switch]$Full
)

BeforeAll {
# Quick subset for fast testing (different distro families)
$script:QuickDistributions = @(
"Ubuntu-24.04", # Debian-based
"Debian", # Pure Debian
"AlmaLinux-8" # RHEL-based
)

# All distributions
$script:AllDistributions = @(
    "Ubuntu-24.04",
    "Debian",
    "AlmaLinux-8",
    # ... 19 more distributions
)

# Select which to test based on parameter
$script:WslDistributions = if ($Full) {
    $script:AllDistributions
} else {
    $script:QuickDistributions
}
Enter fullscreen mode Exit fullscreen mode

}

`

Now you can run:

`
rake test_all_distributions # Quick: 3 distributions
rake test_all_distributions_full # Full: 22 distributions

`

The quick test covers the major distro families (Debian, Ubuntu, RHEL) and runs in minutes. The full test is for pre-release validation.

The Invoke-PesterTests Runner

To make all this work, I needed a centralized test runner that could pass parameters through:

`
param(
[string]$TestFile = $null,
[ValidateSet('Detailed', 'Normal', 'Minimal')]
[string]$Output = 'Detailed',
[switch]$Full # For AllDistributions: run all distributions
)

$ErrorActionPreference = "Stop"

Ensure Pester 5.x is imported

Import-Module Pester -MinimumVersion 5.0 -ErrorAction Stop

$TestPath = $PSScriptRoot

if ($TestFile) {
$TestPath = Join-Path $TestPath "$TestFile.Tests.ps1"
}

Build Pester configuration

$config = New-PesterConfiguration
$config.Run.Path = $TestPath
$config.Output.Verbosity = $Output
$config.Run.PassThru = $true

Pass parameters to test scripts

if ($Full) {
$config.Run.ContainerParameters = @{ Full = $true }
}

Run tests

$result = Invoke-Pester -Configuration $config
exit $result.FailedCount

`

This runner:

  • Loads Pester 5.x (ensuring compatibility)
  • Accepts a -TestFile parameter to run specific tests
  • Passes the -Full flag through to the test script
  • Returns proper exit codes for CI/CD

What Got Converted

All the integration tests are now Pester-based:

  • Basic.Tests.ps1 - vagrant up, ssh, destroy (11 tests)
  • DataDisk.Tests.ps1 - VHD creation, mounting, persistence (15 tests, requires admin)
  • Snapshot.Tests.ps1 - save, restore, list, delete, push/pop (10 tests)
  • Networking.Tests.ps1 - static IPs, port forwarding (4 tests, requires admin)
  • MultiVmNetwork.Tests.ps1 - multi-VM networking, VM isolation (9 tests, requires admin)
  • AllDistributions.Tests.ps1 - distribution compatibility (3 quick / 22 full)

Total: 62 test cases (quick mode) or 81 test cases (full mode).

The legacy tests are still in test/integration/ for reference, but all new work uses Pester.

Lessons Learned

Windows testing is different. Admin privileges, path handling, output encoding - you can’t just port Unix testing patterns. PowerShell has its own quirks, and fighting them is pointless. Learn the PowerShell way.

Pester’s skip functionality is crucial. Tests that require specific conditions (admin rights, specific OS, etc.) should show up as skipped, not silently disappear. It makes it clear what’s being tested and what’s not.

Always cleanup, even on failure. The AfterAll block runs even if tests fail. Use it. Clean up VMs, delete temp files, reset state. Future you will thank present you.

Parameters make tests flexible. The fast/slow distribution test pattern could apply to other scenarios - integration vs. smoke tests, with/without network, etc. Parameters let you control test scope without duplicating code.

Real output matters. Pester’s output shows you exactly what failed, with timing information and clear pass/fail counts. It’s a huge improvement over custom Write-Host messages.

What’s Next

The tests are working, but there’s cleanup to do:

  • Remove the legacy test/integration/test_*.ps1 files (keeping only run_all_tests.ps1 for compatibility)
  • Add more Pester tests for edge cases (network failures, disk full, WSL2 not installed)
  • Integrate with GitHub Actions for automated testing on commits
  • Maybe add performance benchmarks using Pester’s timing data

But for now, the integration tests are solid. They run fast (quick mode), cover all major functionality, and produce clear results.

And they’re actually maintainable, which is the whole point.


The vagrant-wsl2-provider is open source on GitHub. If you’re working on Windows tooling and need inspiration for testing WSL2 integrations, the Pester tests might help.

Top comments (0)