Quantcast
Channel: blog.bjornhouben.com
Viewing all articles
Browse latest Browse all 10

#PowerShell – Getting started with #Pester unit tests to improve code quality

$
0
0

On my list I have a couple of PowerShell related subjects I want/need to learn more about. These include, but are not limited to parallelization, GitHub, DSC, PowerShell Core, JEA, Plaster, DBAtools and DBAchecks.

I have been playing around a bit already with parallelization and Github and the next thing on my list was Pester.

Pester in general

For those that do not know it, the Pester Wiki describes Pester as follows:
“Pester provides a framework for running Unit Tests to execute and validate PowerShell commands”.

So basically you use it to run scripts/functions/code with a specific input and tell Pester what the expected output should be. Then the result of the test either passes or fails. This way you can verify if your code works as expected.

Instead of using it to test your code, you can also use it to test if a configuration is the way it should be/you want it to be. By example if you’ve created an Operating System deployment or configured DSC, you can check if the configuration has been applied (correctly) or not.

Another advantage of having Pester tests is that you can easily re-run tests without spending much time.

Reasons to re-run these tests could be if you run the code under other circumstances like by example with PowerShell Core instead of Windows PowerShell or on Windows Server 2019 instead of Windows Server 2012 R2.  A more common reason is when you change (parts of) your code that you can easily verify if your changes did not break anything. Keep in mind however that if you change your code, you might also need to change and/or add tests.

If you want to, you can use Pester as part of your Continuous Integration and Continuous Deployment (CI/CD) pipeline. But that’s beyond my knowledge and this post. If you want to know more about it, check Microsoft’s article ‘Building a Continuous Integration and Continuous Deployment pipeline with DSC’.

My plan for getting started with Pester

First, I started reading some articles about Pester, then I purchased ‘The Pester Book’ by Adam Bertram. I started reading the first couple of chapters, but didn’t finish it yet. It did provide me with the basics I needed to get started though. Instead of continuing to read the book, I decided to simply start using Pester with a simple real-life example and then build upon that.

The example I decided to start was a simple script I wrote in the past for a colleague to translate network addresses in the IP CIDR format to the IP subnet mask format. By example if the IP CIDR address was ‘192.168.1.1/24’ the output should be ‘192.168.1.1 255.255.255.0’. Basically it would take the input ‘192.168.1.1/24’ split it at the forward slash ( / ) and then use a switch statement to translate the CIDR notation (by example 24) to the subnet mask notation (by example 255.255.255.0).

To be honest I didn’t expect many advantages of using Pester for this specific script.

Actually getting started with Pester

Actually getting started with Pester consisted of the following steps that will be described in more detail:

  1. Installing Pester
  2. Creating a Pester tests file
  3. Coming up with tests
  4. Actually writing the PowerShell / Pester code for the tests you came up with
  5. Running the tests
  6. Analyzing the results, fixing code and re-running tests until all tests pass
  7. Checking code coverage, modifying code / tests, rerunning tests and checking code coverage

I have tried to include relevant parts of the code in my blog, but readability isn’t as good as I would like it to be. So it’s best if you simply download the files yourself from my GitHub.

1. Installing Pester

In my case I had already installed the latest version of Pester, but if you haven’t done that already you can do so by running PowerShell as administrator and running:

Install-Module -Name Pester -Force -SkipPublisherCheck

2. Creating a Pester tests file

My initial script full path was C:\Scripts\Convert-IPCIDRNotationtoIPSubnetMaskNotation.ps1 and the initial version of it can be found on my GitHub page.

Then I used

New-Fixture -path C:\Scripts -Name Convert-IPCIDRNotationtoIPSubnetMaskNotation

to create a C:\Scripts\Convert-IPCIDRNotationtoIPSubnetMaskNotation.tests.ps1 file that is linked to the initial script and that will contain the Pester tests for my script. The linking looks as follows:

$here = Split-Path -Parent $MyInvocation.MyCommand.Path

$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace ‘\.Tests\.’, ‘.’

. $here\$sut

Sidenote: When using Test Driven Development (TDD) to create your code you first define your tests and then write your code. So if you create a new solution this way, you would also use New-Fixture and it would create both your empty script file and a linked Pester tests file.

3. Coming up with tests

Think about all possible scenarios and how you can test them. In my case the tests should at least test all possible input CIDR values ranging from 0 to 32 and if the subnet mask output would be what I expected.

