Unit Testing Powershell with Pester

I write a lot of Powershell these days; it’s my go-to language for quick jobs that need to interact with external systems, like an API, a DB, or the file system for example.

Nothing to configure, nothing to deploy, nothing to set up really; just hack a script together and run it. Perfect for one-off little tasks.

I’ve used it for all manner of things in my career so far, most notably for Azure automation back before Azure had decent automation in place. We’re talking pre-Resource Manager environment creation stuff.

I would tie together a suite of separate scripts which would individually:

  • create a Storage account,
  • get the key for the Storage account,
  • create a DB instance,
  • execute a DB initialisation script,
  • create a Service Bus,
  • execute a Service Bus initialisation script,
  • deploy Cloud Services,
  • start/stop/restart those services

Tie that lot together and I could spin up an entire environment easily.

powershell_azure_createdb

Yeah, but I’ve also used Powershell for completely random things, like:

  • taking a list of email addresses which has been used to sign up on a web site,
  • grab the MX record from each email address’s domain,
  • TELNET into that MX endpoint,
  • attempt to create a new email with the recipient set to that email address,
  • listen out for a specific error code (550) which is returned when the user does not exist

All to validate a sign up list for marketing purposes! Once small script achieves this madness.

Powershell is the glue that holds a windows dev team together (unless there’s some actual Ops knowledge in that team..), allowing automation of non-functional requirements and repetitive tasks.

So what’s my point?

Who writes oodles of Powershell scripts, just like I do? Loads of you, I’ll bet.

Who writes tests for their Powershell scripts? I don’t. I doubt you do either.

Yet we rely on these scripts every bit as much as application code we push into a production environment; just because we don’t necessarily run them as often doesn’t make them any less fragile.

Enter Pester

“Pester provides a framework for running unit tests to execute and validate Powershell commands from within Powershell”

After installing a pester module, you can invoke a command to create boilerplate function and test files – New-Fixture.

Subsequently running Invoke-Pester will execute the Powershell file ending with the name .Tests.ps1 against the other file that just ends in .ps1.

Pester allows mocking of various core commands, for example Get-ChildItem can be configured to always return a known set of items in order to validate functionality which may fork based on the contents of a directory.

Not just that, calls to these mocks can be asserted. Sounds to me like we have a basis for making our Powershell scripts testable and ultimately more reliable!

Working example

Let’s look at a simplified version of a script I was recently asked to write to automate a very labour-intensive task someone had been asked to do;

  • for a directory of images
  • if the image ends with “_A01.JPG”, create a duplicate and end that duplicate’s name with “_A99.JPG” instead of “_A01.JPG”
  • if the image ends with “_ABC.JPG”, ignore it
  • else create a duplicate and append “_A99.JPG” to the name

Looks like a great candidate for a Powershell script, right? You would probably jump right in and create a new directory, paste in an image and copy that image a few times, changing the name to match the use cases you’re working on.

Then you’d run the script against those test images, see if it worked, then delete the new images each time you wanted to run it again.

How about we do this properly instead?

1) Install Pester

Super simple:

Install-Module Pester

EASY, right?!

You do need PSGet installed in order to use Install-Module; PSGet is like Chocolatey for Powershell modules. Don’t have PSGet? Go and install it!

2) Create a New-Fixture

Strangely enough, this is done by creating a new directory for your project and running:

New-Fixture -Name ImageRenamer

Which will result in two new files being created:

ImageRenamer.ps1                                                                                                        
ImageRenamer.Tests.ps1  

The files contain the following boilerplate code, respectively:

function ImageRenamer {

}

and

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path).Replace(".Tests.", ".")
. "$here\$sut"

Describe "ImageRenamer" {
    It "does something useful" {
        $true | Should Be $false
    }
}

3) Run the tests

To execute your test script, you simply use:

Invoke-Pester

which, given that rubbish boilerplate test script, results in:

