Azure and DevOops

Pester Crash course

· Christian Piet

Most developers reach a point where they can fluently write code in their language of choice. As features are added and the codebase expands, a critical question arises: do these changes break existing functionality? Do they work as expected? Testing frameworks like PowerShell’s Pester are designed to address these concerns.

In this blog, I will give you an overview of what Pester is, how you can write your own tests, how you need to structure them, and what value it can bring to your development.

Why

You might think, what are we actually doing? Why are we testing? If you think of it in a very high abstraction, when we write code, we solve problems by creating logic that handles information, events, or inputs in a way that results in an end result/output that we want. So there’s input, and at the end, we expect a certain result or output. By using a test framework like Pester, we can validate that with a given input, executing our code results in the desired output.

And since I mostly write PowerShell, it’s convenient to choose a testing module/framework written in the same language. The most used Testing framework for PowerShell is the Pester-module.

Pester

I found this awesome description on the module description in the PowerShell Gallery.

1Find-PSResource -Repository PSGallery -Name Pester | Select-Object -ExpandProperty Description

Pester provides a framework for running BDD style Tests to execute and validate PowerShell commands inside of PowerShell and offers a powerful set of Mocking Functions that allow tests to mimic and mock the functionality of any command inside of a piece of PowerShell code being tested. Pester tests can execute any command or script that is accessible to a pester test file. This can include functions, Cmdlets, Modules, and scripts. Pester can be run in ad hoc style in a console, or it can be integrated into the Build scripts of a Continuous Integration system.

Pester is a PowerShell module and can be used via its own Domain Specific Language (DSL), so some notations are different or represent things other than those in ’normal’ PowerShell. You can use it to test almost anything on PowerShell and beyond PowerShell, but more on that later.

Pester versions

Pester is included in Windows installations of Windows PowerShell, but it contains an outdated version (major version 3/3.4.0). There are breaking changes from that version to the latest. So make sure you get the latest version, so you’re not missing out on new cool features:

1Find-PSResource -Repository PSGallery -Name Pester | Install-PSResource

The current version while writing this blog is 5.7.1, but do know that Pester 6 is in the making, so new breaking changes are coming up. It will drop support for older versions of PowerShell, so make sure you start with the latest stable version of PowerShell and Pester, to prevent unnecessary rework.

Structure

To start, we’ll have to start with understanding the DSL and the idea of testing our code.

Pester tests are written in files with the .tests.ps1-extension. So a Pester test file example is “function.tests.ps1”. If you want to follow along, make sure you have .tests in front of the .ps1-extension.

Consider the follow Pester file:

 1<#
 2 example.tests.ps1
 3    Mind the keywords and how they're used, like BeforeAll, Describe, Context, and It.
 4#>
 5BeforeAll {
 6    function Get-ToBeTestedOutput {
 7        [CmdletBinding()]
 8        param (
 9            [Parameter()]
10            [String]
11            $Text,
12            [Parameter()]
13            [Switch]
14            $OutputSwitch
15        )
16        if ($OutputSwitch) {
17            $Text
18        }
19    }
20}
21
22Describe 'Get-ToBeTestedOutput' {
23    Context 'Output validation' {
24        It 'Given no OutputSwitch-parameter, it outputs nothing' {
25            Get-ToBeTestedOutput -Text "I am output, look at me" | Should -BeNullOrEmpty
26        }
27    }
28}

This file contains some setup in the BeforeAll scriptBlock, BeforeAll runs once before all the tests. That BeforeAll contains a function that is available in the actual tests.

The file also has a Describe keyword, which is a mechanism to logically group tests. Do mind the curly braces that enclose all our tests. Context is a similar grouping construct that is nested within Describe. These are not required, but they have excellent use cases when you want to scope test runs or loops.