4. Actually writing the PowerShell / Pester code for the tests you came up with

Pester tests have their own terminology and syntax. The Pester Book’ by Adam Bertram does a great job covering them in detail.

In this example in its simplest form you have  a ‘Describe‘ block containing a single test or a logical group of tests contained in ‘It‘ statements where you define the code that should be run and what the output should be. By example you could have a logical group of tests for testing input validation and you could have a logical group of tests for testing if the output is as expected. In

This would then by example look like this:

$here = Split-Path -Parent $MyInvocation.MyCommand.Path

$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace ‘\.Tests\.’, ‘.’

. $here\$sut

Describe “Verify input validation working correctly” {

    It “Valid input (1.1.1.1/0) Should not throw” {

        {Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR ‘1.1.1.1/0’ | Should Not Throw}

    }

    It “Invalid formatting (1.1.1.1-0 instead of 1.1.1.1/0) should throw” {

        {Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR ‘1.1.1.1-0’ | Should Not Throw}

    }

    It “Invalid IP address (1.1.1.1.1) Should throw” {

        {Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR ‘1.1.1.1.1/0’ | Should Not Throw}

    }

    It “Invalid IP address (1.1.1.256) Should throw” {

        {Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR ‘1.1.1.1.1/0’ | Should Not Throw}

    }

    It “Invalid IP address (001.001.001.001) Should throw” {

        {Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR ‘1.1.1.1.1/0’ | Should Not Throw}

    }

    It “Invalid CIDR (/33) Should Throw” {

        {Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR ‘1.1.1.1/33’ | Should Throw}

    }

}