Describing ImageRenamer
 [-] does something useful 124ms
   Expected: {False}
   But was:  {True}
   at line: 7 in C:\pester\ImageRenamer.Test
s.ps1
   7:         $true | Should Be $false
Tests completed in 124ms
Passed: 0 Failed: 1 Skipped: 0 Pending: 0

4) Implement your own code

First up I’ll call the ImageRenamer function within the test:

# ImageRenamer.Tests.ps1

Describe "ImageRenamer" {
    It "should create a new image" {
        ImageRenamer
    }
}

Now I want to be able to check the image is created, right? Let’s implement some code to do that:

# ImageRenamer.ps1
function ImageRenamer {
    $images = Get-ChildItem -Filter "*.JPG"  .\

    foreach($image in $images)
    {
        Copy-Item `
            $image `
            $image.Name.replace(".JPG", "_A99.JPG")
    }
}

But since we don’t have any images, and we aren’t going to make any just to test our script, let’s get MOCKING!

5) Mocking

Our script currently depends on two core Powershell functions: Get-ChildItem and Copy-Item. Let’s mock em.

# ImageRenamer.Tests.ps1

Describe "ImageRenamer" {
    It "should create a new image" {
        Mock Get-ChildItem {
            [PSCustomObject] `
            @{ Name = 'shouldbecopied.JPG'; }
        }   

        Mock Copy-Item {}

        ImageRenamer

        Assert-MockCalled Copy-Item -Times 1
    }
}

So what’s happening here?

Mock Get-ChildItem {
    [PSCustomObject] `
    @{ Name = 'shouldbecopied.JPG'; }
}

When Get-ChildItem is called, return a custom object that contains a property called Name set to shouldbecopied.JPG

The other mock:

Mock Copy-Item {}

has an assertion

Assert-MockCalled Copy-Item -Times 1

The mock Copy-Item doesn’t do anything, but it’s verifiable so I can check that it was called a specific number of times. Running Invoke-Pester now results in:

Describing ImageRenamer
 [+] should create a new image 51ms
Tests completed in 51ms
Passed: 1 Failed: 0 Skipped: 0 Pending: 0

Hooray! Let’s break it, just to check…

Mock Get-ChildItem {
        @(
            [PSCustomObject]`
            @{ Name = 'shouldbecopied.JPG'; },`
            [PSCustomObject]`
            @{ Name = 'shouldbecopiedalso.JPG'; }
        )
    }   

This should fail, right? We’re passing in two images, so Copy-Item should get called twice instead of once.

Describing ImageRenamer
 [+] should create a new image 111ms
Tests completed in 111ms
Passed: 1 Failed: 0 Skipped: 0 Pending: 0

…uh… wut?

Here’s a little secret: -Times actually means “at least this many times”. We need to change it to:

Assert-MockCalled Copy-Item -Times 1 -Exactly

which results in

Describing ImageRenamer
 [-] should create a new image 107ms
   Expected Copy-Item to be called 1 times exactly but was called 2 times
   at line: 516 in C:\Program Files\WindowsPowershell\Modules\Pester\3.3.5\Funct
ions\Mock.ps1
Tests completed in 107ms
Passed: 0 Failed: 1 Skipped: 0 Pending: 0

Thaaaat’s better!

Full scripts

Remember those example requirements?

  • for a directory of images
  • if the image ends with “_A01.JPG”, create a duplicate and end that duplicate’s name with “_A99.JPG” instead of “_A01.JPG”
  • if the image ends with “_ABC.JPG”, ignore it
  • else create a duplicate and append “_A99.JPG” to the name

First up: if the image ends with “_A01.JPG”, create a duplicate and end that duplicate’s name with “_A99.JPG” instead of “_A01.JPG”

Test

Context "if ends in '_A01', create duplicate replacing '_A01' with '_A99' " {
    It "should create a duplicate with a new prefix" {
        Mock Get-ChildItem {
            @(
                [PSCustomObject]@{ Name = 'img_A01.JPG'; }
            )
        }

        Mock Copy-Item -ParameterFilter { 
            $Path.EndsWith("_A01.JPG") `
            -and $Destination.EndsWith("_A99.JPG") `
            -and -not $Destination.Contains("_A01")
        }

        ImageRenamer

        Assert-MockCalled Copy-Item -Times 1 -Exactly
    }
}

Context is just a way of grouping multiple Its together, potentially sharing common setup code and mocks.

Notice my Copy-Item mock here uses a parameter filter which means the mock will only match if that parameter expression is true. I’m checking that the path (i.e., source) ends with “_A01.JPG”, and the destination ends with “_A99.JPG” and doesn’t contain “_A01” – this checks for the scenario where I rename from “img_A01.JPG” to “img_A01_A99.JPG” instead of “img_A99.JPG”.

Second one: if the image ends with “_ABC.JPG”, ignore it

Test

Context "if ends in '_ABC'" {
    It "do nothing" {
        Mock Get-ChildItem {
            @(
                [PSCustomObject]@{ Name = 'img_ABC.JPG'; }
            )
        }

        Mock Copy-Item {}

        ImageRenamer

        Assert-MockCalled Copy-Item -Times 0
    }
}

Notice that the Assert here has -Times set to 0; when you use 0 then -Exactly is implied.

Lastly: else create a duplicate and append “_A99.JPG” to the name

Test

Context "else " {
    It "should create a new image with new suffix" {
        Mock Get-ChildItem {
            @(
                [PSCustomObject]@{ Name = 'img.JPG'; }
            )
        }   

        Mock Copy-Item -ParameterFilter { 
            $Destination.EndsWith("_A99.JPG")
        }

        ImageRenamer

        Assert-MockCalled Copy-Item -Times 1 -Exactly
    }
}

Self explanatory, that one.

The resulting script

function ImageRenamer {
    $images = Get-ChildItem -Filter "*.JPG"  .\ `
    | where {$_.Name -notlike "*ABC.JPG"}

    foreach($image in $images)
    {
         $newextension = "_A99.JPG"

        if ($image.Name -like "*_A01.JPG")
        {
            $newImageName = $image.Name.replace("_A01.JPG",$newextension)
        }
        else 
        {
            $newImageName = $image.Name.replace(".JPG",$newextension)
        }        

        Copy-Item -Path $image.Name -Destination $newImageName
    }
}

Running this with Invoke-Pester results in:

Describing ImageRenamer
   Context if ends in '_A01', create duplicate replacing '_A01' with '_A99' 
    [+] should create a duplicate with a new prefix 131ms
   Context if ends in '_ABC'
    [+] do nothing 130ms
   Context else 
    [+] should create a new image with new suffix 114ms
Tests completed in 376ms
Passed: 3 Failed: 0 Skipped: 0 Pending: 0

Summary

Hopefully that’s given you an easy introduction to unit testing your Powershell using pester.

For more info, head over to the repo on github. For the time being, I’m going to make a concerted effort to actually test my scripts from now on. You with me?

3 thoughts on “Unit Testing Powershell with Pester

  1. Thanks so much for the wonderful and informative post. I never knew about this library, but it sure would have come in handy in the past if I had. I hope to explore it more and use it more in the future.

    I just wanted to point out a few typos, that if someone were trying to follow along with the post, might get tripped up on. (Things don’t work if one were to copy and paste your above code into their own text editor and run them)

    In Step 2, the New_fixture command should be updated from New-Fixture -Name MyImageRenamer -> New-Fixture -Name ImageRenamer
    Also, in the MyImageRenamer.ps1 file, in the foreach loop, the third line should be changed from $image.replace(“.JPG”, “_A99.JPG”) -> $image.Name.replace(“.JPG”, “_A99.JPG”)

    Thanks again for the post. I’m really excited to start using this new tool!

Leave a Reply

Your email address will not be published. Required fields are marked *