DEV Community

Olivier Miossec
Olivier Miossec

Posted on

Unit testing in PowerShell, introduction to Pester

When people (at least IT people) thinks about PowerShell, they think about some magic commands and maybe about some scripts and sometime more.
PowerShell is more than that. It’s not only a sequential programming language it’s also a functional language. You can create functions and modules, but not only you can use it as configuration management with DSC and other tools, you can use it in serverless platforms like AWS Lambda or Azure Functions, you can build cloud solutions either on Azure or AWS. You can build complete solutions with PowerShell.

Even if PowerShell is seen as an Ops tools it follows the same methods and patterns than any other programming language.
Unit testing is one of these patterns. It ensures that section by section codes work as expected.

PowerShell own a Unit testing framework. Its name is Pester, it’s the ubiquitous test and mock framework for PowerShell. It’s a Domain Definition Language and a set of tools to run unit and acceptance test.

Installing Pester

Even if Pester is now installed by default on Windows 10, it’s better to update it to the latest version (now in 4.8.x, soon in 5.x).
Pester can be installed on Windows PowerShell (even on old version) and on PowerShell Core

Install-module -name Pester 
Enter fullscreen mode Exit fullscreen mode

You may need to use the force parameter if another is already installed

Install-module -name Pester -force
Enter fullscreen mode Exit fullscreen mode

Yes, you don’t need to use -SkipPublisherChek anymore, the Pester module is signed now.

The basic

A test script starts with a Describe. Describe block create the test container where you can put data and script to perform your tests. Every variable created inside a describe block is deleted at the end of execution of the block.

Describe {
   # Test Code here
}
Enter fullscreen mode Exit fullscreen mode

you can add tags and a name to each describe block to define testing scenario

Describe -tag "SQL" -name "test1" {
 # Test Code here
}

Describe -tag "SQL" -name "test2" {
 # Test Code here
}
Enter fullscreen mode Exit fullscreen mode

Tests inside a describe block can be grouped into a Context.
Context is also a container and every data or variable created inside a context block is deleted at the end of the execution of the context block
Context and Describe define a scope.

Describe -tag "SQL" -name "Sqk2017" {
    # Scope Describe
    Context "Context 1" {
        # Test code Here
        # Scope describe 1
    }

    Context "Context 2" {
        # Test code Here
        # Scope describe 2
    }

}
Enter fullscreen mode Exit fullscreen mode

It Block

To create a test with Pester we simply use the keyword It. The It blocks contain the test script. This script should throw an exception.
It blocks need to have an explicit description It "return the name of something" and a script block. The description must be unique in the scope (Describe or Context).
It description can be static or dynamically created (ie: you can use a variable as description as long as the description is unique)

The command Should let you to compare objects, something from your code against something expected, and throw an error if the It bloc fail.
Should introduce an assertion, something that must be true and if not throw an error.

There are several assertions:

ASSERTIONS DESCRIPTION
Be Compare the 2 objects
BeExactly Compare the 2 objects in case sensitive mode
BeGreaterThan The object must be greater than the value
BeGreaterOrEqual The object must be greater or equal than the value
BeIn test if the object is in array
BeLessThan The object must be less than the value
BeLessOrEqual The object must be less or equal than the value
BeLike Perform a -li comparaison
BeLikeExactly Perform a case sensitive -li comparaison
BeOfType Test the type of the value like the -is operator
BeTrue Check if the value is true
BeFalse Check if the value is false
HaveCount The array/collection must have the specified ammount of vallue
Contain The array/collection must contain the value, like the -contains operator
Exist test if the object exist in a psprovider (file, registry, ...)
FileContentMatch Regex comparaison in a text file
FileContentMatchExactly Case sensitive regex comparaison in a text file
FileContentMatchMultiline Regex comparaison in a multiline text file
Match RegEx Comparaison
MatchExactly Case sensitive RegEx Comparaison
Throw Check if the ScriptBlock throw an error
BeNullOrEmpty Checks if the values is null or an empty string
Describe "test" {
    It "true is not false" {
        $true | Should -Be $true
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see the It block is divided into two parts. The first one, the name, identify the test. The second one, the Script bock can contain any code you need as long as you pass the result to a Should statement by Pipeline. The should Statement will evaluate the data and throw an error if the condition of the statement aren’t meet.
We can also build negative assertion with the -not keyword

Describe "test" {
    It "true is never false" {
        $true | Should -not -Be $false
    }
}
Enter fullscreen mode Exit fullscreen mode

When I write unit tests, often I need to test the same portion of code or function several times with different conditions or different data. As I am lazy, I don’t like to write the same test more than one or maybe two times.
For that I just need a dictionary object and -testcase

$BufferSize = 4
$DataTypeName = [System.Byte[]]::new($BufferSize)
$RandomSeed = [System.Random]::new()
$RandomSeed.NextBytes($DataTypeName)

$TestData =  @(@{"TestValue" = "Value One"; "TestType" = "String"},@{"TestValue" = 2; "TestType" = "int32"},@{"TestValue" = $DataTypeName; "TestType" = "Byte[]"}) 

function get-DataTypeName {
    [CmdletBinding()]
    param (
        $value
    )
    return ($value.getType()).name
}



Describe "test some values" {
    It "Test if <TestValue> is a <TestType> Object"  -TestCase $TestData {
        param($TestValue, $TestType)
        get-DataTypeName -value $TestValue | Should -Be $TestType
    }
}

Enter fullscreen mode Exit fullscreen mode

When you use -TestCase with a dictionary object, the It block will iterate the object and perform a test for each iteration.
To display the value in the It description the <> is needed. Inside the It bloc; you need to pass the data as parameters and get their value in parameters variables

On how to write and run Pester tests

You can virtually run pester in any PowerShell file but by convention, it’s recommended to use a file named xxx.tests.ps1. One per PowerShell file in your solution. If you have one file per function it mean one pester .tests.ps1.
You can place pester files in the same folder as your source code or you can use a tests folder at the root of the repository.
Personally, I prefer to use a tests folder but wherever the test files are, they need to load the code.

To do that you can dot source the script you need to test.

To run all the tests in the .tests.ps1 files, you just need to use invoke-pester from the tests folder.
If the It and Describe blocs are the heart of the testing process, invoke-pester can be the liver as it can filter tests and transform the result.

By using -Testname or/and -Tag parameter in conjunction with -passThru you can filter describe bloc with the name of the test or the tags.

The -Strict param allow to consider any pending or skyped test will create a fail.

By default, pester produce the result directly as a text in the console. In some situation you may want to use another format.

The -PassThru parameter let you produce a custom PowerShell object instead of standard output. It produces a PSCustomObject with the number of tests, the number of passed, skipped, pending or failed. It contains also an array, TestResult with all the test result.

The -EnableExist switch will make invoke-pester exit with an exit code. This code is 0 if all tests pass or the number of failled test. This can be usefull is some continuous integration scenarios.

OutputFile and OutputFormat parameters will redirect the output to a file in NUnit xml format. By default, Pester 4.x will output the result using NUnit format using 2.5 Schema. You cannot change the format event with -OutputFormat (there is only one possible value NUnitXml). Using the Output can be useful with some CI tool which display Nunit test result

Code Coverage

Code coverage measure the degree to which your code is executed by your tests. The measure is expressed in a percentage.
The coverage percentage don't mean that the module or the script is bug free, it only mean that the code has been run by during the test process.

To generate code coverage metric with Pester you need to use invoke-pester with -coverage following the path of the script you need to test.

    invoke-pester -script .\function.test.ps1 -coverage .\function.ps1
Enter fullscreen mode Exit fullscreen mode

You can use wildcard and a coma to analyze multiple files

    invoke-pester -script .\functions.test.ps1 -coverage .\function.ps1, .\*.psm1
Enter fullscreen mode Exit fullscreen mode

You can also use a hashtable for coverage. It must contain the Path key. You can use the Function key to limit the analyze to these functions or you can use StartLine or EndLine to limit the analyze.

Note that having a 100% coverage do not implies that the code is bug free, it is just an indication that all your code has been executed during the test

Advanced usage

Test Drive

A function may need to manipulate the file system, it can create, delete or modify one or more files. It isn't desirable to change file system during the test phase.
Pester provide a drive named TestDrive:. The drive is available in the scope of the test (Describe or Context) and automatically deleted at the end of the scope.
When using the test drive, Pester create a random folder in $env:tmp and use it to put file from the current test drive in the scope.
You can use testdrive:\ or the $testdrive variable to perform any file operation during the test.

Describe "test" {

    new-item (Join-Path $TestDrive 'File.txt') 

    It "Test if File.txt exist" {
       (test-path -path (Join-Path $TestDrive 'File.txt')  ) | Should -Be $true
    }
}
Enter fullscreen mode Exit fullscreen mode

Mocking

Scripts and functions rely on modules and function that may not be present on the test machine, or perform a destructive operation.
You design a function that create or delete an active directory account.
you may not have any active directory module on the test computer and/or you do not want to make any change on production computer
You create a module that change the configuration of the server and you don't want to change anything during the test phase.
You just need to be sure that the code is correct.

Mocking in Pester, let you imitate the result of a function or command called during the test. When you use Mock, you simply create the result for a given command so you can test other part of your script.

To mock a function or a command you simply need to use Mock and the name of the function or the command.

Describe "test" {

    Mock remove-something { }

    It "Test if remove-something return null" {
       remove-something | Should  BeNullOrEmpty 
    }
}
Enter fullscreen mode Exit fullscreen mode

Mocking is limited by the scope of the test, either Describe or Context. If you need to test different behavior you can use -ParameterFilter to create different result

Describe "test" {

    Mock Get-AswerAboutLifeUniverseEverything { return 42 }  -ParameterFilter { $Name -eq "42" }
    Mock Get-AswerAboutLifeUniverseEverything { return 42 }  -ParameterFilter { $Name -eq "57" }
    Mock Get-AswerAboutLifeUniverseEverything { return 42 }

    It "Test if Get-AswerAboutLifeUniverseEverything return 42" {
       Get-AswerAboutLifeUniverseEverything | Should  be 42 
    }
    It "Test if Get-AswerAboutLifeUniverseEverything -Name 42 return 42" {
       Get-AswerAboutLifeUniverseEverything -Name 42 | Should  be 42 
    }
    It "Test if Get-AswerAboutLifeUniverseEverything -Name 57 return 42" {
       Get-AswerAboutLifeUniverseEverything -Name 57 | Should  be 42 
    }
}
Enter fullscreen mode Exit fullscreen mode

You can use Mock inside an IT block, in this case Mock will be available in the parent scope.

You may want to test if a mocked command was called during the test phase. It's possible to test this in 2 ways

Assert-VerifiableMocks, throw an error if a Mock command marked as -verifiable

Describe "test" {

    Mock Get-AswerAboutLifeUniverseEverything { return 42 }  -Verifiable 


    It "Test if remove-something return 42" {
       Get-AswerAboutLifeUniverseEverything | Should  be 42 
    }

    Assert-VerifiableMocks

}
Enter fullscreen mode Exit fullscreen mode

Another way to test the execution of a mocked command is to use Assert-MockCalled.
Assert-MockCalled must be placed inside an IT block. You need to use it with the -CommandName parameter. The Parameter take the name of the mocked command

Describe "test" {

    Mock Get-AswerAboutLifeUniverseEverything { return 42 } 

    It "Test if remove-something return 42" {
       Get-AswerAboutLifeUniverseEverything | Should  be 42 
    }

    It "Test if remove-something was called" {
       Assert-MockCalled -CommandName Get-AswerAboutLifeUniverseEverything
    }
}
Enter fullscreen mode Exit fullscreen mode

You can control how many times the mocked command was called with -times parameter. The Times X parameter test if the mocked command was called at least X times.
If you need to test that the mocked command is called only X times you can use -Exactly

When mocking a function or a cmdlet, you may need to return an object. For example Get-AdUser return a Microsoft.ActiveDirectory.Management.ADUser object. Most of the time you can use a PScustom object to mock the returned object

    Describe "test" {

        Mock Get-AdUser { 

        $AObject = [PSCustomObject]@{
        Surname         = "Marc"
        UserPrincipalName   = "marc@mydomain.com"
        Enabled             = $true
        SamAccountName      = "marc"
        ObjectClass         = "user"
        }
        Return $AdObject
        }  -ParameterFilter { $Identity -eq "Marc" }


        It "Test if the User is Valid" {
        (Get-AdUser -identity Marc).Enabled | Should  betrue 
        }

        It " Test if the User UPN " {
        (Get-AdUser -identity Marc).UserPrincipalName | Should  be marc@mydomain.com
        }
    }
Enter fullscreen mode Exit fullscreen mode

But sometime a PsCustomObject is not enough. This is the case when you use class in your script.
You can use New-MockObject to return any type of object. New-MockObject fake any type of object without the need to create it. You can use it to mock any .Net Type or you own class-based type

Mock Get-Something {
    New-MockObject  -type 'System.Diagnostics.Process'
}
Enter fullscreen mode Exit fullscreen mode

In some situation, you may need to execute some code before of after the tests. It could be variable initialization or environment setup.

For example, you can create some files inside a test drive, open a connection to webservice, create random data.
BeforeAll and AfterAll must reside inside a Describe or Context Block. It can be placed anywhere inside the block but it's better to place BeforeAll at the beginning and after all at the end.

BeforeEach and AfterEach run on each IT Block in the same way as BeforeAll/AfterAll

Module Testing

Pester can be used to test PowerShell module. But remember, pester need to access to the code to test code.

First step, as module are loaded in memory you need to instruct PowerShell to remove the module. If not, you may not test the actual version of your code.

Get-module -name TheModule -all | remove-module -force -erroraction SilentContinue 
Enter fullscreen mode Exit fullscreen mode

You can import the module

Import-Module -name PathTo\TheModule.psd1 -force -ErrorAction Stop
Enter fullscreen mode Exit fullscreen mode

Problems, doing so you will only have access to public function declared inside FunctionToExport.
To avoid that, you need to use InModuleScope

InModuleScope TheModule {
    Describe 'Module Test' {
        It 'test one not exported function' {
            Get-NotExportFunction | Should not Throw 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This was my introduction to Pester. Tell me what you thinks

Top comments (6)

Collapse
 
xero399 profile image
xero399

First of all, thanks you Olivier for this AWESOME introduction to Pester!

I'm trying to mock a function but isn't working as expected. The mocking is:

Mock Get-PnPList{return @{"Title"="testList"}} -ParameterFilter { $Identity -eq "testList" }
Mock Get-PnPList{return $null } -ParameterFilter { $Identity -ne "testList" }

The script code is:

function checkListExists{
Param([Parameter(Mandatory = $true)][string] $listTitle)
$list = Get-PnPList -Identity $listTitle
if($null -eq $list){
return $false
}
else{
return $true
}
}

And finally I'm calling

checkListExists "testList" | Should -BeTrue

But the test fails: "Expected $true, but got $false."

I'm debugging and the parameter -Identity $listTitle of the Get-PnPList function is receiving the value "testList".

Why the moking is not working as expected?

Thanks!

Collapse
 
omiossec profile image
Olivier Miossec

I plan to write a post about mocking in Pester

You should try to do somehitng like that

Mock Get-PnPList -MockWith {
[pscustomobject]@{
"Title" = "testList"
}
} -ParameterFilter { $Identity -eq "testList" }

Collapse
 
xero399 profile image
xero399

Hi, thanks for the answer. Tried like you said but doesn't work. Finally I have found that the problem was the -ParameterFilter. Doing $Identity.toString() -eq "testList" it works perfectly. That .toString() makes the difference!

Collapse
 
shengeveld profile image
shengeveld

Hi Olivier,

Thanks for the post. I do still have a scenario that I can't get to work.

function Get-ResourceGroupName {
    param (
        $someParameter
    )
    [...]
    return (Get-AzResource -name $storageAccountName).ResourceGroupName
}

The test is in another file:

Import-Module "$here/../../../test/utility.psm1" 

Describe 'Utility Unit Tests' -Tag 'unit' {
It " Retrieves storage context for a given storage account" {

        function Get-AzResource($name) { 
            $KeyObject = [PSCustomObject]@{
                ResourceGroupName = "RG100-O"
            }
            Return $KeyObject
        } 

        Get-ResourceGroupName -someParameter "https://bla.blob.core.windows.net/blobcontainer/testfile.txt" `
            | Should -Be "RG100-O"
    }
}

When running this test, I get the error that my Azure credentials have not been set, indicating the function call in the other function was not mocked. Hence, the scoping solution does not seem to have the desired effect.

Do you know how to solve this?

Collapse
 
shengeveld profile image
shengeveld

Solved!

The issue seemed to be session related. When I define the Get-AzResource function in the BeforeAll, it works like a charm.


BeforeAll {
    function Get-AzResource($name) { 
        $KeyObject = [PSCustomObject]@{
            ResourceGroupName = "RG100-O"
        }
        Return $KeyObject
    } 
}

Describe 'Utility Unit Tests' -Tag 'unit' {

    It " Retrieves storage context for a given storage account" {
Collapse
 
melezhik profile image
Alexey Melezhik

Alternatively you may try Tomtit for simple yet efficient blackbox testing of Powershell code.