Inside the Context-block is a single test, which is represented by It. The’ It `Block has a title, which is a description of what testcase it tests for. Again, mind the curly braces.

Contained inside the It-block, we see a single-line script block that executes some code and is piped to an assertion, which you can recognize by the Should-keyword. If I use my dictionary: “an assertion is a confident and forceful statement of fact or belief”. So inside this It-block, we execute code, and we want to test for the assumption that our function with those arguments results in a NullOrEmpty output.

There are other concepts within the language, like Mocks, which deserve their own post; I’ll probably create a post about this concept in the near future.

Running tests

When we want to execute this Pester file in VSCode, we can do so via the IDE itself, via the “Run Tests”:

Debug Test and Run Test buttons in VSCode

Or we can do so via the terminal:

1Invoke-Pester /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/example.tests.ps1
2
3Starting discovery in 1 files.
4Discovery found 1 tests in 10ms.
5Running tests.
6[+] /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/example.tests.ps1 76ms (19ms|48ms)
7Tests completed in 77ms
8Tests Passed: 1, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0

Output verbosity

The output shows that the Pester did test discovery; more on that later. After Discovery, Pester ran the tests in the file, and after completion, the results were shown. The output is summarized in the results of the whole file. Personally, I like a bit more verbose output to get a better grasp on what’s going on. In VSCode, we can click “Debug Tests,” and in the terminal, we can use the Output-parameter of Invoke-Pester and select the Detailed preference. More options on verbosity can be found on the module’s documentation website pester.dev - Output Page.

1Starting discovery in 1 file.
2Discovery found 1 tests in 11ms.
3Running tests.
4
5Running tests from '/Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/example.tests.ps1'
6Describing Get-ToBeTestedOutput
7 [+] Given no OutputSwitch-parameter, it outputs nothing 3ms (1ms|2ms)
8Tests completed in 89ms
9Tests Passed: 1, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0

In this output style, we can see per test if it succeeded; yay, we can test stuff!

Assertions

PowerShell code can be written in several ways to reach the desired end result. To verify we actually achieved that end result, we write the It-blocks with an assertion. There are several assertions available to verify results with. I mostly use the following:

Assertion Comment Equivalent
Should -Be Case-insensitively checks for equality of objects (and everything is an object, yay!). You can also check arrays with this assertion. Reading the docs, I also found out -Be is sensitive to array order, so be careful about that. -eq
Should -BeIn Checks if an item is present in the array. -in
Should -BeLike Wildcard match a string output -like
Should -Match Match a string output using regular expressions -match
Should -Throw Verify if an exception was thrown. Nice throwback to my “How to work with errors in PowerShell” -blogpost, right?

You can find the documentation on all available assertions at pester.dev - Assertion Reference.

Considerations when writing tests

When we looked at the function code we wanted to test, we asserted that it doesn’t output if the switch isn’t provided. However, we didn’t test if it outputs anything when we do provide it, and especially, does it output what we want?

Let’s add those in the mix:

 1<#
 2 example.tests.ps1
 3    Mind the assertions and negations used to test the function.
 4#>
 5BeforeAll {
 6    function Get-ToBeTestedOutput {
 7        [CmdletBinding()]
 8        param (
 9            [Parameter()]
10            [String]
11            $Text,
12            [Parameter()]
13            [Switch]
14            $OutputSwitch
15        )
16        if ($OutputSwitch) {
17            $Text
18        }
19    }
20}
21
22Describe 'Get-ToBeTestedOutput' {
23    It 'Given no OutputSwitch-parameter, it outputs nothing' {
24        Get-ToBeTestedOutput -Text "I am output, look at me" | Should -BeNullOrEmpty
25    }
26    It 'Given OutputSwitch-parameter, it outputs the text' {
27        Get-ToBeTestedOutput -Text "I am output, look at me" -OutputSwitch | Should -Not -BeNullOrEmpty
28    }
29    It 'Given Text-parameter, it outputs the correct text' {
30        Get-ToBeTestedOutput -Text "I am output, look at me" -OutputSwitch | Should -Be "I am output, look at me"
31    }
32}

If you look closely, I changed the assertion on the second test to contain ‘a result by adding a negation -Not to invert the logic on the assertion, and it tests for any output if OutputSwitch-switch is provided. The third assertion tests whether the output is exactly what we expect it to be using the -Be assertion. If you’ve created similar tests before, you might’ve spotted that the second test can be considered redundant since the third test tests the same. If it has the result we want, it implicitly as a result, and thus is not $null or empty. Let’s run the test to validate our assertions:

1Describing Get-ToBeTestedOutput
2 [+] Given no OutputSwitch-parameter, it outputs nothing 1ms (1ms|1ms)
3 [+] Given OutputSwitch-parameter, it outputs the text 1ms (1ms|0ms)
4 [+] Given Text-parameter, it outputs the correct text 1ms (1ms|0ms)
5Tests completed in 30ms
6Tests Passed: 3, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0

You might think yay, less code; let’s shove that all in a single It-block, less code, more time for us.

 1<#
 2    Mind the test structure inside the It-block.
 3#>
 4BeforeAll {
 5    function Get-ToBeTestedOutput {
 6        [CmdletBinding()]
 7        param (
 8            [Parameter()]
 9            [String]
10            $Text,
11            [Parameter()]
12            [Switch]
13            $OutputSwitch
14        )
15        if ($OutputSwitch) {
16            $Text
17        }
18    }
19}
20
21Describe 'Get-ToBeTestedOutput' {
22    It 'Outputs what we want, nothing if we don''t want it to and something if we need it to' {
23        Get-ToBeTestedOutput -Text "I am output, look at me" | Should -BeNullOrEmpty
24        Get-ToBeTestedOutput -Text "I am output, look at me" -OutputSwitch | Should -Not -BeNullOrEmpty
25        Get-ToBeTestedOutput -Text "I am output, look at me" -OutputSwitch | Should -Be "I am output, look at me"
26    }
27}

If we run this:

1Describing Get-ToBeTestedOutput
2 [+] Outputs what we want, nothing if we don't want it to and something if we need it to 18ms (16ms|1ms)
3Tests completed in 148ms
4Tests Passed: 1, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0

Pester code written this way can get hard to read in the output (if you/colleagues even take the time to make a decent description); it is not very friendly to read as test code and is annoying to maintain. A good practice is to stick to a single assertion per It-block. This keeps it easy to read, both in the output and the IDE. If you need another test, you can just add another one at the bottom; you don’t have to think about what happened to the state in the earlier tests.

Functions

To prevent us from repeating ourselves in code, PowerShell has the concept of functions. Functions are named script blocks and can be reused throughout the code. Functions can also contain parameters, which can tweak the function’s behavior based on information provided via parameters. Functions are nice mechanisms that provide functionality to our users flexibly.

To access the functions of our scripts, we’d have to execute the whole script. This means we’d also apply the operations inside the script instead of asserting our code does what we want it to do.

Imagine you write a script to wipe a machine. You wouldn’t want to execute the whole script just to test it! Instead, you’d want to know if it calls the right endpoints/external functions with the right information and handles input as expected.

If we want to test functions in scripts, we need to separate them from the script logic. We can do this by placing the functions in an external PowerShell script file. By doing so, you can use dot-sourcing, to make the functions available in your own scripts.

Dot-sourcing vs. Import-Module

If you’re unfamiliar with dotsourcing, the Microsoft learn page about_Operators has a nice explanation on it:

Dot sourcing operator .

Runs a script in the current scope so that any functions, aliases, and variables that the script creates are added to the current scope, overriding existing ones. Parameters declared by the script become variables. Parameters for which no value has been given become variables with no value. However, the automatic variable $args is preserved. . .\sample.ps1

Even better would to change the extension of the script file containing your functions to .psm1 making it a full-fledged scriptmodule. This way you can segregate functions into private and public/user-facing functions. Be sure to give the directory and the script the same name. This way, you can use Import-Module or #requires, as well as #using statements in your scripts.

I prefer Import-Module over dot-sourcing since dot-sourcing exposes all of the functions. If you have separate script files for every function, you’ll need to dot source every file containing the functions you want to test. You could choose to define all functions in the same psm1 file, but that’ll also expose all private functions.

Testing private functions in a manifest module

For example, a lot of PowerShell modules use this file structure:

├── Private
│   └── New-DummyOutputPrivate.ps1
├── Public
│   └── New-DummyOutput.ps1
├── testModule.psd1
└── testModule.psm1

It’s a common pattern to split functions in your module into public-facing functions that users of your module can use, as well as private functions that can be used in other functions but not directly by your users. Since we’re trying to test all our code, we need access to all functions in a module. For this concept, Pester has the language concept of InModuleScope. InModuleScope injects code into a module, that allows us to expose internal functions and allows us to Pester test private functions too. Public functions are available from importing the module, so those can be tested that way.

Since private functions are not accessible directly from outside the module, we can’t test the private function in Pester using the usual method.

1. "/Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/testModule/testModule.psm1"
2gcm New-Dummy*
3
4CommandType     Name            Version    Source
5-----------     ----            -------    ------
6Function        New-DummyOutput 0.0        testModule

So, we need InModuleScope to fix this for Pester.

 1<#
 2 New-DummyOutputPrivate.ps1
 3    This is not exposed by default, as seen in the script block above; we need to implement InModuleScope for that.
 4#>
 5function New-DummyOutputPrivate {
 6    "I am a private Function"
 7}
 8<#
 9 New-DummyOutput.ps1
10    Public functions are exposed on import, so if the function is not private, we can test them straight away.
11#>
12function New-DummyOutput {
13    "I am a public Function"
14}
15<#
16 example.tests.ps1
17    Note the use of InModuleScope
18#>
19BeforeAll {
20    Import-Module -Name "$PSScriptRoot/testModule" -Force
21}
22Describe 'Get-ToBeTestedOutput' {
23    It 'Validates output of New-DummyOutput' {
24        New-DummyOutput | Should -Be 'I am a public Function'
25    }
26    InModuleScope testModule {
27        It 'Validates output of New-DummyOutputPrivate' {
28            New-DummyOutputPrivate | Should -Be 'I am a private Function'
29        }
30    }
31}

The first time you run this on a module that’s not imported, it’ll error with:

1Starting discovery in 1 files.
2[-] Discovery in C:\Git\blog\content\script\example.tests.ps1 failed with:
3System.Management.Automation.RuntimeException: No modules named 'testModule' are currently loaded.

Don’t worry for now. If you run the code again, it will work. In the next section, we’ll dive into the phases of Pester and fix that error.

Discovery and Run

Pester has two phases of running the code in your Pester-file:

The Discovery-phase discovers the structure of your test file searching for Describe, Context, and It. You can loop over objects that are created before the discovery phase using the BeforeDiscovery-keyword to prevent having to write the same tests over and over again with different subjects. Before the discovery phase, Pester runs all of the BeforeDiscovery-blocks, even when selecting just a specific test to run. That burned me before.

The Run-phase runs your code inside the BeforeAll,BeforeEach, AfterAll, AfterEach, and, of course, the It-blocks. This is the phase we used earlier. Since the run phase is performed after the discovery phase, any code in the run phase is not available during the discovery phase.

BeforeDiscovery

We had the following test-code earlier:

 1<#
 2 example.tests.ps1
 3    Errors out the first time when ran, because testModule isn't in $PSModulePath, but is referenced with InModuleScope.
 4#>
 5BeforeAll {
 6    Import-Module -Name "$PSScriptRoot/testModule" -Force
 7}
 8Describe 'Get-ToBeTestedOutput' {
 9    It 'Validates output of New-DummyOutput' {
10        New-DummyOutput | Should -Be 'I am a public Function'
11    }
12    InModuleScope testModule {
13        It 'Validates output of New-DummyOutputPrivate' {
14            New-DummyOutputPrivate | Should -Be 'I am a private Function'
15        }
16    }
17}

This gave the error:

1Starting discovery in 1 files.
2[-] Discovery in C:\Git\blog\content\script\example.tests.ps1 failed with:
3System.Management.Automation.RuntimeException: No modules named 'testModule' are currently loaded.

This error is happening because the module is not present in the directories specified in my $env:PSModulePath. The InModuleScope' keyword is part of the Discovery phase, so it can't find the module referenced with the InModuleScopekeyword. If we change theBeforeAllat the top toBeforeDiscovery`, it will be available during Discovery and won’t give us errors anymore during initial runs.

 1<#
 2 example.tests.ps1
 3    BeforeDiscovery makes sure the module is always imported before it runs the Discovery Phase
 4#>
 5BeforeDiscovery {
 6    Import-Module -Name "$PSScriptRoot/testModule" -Force
 7}
 8Describe 'Get-ToBeTestedOutput' {
 9    It 'Validates output of New-DummyOutput' {
10        New-DummyOutput | Should -Be 'I am a public Function'
11    }
12    InModuleScope testModule {
13        It 'Validates output of New-DummyOutputPrivate' {
14            New-DummyOutputPrivate | Should -Be 'I am a private Function'
15        }
16    }
17}
18
19# Output
20Invoke-Pester '/Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/example.tests.ps1' -Output Detailed
21Pester v5.7.1
22
23Starting discovery in 1 files.
24Discovery found 2 tests in 54ms.
25Running tests.
26
27Running tests from '/Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/example.tests.ps1'
28Describing Get-ToBeTestedOutput
29  [+] Validates output of New-DummyOutput 5ms (4ms|1ms)
30  [+] Validates output of New-DummyOutputPrivate 1ms (1ms|0ms)
31Tests completed in 72ms
32Tests Passed: 2, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0

