MAIN MENU
Devolutions Blog

Announcements, updates, and insights from Devolutions.

Pester turtle and TDD cycle of fail, pass, and refactor for PowerShell Universal.

Using Pester 5 to test PowerShell Universal instances

This walkthrough shows how to stand up an isolated PowerShell Universal instance, seed it with configuration, write data-driven Pester 5 tests against scripts and endpoints, then wrap the suite in InvokeBuild and GitHub Actions.

Learn how to write tests against PowerShell Universal with Pester 5.

Installing prerequisites

In this post, we’ll use InvokeBuild and Pester. You’ll need to install these modules to follow along.

Install-Module Pester
Install-Module InvokeBuild

Downloading PowerShell Universal

The first step is to download and install the version of PowerShell Universal that you want to test against your configuration. You can do this in two ways. You can use Invoke-WebRequest directly to download the ZIP, or you can use Install-PSUServer to download the MSI and install the service.

In this example, I’ll use Invoke-WebRequest since I’ll be downloading the nightly build. The following script reads the nightly blob storage, finds the latest build, downloads the ZIP, extracts it, and unblocks the files.

[xml]$Xml = (Invoke-RestMethod 'https://imsreleases.blob.core.windows.net/universal-nightly?restype=container&comp=list').Substring(3)

$MaxBlob = $null
foreach($blob in $Xml.EnumerationResults.Blobs.Blob.Where({$_.Url.Contains('win7') -and $_.Url.Contains(".zip")}))
{
    if ($null -eq $MaxBlob -or ([int]$blob.Name.Split('/')[0]) -gt ([int]$MaxBlob.Name.Split('/')[0]))
    {
        $MaxBlob = $blob
    }
}

Invoke-WebRequest $MaxBlob.Url -OutFile "$PSScriptRoot\Universal.zip"
Expand-Archive -Path "$PSScriptRoot\Universal.zip" -Destination "$PSScriptRoot\Universal"
Get-ChildItem "$PSScriptRoot\Universal" -Recurse | Unblock-File

You could also update the script above to download a released version.

Invoke-WebRequest "https://imsreleases.blob.core.windows.net/universal/production/2.5.4/Universal.win7-x64.2.5.4.zip" -OutFile "$PSScriptRoot\Universal.zip"

Configuring PowerShell Universal

Once we have PowerShell Universal downloaded, we can configure PSU with some pre-built configuration files. In our integration test suite, we have .ps1 files that we load into the configuration directory before starting the server.

PowerShell Universal test repository folder with Scripts, dashboards, and published folders.
Test repository layout for PowerShell Universal

Our test files have many permutations of PSU configuration. This is a sample of our endpoints.ps1.

New-PSUEndpoint -Url "/get" -Method 'GET' -Endpoint {
    "Hello"
}

New-PSUEndpoint -Url "/get/header" -Method 'GET' -Endpoint {
    $Headers["X-MYHEADER"]
}

New-PSUEndpoint -Url "/get/:id" -Method 'GET' -Endpoint {
    $Id
}

New-PSUEndpoint -Url "/post" -Method 'POST' -Endpoint {
    $Body
}

New-PSUEndpoint -Url "/post/params" -Method 'POST' -Endpoint {
    param($Name, $Value)

    @{
        Name = $Name
        Value = $Value
    }
}

New-PSUEndpoint -Url "/error" -Endpoint {
    throw "Uh oh!"
} -ErrorAction stop

We then take this configuration and deploy it to the configuration directory C:\ProgramData\UniversalAutomation\Repository.

New-Item C:\ProgramData\UniversalAutomation -ItemType Directory
Copy-Item "$PSScriptRoot\assets\Repository" C:\ProgramData\UniversalAutomation -Recurse

Setting up for a Pester test

Next, we can get the PSU server up and running and ready for our Pester test suite. The following script starts the PSU server, waits for it to become active, logs in, and then grants an app token we can use later in our tests.

Import-Module "$PSScriptRoot\Universal\Universal.psd1"
$Process = Start-Process "$PSScriptRoot\Universal\Universal.Server.exe" -PassThru

# Wait for the PSU server to start
while($true)
{
    try
    {
        Invoke-RestMethod "http://localhost:5000/api/v1/alive"
        break;
    }
    catch
    {

    }
}

try
{
    # Sign in using default forms auth.
    Invoke-RestMethod "http://localhost:5000/api/v1/signin" -Method Post -Body (@{
        Username = "admin"
        Password = "admin"
    } | ConvertTo-Json) -SessionVariable Session -ContentType "application/json"

    # Grant an app token using the login session
    $ENV:TestAppToken = (Invoke-RestMethod "http://localhost:5000/api/v1/apptoken/grant" -Method GET -WebSession $Session).Token

    # Connect to the PSU server so we can use cmdlets
    Connect-PSUServer -AppToken $ENV:TestAppToken -ComputerName "http://localhost:5000"

    # Set the current location and start executing tests
    Set-Location $PSScriptRoot
    $Results = Invoke-Pester -PassThru
    if ($Results.Result -ne 'Passed')
    {
        throw "Tests failed!"
    }
}
finally
{
    Stop-Process $Process
}

Writing a Pester test

The next step is to run the validation scripts using Pester. This post was written against Pester 5.3.1.

Here is a subset of some of the tests we run for scripts. We take advantage of a data-driven test that lets us run against multiple environments by providing a -ForEach array to the Describe block.

All of our tests in this example will run three times, once for each environment. We also use the BeforeAll block to set the environment before each environment’s tests run.

Finally, each It block validates individual functionality with cmdlets such as Invoke-PSUScript.