Describe “Verify if output from Convert-IPCIDRNotationtoIPSubnetMaskNotation is correct” {

    $TestCases = @(

        @{InputValue = ‘192.168.1.1/0’; ExpectedIPCIDR = ‘192.168.1.1/0’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘0.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 0.0.0.0’}

        @{InputValue = ‘192.168.1.1/1’; ExpectedIPCIDR = ‘192.168.1.1/1’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘128.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 128.0.0.0’}

        @{InputValue = ‘192.168.1.1/2’; ExpectedIPCIDR = ‘192.168.1.1/2’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘192.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 192.0.0.0’}

        @{InputValue = ‘192.168.1.1/3’; ExpectedIPCIDR = ‘192.168.1.1/3’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘224.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 224.0.0.0’}

        @{InputValue = ‘192.168.1.1/4’; ExpectedIPCIDR = ‘192.168.1.1/4’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘240.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 240.0.0.0’}

        @{InputValue = ‘192.168.1.1/5’; ExpectedIPCIDR = ‘192.168.1.1/5’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘248.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 248.0.0.0’}

        @{InputValue = ‘192.168.1.1/6’; ExpectedIPCIDR = ‘192.168.1.1/6’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘252.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 252.0.0.0’}

        @{InputValue = ‘192.168.1.1/7’; ExpectedIPCIDR = ‘192.168.1.1/7’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘254.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 254.0.0.0’}

        @{InputValue = ‘192.168.1.1/8’; ExpectedIPCIDR = ‘192.168.1.1/8’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.0.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.0.0.0’}

        @{InputValue = ‘192.168.1.1/9’; ExpectedIPCIDR = ‘192.168.1.1/9’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.128.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.128.0.0’}

        @{InputValue = ‘192.168.1.1/10’; ExpectedIPCIDR = ‘192.168.1.1/10’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.192.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.192.0.0’}

        @{InputValue = ‘192.168.1.1/11’; ExpectedIPCIDR = ‘192.168.1.1/11’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.224.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.224.0.0’}

        @{InputValue = ‘192.168.1.1/12’; ExpectedIPCIDR = ‘192.168.1.1/12’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.240.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.240.0.0’}

        @{InputValue = ‘192.168.1.1/13’; ExpectedIPCIDR = ‘192.168.1.1/13’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.248.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.248.0.0’}

        @{InputValue = ‘192.168.1.1/14’; ExpectedIPCIDR = ‘192.168.1.1/14’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.252.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.252.0.0’}

        @{InputValue = ‘192.168.1.1/15’; ExpectedIPCIDR = ‘192.168.1.1/15’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.254.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.254.0.0’}

        @{InputValue = ‘192.168.1.1/16’; ExpectedIPCIDR = ‘192.168.1.1/16’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.0.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.0.0’}

        @{InputValue = ‘192.168.1.1/17’; ExpectedIPCIDR = ‘192.168.1.1/17’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.128.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.128.0’}

        @{InputValue = ‘192.168.1.1/18’; ExpectedIPCIDR = ‘192.168.1.1/18’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.192.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.192.0’}

        @{InputValue = ‘192.168.1.1/19’; ExpectedIPCIDR = ‘192.168.1.1/19’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.224.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.224.0’}

        @{InputValue = ‘192.168.1.1/20’; ExpectedIPCIDR = ‘192.168.1.1/20’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.240.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.240.0’}

        @{InputValue = ‘192.168.1.1/21’; ExpectedIPCIDR = ‘192.168.1.1/21’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.248.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.248.0’}

        @{InputValue = ‘192.168.1.1/22’; ExpectedIPCIDR = ‘192.168.1.1/22’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.252.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.252.0’}

        @{InputValue = ‘192.168.1.1/23’; ExpectedIPCIDR = ‘192.168.1.1/23’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.254.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.254.0’}

        @{InputValue = ‘192.168.1.1/24’; ExpectedIPCIDR = ‘192.168.1.1/24’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.0’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.0’}

        @{InputValue = ‘192.168.1.1/25’; ExpectedIPCIDR = ‘192.168.1.1/25’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.128’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.128’}

        @{InputValue = ‘192.168.1.1/26’; ExpectedIPCIDR = ‘192.168.1.1/26’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.192’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.192’}

        @{InputValue = ‘192.168.1.1/27’; ExpectedIPCIDR = ‘192.168.1.1/27’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.224’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.224’}

        @{InputValue = ‘192.168.1.1/28’; ExpectedIPCIDR = ‘192.168.1.1/28’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.240’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.240’}

        @{InputValue = ‘192.168.1.1/29’; ExpectedIPCIDR = ‘192.168.1.1/29’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.248’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.248’}

        @{InputValue = ‘192.168.1.1/30’; ExpectedIPCIDR = ‘192.168.1.1/30’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.252’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.252’}

        @{InputValue = ‘192.168.1.1/31’; ExpectedIPCIDR = ‘192.168.1.1/31’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.254’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.254’}

        @{InputValue = ‘192.168.1.1/32’; ExpectedIPCIDR = ‘192.168.1.1/32’; ExpectedIpAddress = ‘192.168.1.1’; ExpectedSubnetMask = ‘255.255.255.255’; ExpectedIpAndSubnetMask = ‘192.168.1.1 255.255.255.255’}

        )

    It “IPCIDR result for input <inputvalue> should be <ExpectedIPCIDR>” -TestCases $TestCases {

        param($InputValue, $ExpectedIpCIDR, $ExpectedIpAddress, $ExpectedSubnetMask, $ExpectedIpAndSubnetMask)

        (Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR $InputValue).IPCIDR | Should -Be $ExpectedIpCIDR

    }

    It “IPAddress result for input <inputvalue> should be <ExpectedIpAddress>” -TestCases $TestCases {

        param($InputValue, $ExpectedIpCIDR, $ExpectedIpAddress, $ExpectedSubnetMask, $ExpectedIpAndSubnetMask)

        (Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR $InputValue).IpAddress | Should -Be $ExpectedIpAddress

    }

    It “SubNetMask result for input <inputvalue> should be <ExpectedSubnetMask>” -TestCases $TestCases {

        param($InputValue, $ExpectedIpCIDR, $ExpectedIpAddress, $ExpectedSubnetMask, $ExpectedIpAndSubnetMask)

        (Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR $InputValue).SubnetMask | Should -Be $ExpectedSubnetMask

    }

    It “Ip and subnet mask result for input <inputvalue> should be <ExpectedIpAndSubnetMask>” -TestCases $TestCases {

        param($InputValue, $ExpectedIpCIDR, $ExpectedIpAddress, $ExpectedSubnetMask, $ExpectedIpAndSubnetMask)

        (Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR $InputValue).IpAndSubnetMask | Should -Be $ExpectedIpAndSubnetMask

    }

}

Sidenote: In the example above I used testcases to define multiple inputs to test, but you could also use standard PowerShell code like getting input from a CSV and using a Foreach. This would look like this:

$Tests = Import-Csv -path $here\Convert-IPCidrNotationToIpSubnetMaskNotation.Tests.Csv” -Delimiter ‘;’