ForEach/TestCases

Consider/Run the following test:

 1<#
 2 count.tests.ps1
 3    Mind the assignment during the BeforeDiscovery and Foreach implementation with that variable.
 4#>
 5BeforeDiscovery {
 6    $array = 1..10
 7}
 8Describe "Count count🦇" -ForEach $array {
 9    It "The number <_> should be less than 11" {
10        $_ | Should -BeLessThan 11
11    }
12}

The Describe keyword in this block is followed by ForEach and iterates on the $array variable, effectively running all of the tests in the Describe for each item in $array. The title has <_> referencing the current item, similar to $_ in Foreach-Object in regular pwsh. You can also access properties this way:

 1<#
 2 deskitem.tests.ps1
 3    Mind the assignment of complex objects during BeforeDiscovery and the looping over all items and a subset of items.
 4#>
 5BeforeDiscovery {
 6    $itemsOnMyDesk = @(
 7        @{
 8            Name  = 'Pencil'
 9            Count = 3
10        },
11        @{
12            Name           = 'GamePC'
13            Count          = 1
14            Color          = 'White'
15            NoiseLevel     = $true
16            InstalledGames = @(
17                'Diablo 4',
18                'Supreme Commander: Forged Alliance Forever',
19                'Battlefield V'
20            )
21        }
22    )
23}
24Describe "Desktop items" -ForEach $itemsOnMyDesk {
25    It "<_.Name> has a Count-property" {
26        $_.Count | Should -Not -BeNullOrEmpty
27    }
28    BeforeDiscovery {
29        $PC = $_ | Where-Object Name -EQ 'GamePC'
30    }
31    Context "GamePC" -Foreach $PC.InstalledGames {
32        It "Should not be a timesink (<_>)" {
33            $_ | Should -Not -Be "Satisfactory"
34        }
35    }
36}