Describe "Scripts.<_>" -ForEach @('pwsh', 'powershell', 'integrated') {
    BeforeAll {
        Set-PSUSetting -DefaultEnvironment $_
    }

    Context "Run" {
        It "should have correct variables" {
            $Vars = Invoke-PSUScript -Name 'Vars.ps1' -Wait
            $Vars.Environment | Should -be $_
            $Vars.Script.Name | Should -be "Vars.ps1"
            $Vars.Job | Should -not -be $null
            $Vars.JobId | Should -not -be $null
            $Vars.ScriptId | should -not -be $null
            $Vars.Simple | Should -be "123"
        }
        It "should run a script by name" {
            $Job = Invoke-PSUScript -Name 'Script.ps1'
            $Job | Should -not -be $null
        }

        It "should run a script by pipeline" {
            { Get-PSUScript -Name 'Script.ps1' | Invoke-PSUScript } | Should -Throw
        }

        It "should return a hashtable" {
            $Output = Invoke-PSUScript -Name 'Output.ps1' -Wait
            $Output.Name | Should -be 'Tutorial'
            $Output.Description | Should -be 'Tutorial'
        }

        It "should pass parameters to script" {
            $Output = Invoke-PSUScript -Name 'Params.ps1' -Wait -One 1 -Two 2
            $Output.One | Should -be 1
            $Output.Two | Should -be 2
        }

        It "should call script in folder" {
            Invoke-PSUScript -Name 'Script2.ps1' -Wait | Should -be 'Hello'
        }
    }
}

Packaging into an InvokeBuild script

Now that we have the test framework and the tests, we can wrap them in an InvokeBuild script. That makes it easier to call individual portions of the workflow.

Here’s the full example. We have set up three build tasks. One cleans up after previous test runs. The second downloads and extracts the nightly build. The final task runs the test suite.

task Clean {
    Remove-Item -Path "C:\ProgramData\PowerShellUniversal" -Force -ErrorAction SilentlyContinue -Recurse
    Remove-Item -Path "C:\ProgramData\UniversalAutomation" -Force -ErrorAction SilentlyContinue -Recurse
    Remove-Item -Path "$PSScriptRoot\Universal" -Force -ErrorAction SilentlyContinue -Recurse
    Remove-Item -Path "$PSScriptRoot\Universal.zip" -Force -ErrorAction SilentlyContinue
}

task DownloadNightly {
    [xml]$Xml = (Invoke-RestMethod 'https://imsreleases.blob.core.windows.net/universal-nightly?restype=container&comp=list').Substring(3)

    $MaxBlob = $null
    foreach($blob in $Xml.EnumerationResults.Blobs.Blob.Where({$_.Url.Contains('win7') -and $_.Url.Contains(".zip")}))
    {
        if ($null -eq $MaxBlob -or ([int]$blob.Name.Split('/')[0]) -gt ([int]$MaxBlob.Name.Split('/')[0]))
        {
            $MaxBlob = $blob
        }
    }

    Invoke-WebRequest $MaxBlob.Url -OutFile "$PSScriptRoot\Universal.zip"
    Expand-Archive -Path "$PSScriptRoot\Universal.zip" -Destination "$PSScriptRoot\Universal"
    Get-ChildItem "$PSScriptRoot\Universal" -Recurse | Unblock-File
}

task RunTests {

    New-Item C:\ProgramData\UniversalAutomation -ItemType Directory
    Copy-Item "$PSScriptRoot\assets\Repository" C:\ProgramData\UniversalAutomation -Recurse

    Import-Module "$PSScriptRoot\Universal\Universal.psd1"
    $Process = Start-Process "$PSScriptRoot\Universal\Universal.Server.exe" -PassThru

    while($true)
    {
        try
        {
            Invoke-RestMethod "http://localhost:5000/api/v1/alive"
            break;
        }
        catch
        {

        }
    }

    try
    {
        Invoke-RestMethod "http://localhost:5000/api/v1/signin" -Method Post -Body (@{
            Username = "admin"
            Password = "admin"
        } | ConvertTo-Json) -SessionVariable Session -ContentType "application/json"

        $ENV:TestAppToken = (Invoke-RestMethod "http://localhost:5000/api/v1/apptoken/grant" -Method GET -WebSession $Session).Token

        Connect-PSUServer -AppToken $ENV:TestAppToken -ComputerName "http://localhost:5000"

        Set-Location $PSScriptRoot
        $Results = Invoke-Pester -PassThru
        if ($Results.Result -ne 'Passed')
        {
            throw "Tests failed!"
        }
    }
    finally
    {
        Stop-Process $Process
    }
}

task CleanAndRun Clean, RunTests

task . Clean, DownloadNightly, RunTests

Running Pester tests in GitHub Actions

In our environment, we use GitHub Actions for CI and CD pipelines. We have a self-hosted agent designated for running integration tests. From within the Universal repository, we’ve set up a GitHub Actions workflow YAML file to run our tests on our custom agent. The workflow runs each morning and can be triggered manually.

We call Invoke-Build to run our integration tests. If a test fails, an exception is thrown and the workflow fails as well.

name: Integration Tests
on:
    schedule:
        - cron: "0 4 * * *"
    workflow_dispatch:

jobs:
    build:
      name: Build
      runs-on: self-hosted
      steps:
        - uses: actions/checkout@v1
        - name: Run Integration Test
          run: Invoke-Build -File .\test\integration-test.ps1
          shell: pwsh

The Action output shows which tests succeeded and which failed. Here’s the result of one of our test suites.

Pester test run output showing 68 passed tests and one failed dashboard test.
Pester results from a GitHub Actions integration run

Ready to build? Download PowerShell Universal.