Using Pester 5 to Test PowerShell Universal Instances

Image Description

Daily PowerShell #32

Daily PowerShell PowerShell Universal Pester GitHub

November 17, 2021

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

Installing Prerequisites

In this blog, 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 new version of PowerShell Universal that you wish to test against your configuration. You can do so in two different ways. First, you can use Invoke-WebRequest directly to download the file, 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 scripts 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 above to download the current 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.

Our test files having 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 to run. 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 on within 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 actually run the validation scripts using Pester. This post was done with 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 allows us to run against multiple environments by providing a -ForEach array to the Describe block.

All of our tests in this example will run three times; one for each environment. We are also taking advantage of the BeforeAll block to set the environment before each environment test is run.

Finally, each It block validates individual functionality.

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 developed and the tests authored, we can wrap them up in a handy InvokeBuild script. This will make it easier to call individual portions of the script.

Here’s the full example of the script. We have setup three build tasks. One is to clean up after previous test runs. The second is to download and extract 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 setup a GitHub Actions workflow yml file to run our tests on our custom agent. The workflow runs each morning and can be triggered manually.

We are calling Invoke-Build to run our integration tests. If a test fails, an exception is thrown and the workflow will also fail.

name: Integration Tests
env:
    ACTIONS_ALLOW_UNSECURE_COMMANDS: true
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 resulting output of the Action provides information about which tests are successful and which have failed. Here’s the result of one of our test suites.