This gives us the following results:

 1Invoke-Pester '/Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/tests.tests.ps1' -Output Detailed
 2Pester v5.7.1
 3
 4Starting discovery in 1 files.
 5Discovery found 5 tests in 8ms.
 6Running tests.
 7
 8Running tests from '/Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/tests.tests.ps1'
 9Describing Desktop items
10  [+] Pencil has a Count-property 1ms (1ms|1ms)
11
12Describing Desktop items
13  [+] GamePC has a Count-property 4ms (4ms|1ms)
14 Context GamePC
15   [+] Should not be a timesink (Diablo 4) 3ms (1ms|2ms)
16 Context GamePC
17   [+] Should not be a timesink (Supreme Commander: Forged Alliance Forever) 1ms (1ms|1ms)
18 Context GamePC
19   [+] Should not be a timesink (Battlefield V) 1ms (1ms|1ms)
20Tests completed in 60ms
21Tests Passed: 5, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0

The Pester file above tests items in a predefined set of test cases/items to test. You can access the current looped object in the titles, loop over their properties, etc. I personally like to put the BeforeDiscovery-blocks close to where they are used if they are only used once, but you can also choose to place the BeforeDiscovery-blocks at the top of the describe block.

The Power of Discovery

If we spice up our module a bit, we can start to discover why this Discover phase can be so powerful. The final section of this blog will be without the training wheels; it’s just a repetition of earlier mentioned concepts. Don’t get intimidated by the amount code; much is still the same, and the Pester code just has some setup for scale.