Foreach ($Test in $Tests) #Determine for your own situation if you should use a foreach or Pester test cases

{

    Describe “Convert-IPCIDRNotationtoIPSubnetMaskNotation for $($Test.Input) {

        $Result = Convert-IPCIDRNotationtoIPSubnetMaskNotation -IPCIDR $Test.Input

        It “IPCIDR result should be $($Test.ExpectedIpCIDR) {

            $Result.IPCIDR | Should -Be $($Test.ExpectedIpCIDR)

        }

        It “IPAddress result should be $($Test.ExpectedIPAddress) {

            $Result.IpAddress | Should -Be $($Test.ExpectedIPAddress)

        }

        It “SubNetMask result should be $($Test.ExpectedSubnetMask) {

            $Result.SubNetMask | Should -Be $($Test.ExpectedSubnetMask)

        }

        It “Ip and subnet mask result should be $($Test.ExpectedIpAndSubnetMask) {

            $Result.IpAndSubNetMask | Should -Be $($Test.ExpectedIpAndSubnetMask)

        }

    }

}

5. Running the tests

Even though you can simply run the C:\Scripts\Convert-IPCIDRNotationtoIPSubnetMaskNotation.tests.ps1 file directly and run the tests, you should use Invoke-Pester because it gives your more options. In this simple example however, the differences aren’t that big but with Invoke-Pester it will show you how long it took to run all tests and how many tests had the result passed, failed, skipped, pending and inconclusive. So in this case you would run:

Invoke-Pester -Script ‘C:\scripts\Convert-IPCIDRNotationtoIPSubnetMaskNotation.Tests.ps1’

6. Analyzing the results, fixing code and re-running tests until all tests pass

Before I mentioned that I didn’t expect many advantages of using Pester for this specific script, but boy was I wrong. Apparently I made typos in the switch statement and without Pester I would have probably never noticed them. It could have caused major issues If this had been used in production for by example creating firewall rules.

By fixing issues and re-running the tests I finally got rid of the errors in my code. If you are interested in what mistakes I made and what I corrected, take a look at GitHub.

7. Checking code coverage, modifying code / tests, rerunning tests and checking code coverage

The code coverage of your tests basically describes to what degree your tests cover all possible scenarios. So if I had only tested only half of the options in the switch statement, my code coverage could never be 100%. For more info, see the Pester Wiki about code coverage.

The cool thing about Pester is that if you specify the -CodeCoverage parameter with Invoke-Item, it will create a code coverage report for you. So you would run by example:

Invoke-Pester -Script ‘C:\scripts\Convert-IPCIDRNotationtoIPSubnetMaskNotation.Tests.ps1’ -CodeCoverage ‘C:\scripts\Convert-IPCIDRNotationtoIPSubnetMaskNotation.ps1’

Then it would tell you what percentage is covered by your tests and it even tells you what part(s) you not have tested. I have to admit that I don’t know exactly how it works and if it’s able to detect everything so you have to be critical yourself as well.

In my case I had defined the default keyword in the switch statement to return an error if the CIDR value was not between 0 and 32. I however forgot to write a test for it. Instead of writing a test for it, I decided in this case it would be better to modify the existing script to do input validation and then write tests to test if the input validation was working correctly. After this, the code coverage was 100%

Sidenote: You don’t always have to have 100% code coverage for a script from the start. Often you have limited time and it is not feasible to write tests for scenarios that are unlikely to ever happen. This is something you have to decide yourself. You can always add/improve tests later on and each test you have created is better than having none at all.

Closing thoughts

Pester is a great tool that helps to improve your code, even for simple code. We are all human and we all make mistakes. Pester helps me finding these issues and therefore improves my code quality. It also gives me a better feeling and more confidence about the scripts I’ve written.

What I also really like, is that making changes to an existing scripts takes less time and is less likely to create issues that will not be noticed. This is because you can easily re-run the tests that you have already created. You should still keep in mind that changes to your code might require changes to existing tests or might require you to add tests. But using the -codecoverage parameter of Invoke-Pester also helps you with that.

I purposely chose this simple example just to get started, but Pester can do much more. I plan to read more about it in ‘The Pester Book’ by Adam Bertram and when I get a good use case I will blog about it.

If you have any thoughts about this article, please leave a reply as this motivates me to create more blog posts.


Viewing all articles
Browse latest Browse all 10

Latest Images

Trending Articles





Latest Images