We all want to know what parameters for functions do, so it can be convenient to check at least all your public functions for comment-based help and parameter descriptions.

I borrowed this code of Brandon Olin his excellent Stucco PowerShell Module template engine, who borrowed it (with love) from juneb on GitHub.

Consider the following function definitions:

 1# New-DummyOutput.ps1
 2function New-DummyOutput {
 3    <#
 4        .SYNOPSIS
 5     A short one-line action-based description, e.g. 'Tests if a function is valid'
 6        .DESCRIPTION
 7     A longer description of the function, its purpose, common use cases, etc.
 8        .NOTES
 9     Information or caveats about the function e.g., 'This function is not supported in Linux.'
10        .LINK
11     Specify a URI to a help page, this will show when Get-Help -Online is used.
12        .EXAMPLE
13     Test-MyTestFunction -Verbose
14
15     Explanation of the function or its result. You can include multiple examples with additional .EXAMPLE lines
16     #>
17    [CmdletBinding()]
18    param (
19        # I am a very helpful parameter
20        [Parameter()]
21        [String]
22        $message
23    )
24    "I am a public Function: $message"
25}
26# New-DummyOutput2.ps1
27function New-DummyOutput2 {
28    [CmdletBinding()]
29    param (
30        [Parameter()]
31        [String]
32        $message
33    )
34    "I am a public Function: $message"
35}

I added ‘ProgressAction’ to $commonParams that was added in PowerShell 7.4. FilterOutCommonParams is a function with an access modifier so that the function is available both in the discovery phase and outside of it. Then, we import the module, just like we did earlier.

 1BeforeDiscovery {
 2    function global:FilterOutCommonParams {
 3        param ($Params)
 4        $commonParams = @(
 5            'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable',
 6            'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction',
 7            'WarningVariable', 'Confirm', 'Whatif', 'ProgressAction'
 8        )
 9        # We're not interested in these, we just want the custom parameters
10        $params | Where-Object { $_.Name -notin $commonParams } | Sort-Object -Property Name -Unique
11    }
12    # We need to import our own module, so we can iterate over it's functions/cmdlets
13    Import-Module -Name "$PSScriptRoot/testModule" -Force -Verbose:$false -ErrorAction Stop
14    # Create a splat for get
15    $getCommandsplat = @{
16        Module      = (Get-Module testModule)
17        CommandType = [System.Management.Automation.CommandTypes[]]'Cmdlet, Function' # Not alias
18    }
19    if ($PSVersionTable.PSVersion.Major -lt 6) {
20        $getCommandsplat.CommandType[0] += 'Workflow'
21    }
22    #Commands is available in all subsequent BeforeDiscovery Blocks and can be used in ForEach
23    $commands = Get-Command @getCommandsplat
24
25    ## When testing help, remember that help is cached at the beginning of each session.
26    ## To test, restart session.
27}

From there on, we make sure we get just the cmdlets and functions from our module, with a conditional statement to also make sure to add in workflows for earlier versions of PowerShell. No idea what those are; not relevant for now. The found (public) functions and cmdlets are stored in a variable called $commands.

Then you’ll see the Describe keyword as we had earlier with a ForEach on that $commands variable, effectively running all of the tests in the Describe for each command in $commands. And it references the name of the current command in the ForEach loop. This is similar to $_ in foreach-object, representing the current item.

1Describe "Test help for <_.Name>" -ForEach $commands {

When ran it’ll look like:

1Describing Test help for New-DummyOutput

Then another BeforeDiscovery block is created inside of Describe with several variables; this is still within that loop over $commands. The writer of the Pester file wants to access the properties of the current item in the loop so that they can use them to iterate over again. A use case you could think of is testing multiple parameters for a single command, maybe? I usually put BeforeDiscovery blocks directly above the blocks I am going to use them in, or when I use the Discovery in multiple places, I place them on top in their shared parent.

1 BeforeDiscovery {
2    # Get command help, parameters, and links
3    $command = $_
4    $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue
5    $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters
6    $commandParameterNames = $commandParameters.Name
7    $helpLinks = $commandHelp.relatedLinks.navigationLink.uri
8 }

The BeforeAll-block with the same code makes sure that we have the same information available within our Run-phase. One important item is that in a loop in the Run phase, $_ references the same $commands item as in the discovery phase. The rest of the BeforeAll block makes use of that mechanism, pointing to that same $command-variable.

 1 BeforeAll {
 2    # These vars are needed in both the discovery and test phases, so we need to duplicate them here
 3    $command = $_
 4    $commandName = $_.Name
 5    $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue
 6    $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters
 7    $commandParameterNames = $commandParameters.Name
 8    $helpParameters = global:FilterOutCommonParams -Params $commandHelp.Parameters.Parameter
 9    $helpParameterNames = $helpParameters.Name
10 }

Then, several tests follow based on the $commandHelp help-object. It even includes a fancy HelpLinks-loop over the $helpLinks defined in the help documentation, ensuring that all related links are valid and accessible.

It loops again over each individual custom parameter after omitting the common parameters with the FilterOutCommonParams-function and continues to test each custom-defined parameter.

  1# Help.Tests.ps1
  2BeforeDiscovery {
  3
  4    function global:FilterOutCommonParams {
  5        param ($Params)
  6        $commonParams = @(
  7            'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable',
  8            'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction',
  9            'WarningVariable', 'Confirm', 'Whatif', 'ProgressAction'
 10        )
 11        $params | Where-Object { $_.Name -notin $commonParams } | Sort-Object -Property Name -Unique
 12    }
 13    Import-Module -Name "$PSScriptRoot/testModule" -Force -Verbose:$false -ErrorAction Stop
 14    $params = @{
 15        Module      = (Get-Module testModule)
 16        CommandType = [System.Management.Automation.CommandTypes[]]'Cmdlet, Function' # Not alias
 17    }
 18    if ($PSVersionTable.PSVersion.Major -lt 6) {
 19        $params.CommandType[0] += 'Workflow'
 20    }
 21    $commands = Get-Command @params
 22
 23    ## When testing help, remember that help is cached at the beginning of each session.
 24    ## To test, restart session.
 25}
 26
 27Describe "Test help for <_.Name>" -ForEach $commands {
 28
 29    BeforeDiscovery {
 30        # Get command help, parameters, and links
 31        $command = $_
 32        $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue
 33        $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters
 34        $commandParameterNames = $commandParameters.Name
 35        $helpLinks = $commandHelp.relatedLinks.navigationLink.uri
 36    }
 37
 38    BeforeAll {
 39        # These vars are needed in both the discovery and test phases, so we need to duplicate them here
 40        $command = $_
 41        $commandName = $_.Name
 42        $commandHelp = Get-Help $command.Name -ErrorAction SilentlyContinue
 43        $commandParameters = global:FilterOutCommonParams -Params $command.ParameterSets.Parameters
 44        $commandParameterNames = $commandParameters.Name
 45        $helpParameters = global:FilterOutCommonParams -Params $commandHelp.Parameters.Parameter
 46        $helpParameterNames = $helpParameters.Name
 47    }
 48
 49    # If help is not found, synopsis in auto-generated help is the syntax diagram
 50    It 'Help is not auto-generated' {
 51        $commandHelp.Synopsis | Should -Not -BeLike '*`[`<CommonParameters`>`]*'
 52    }
 53
 54    # Should be a description for every function
 55    It "Has description" {
 56        $commandHelp.Description | Should -Not -BeNullOrEmpty
 57    }
 58
 59    # Should be at least one example
 60    It "Has example code" {
 61     ($commandHelp.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty
 62    }
 63
 64    # Should be at least one example description
 65    It "Has example help" {
 66     ($commandHelp.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty
 67    }
 68
 69    It "Help link <_> is valid" -ForEach $helpLinks {
 70     (Invoke-WebRequest -Uri $_ -UseBasicParsing).StatusCode | Should -Be '200'
 71    }
 72
 73    Context "Parameter <_.Name>" -Foreach $commandParameters {
 74
 75        BeforeAll {
 76            $parameter = $_
 77            $parameterName = $parameter.Name
 78            $parameterHelp = $commandHelp.parameters.parameter | Where-Object Name -EQ $parameterName
 79            $parameterHelpType = if ($parameterHelp.ParameterValue) { $parameterHelp.ParameterValue.Trim() }
 80        }
 81
 82        # Should be a description for every parameter
 83        It "Has description" {
 84            $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty
 85        }
 86
 87        # Required value in Help should match IsMandatory property of parameter
 88        It "Has correct [mandatory] value" {
 89            $codeMandatory = $_.IsMandatory.toString()
 90            $parameterHelp.Required | Should -Be $codeMandatory
 91        }
 92
 93        # Parameter type in help should match code
 94        It "Has correct parameter type" {
 95            $parameterHelpType | Should -Be $parameter.ParameterType.Name
 96        }
 97    }
 98
 99    Context "Test <_> help parameter help for <commandName>" -Foreach $helpParameterNames {
100
101        # Shouldn't find extra parameters in help.
102        It "finds help parameter in code: <_>" {
103            $_ -in $parameterNames | Should -Be $true
104        }
105    }
106}

Running it gives the following results:

 1Invoke-Pester /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1 -Output Detailed
 2Pester v5.7.1
 3
 4Starting discovery in 1 files.
 5Discovery found 14 tests in 84ms.
 6Running tests.
 7
 8Running tests from '/Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1'
 9Describing Test help for New-DummyOutput
10  [+] Help is not auto-generated 4ms (3ms|1ms)
11  [+] Has description 6ms (6ms|0ms)
12  [+] Has example code 3ms (2ms|0ms)
13  [+] Has example help 2ms (2ms|0ms)
14 Context Parameter message
15   [+] Has description 4ms (2ms|2ms)
16   [+] Has correct [mandatory] value 9ms (8ms|0ms)
17   [+] Has correct parameter type 3ms (2ms|0ms)
18
19Describing Test help for New-DummyOutput2
20  [-] Help is not auto-generated 12ms (11ms|1ms)
21   Expected like wildcard '*`[`<CommonParameters`>`]*' to not match '
22   New-DummyOutput2 [[-message] <string>] [<CommonParameters>]
23   ', but it did match.
24   at $commandHelp.Synopsis | Should -Not -BeLike '*`[`<CommonParameters`>`]*', /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:50
25   at <ScriptBlock>, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:50
26  [-] Has description 37ms (36ms|0ms)
27   Expected a value, but got $null or empty.
28   at $commandHelp.Description | Should -Not -BeNullOrEmpty, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:55
29   at <ScriptBlock>, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:55
30  [-] Has example code 4ms (4ms|0ms)
31   Expected a value, but got $null or empty.
32   at ($commandHelp.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:60
33   at <ScriptBlock>, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:60
34  [-] Has example help 6ms (6ms|0ms)
35   Expected a value, but got $null or empty.
36   at ($commandHelp.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:65
37   at <ScriptBlock>, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:65
38 Context Parameter message
39   [-] Has description 15ms (12ms|3ms)
40    Expected a value, but got $null or empty.
41    at $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:83
42    at <ScriptBlock>, /Users/christianpiet/Documents/InSpark/Git/Personal/blog/content/script/Help.tests.ps1:83
43   [+] Has correct [mandatory] value 1ms (1ms|0ms)
44   [+] Has correct parameter type 6ms (5ms|0ms)
45Tests completed in 315ms
46Tests Passed: 9, Failed: 5, Skipped: 0, Inconclusive: 0, NotRun: 0

So we can see 9 tests passed (the ones for New-DummyOutput) and New-DummyOutput2 still requires some love.

This approach works because everything in PowerShell is an object, even the help documentation. So, by creating a set of standards to which you want your code to submit, you can make a scaleable test suite for your modules.

Conclusion

In this comprehensive guide, we’ve explored the fundamentals of Pester, a powerful testing framework for PowerShell. We’ve covered everything from setting up your environment and understanding the basic structure of Pester tests to leveraging advanced features like Discovery and InModuleScope for testing private functions.

By adopting Pester, you can ensure the reliability and maintainability of your PowerShell code, leading to fewer bugs and increased confidence in your PowerShell code. The ability to automate testing and validate your code against predefined standards is invaluable in any development workflow.

While this post provides a solid foundation, there’s always more to learn. I encourage you to dive deeper into the Pester documentation, experiment with the examples provided, and explore the vast array of assertions and mocking capabilities that Pester offers. Happy testing!

#pester #powershell #unit testing #testing framework