Scott's Blog

PowerShell, but DevOps - Part 1

PowerShell is getting a lot of buzz since PowerShell 7 was released. A tried and true friend to Windows SysAdmins, now stable and available for most Linux distributions. PowerShell is a very robust tool to create automation that makes your life easier. But, with any software, we need predicatbility when releasing these tools.

All the code from this blog post will be available on my GitHub here: PowerShellDevOps

Getting Started

Here’s what we’re working with:

The goal of our application is to create a PowerShell module that can easily perform math operations. We’ll have 4 commands, Add-Numbers, Subtract-Numbers, Multiply-Numbers and Divide-Numbers.

Creating the PowerShell Module

We’re going to start by creating a new PowerShell Module called MathFunctions.psm1

function Add-Numbers
{
    param(
        [int]$Number1,
        [int]$Number2
    )

    return ($Number1 + $Number2)
}

function Subtract-Numbers
{
    param(
        [int]$Number1,
        [int]$Number2
    )

    return ($number1 - $number2)
}

function Multiply-Numbers
{
    param(
        [int]$Number1,
        [int]$Number2
    )

    return ($number1 * $number2)
}

function Divide-Numbers
{
    param(
        [int]$Number1,
        [int]$Number2
    )

    if($Number2 -eq 0)
    {
        throw "Can't divide by zero, pal"
    }

    return ($number1 / $number2)
}

Export-ModuleMember -Function Add-Numbers, Subtract-Numbers, Multiply-Numbers, Divide-Numbers

Now, if we import our newly created module with Import-Module .\MathFunctions.psm1 we can run the commands and see the expected output.

PS > Add-Numbers 1 2
PS > 3
PS >
PS > Subtract-Numbers 3 1
PS > 2
PS > 
PS > Multiply-Numbers 1 2
PS > 2
PS > 
PS > Divide-Numbers 4 2
PS > 2

Great, ready to ship? Nope. How do we know that future changes won’t break this application? We could manually test this. But, the developers are busy! We can’t expect them to test every single function each time they make a trivial change. Here’s where automated unit testing becomes important!

Unit Testing with Pester

Pester is a fantastic framework for unit testing in PowerShell. It can even provide code coverage results, which we’ll demonstrate here.

To ensure we’re using the latest version of Pester, let’s run Install-Module -Name Pester -Force to install the latest release, as of writing this I’m using version 5.2.2.

Now, I like to structure my unit tests under a folder called tests\. For each cmdlet we’re writing a test for I’ll create a new tests file.

Let’s write a test for Add-Numbers first. Create a new file: .\tests\Add-Numbers.Tests.ps1

For our tests definition file, we will describe our test, and define what it should do.

Describe 'Add-Numbers' {
    It "Given no parameters, will return 0" {
        $AdditionValue = Add-Numbers
        $AdditionValue | Should -Be 0
    }
}

Now, in our PowerShell Console if we run Invoke-Pester .\tests\*.Tests.ps1, well get a result such as:

Starting discovery in 1 files.
Discovery finished in 161ms.
Running tests.
[+] .\tests\Add-Numbers.Tests.ps1 606ms (120ms|346ms)
Tests completed in 624ms
Tests Passed: 1, Failed: 0, Skipped: 0 NotRun: 0

Great! Our tests work. Let’s add some more tests to Add-Numbers:

Describe 'Add-Numbers' {
    It "Given no parameters, will return 0" {
        $AdditionValue = Add-Numbers
        $AdditionValue | Should -Be 0
    }

    It "Given 2 numbers, 1 and 1 will return 2" {
        $AdditionValue = Add-Numbers
        $AdditionValue | Should -be 2
    }
}

Notice we didn’t give any parameters to the second test definition. We’re expecting a value of 2 but never told it which numbers to add together. In this case, we see that pester has failed, leaving out test passing rate at 50%.

Starting discovery in 1 files.
Discovery finished in 26ms.
Running tests.
[-] Add-Numbers.Given 2 numbers, 1 and 1 will return 2 26ms (25ms|1ms)
 Expected 2, but got 0.
 at $AdditionValue | Should -be 2, .\tests\Add-Numbers.Tests.ps1:9
 at <ScriptBlock>, .\tests\Add-Numbers.Tests.ps1:9
Tests completed in 158ms
Tests Passed: 1, Failed: 1, Skipped: 0 NotRun: 0

Giving it the correct parameters will allow the tests to pass:

Describe 'Add-Numbers' {
    It "Given no parameters, will return 0" {
        $AdditionValue = Add-Numbers
        $AdditionValue | Should -Be 0
    }

    It "Given 2 numbers, 1 and 1 will return 2" {
        $AdditionValue = Add-Numbers 1 1
        $AdditionValue | Should -be 2
    }
}

And our output:

Starting discovery in 1 files.
Discovery finished in 21ms.
Running tests.
[+] .\tests\Add-Numbers.Tests.ps1 111ms (11ms|80ms)
Tests completed in 112ms
Tests Passed: 2, Failed: 0, Skipped: 0 NotRun: 0

Great, now we’re happy that our tests are running. We can deploy our package to all our friends who could use these new cutting edge features.

Wait, did we forget something?

Code Coverage

Code Coverage is a tool we can use to understand which parts of our test we haven’t yet automated testing for. In the previous section we only wrote a test for Add-Numbers, leaving the rest in an unknown state. Let’s use code coverage to find our gaps.

Using the command: Invoke-Pester .\tests\*.Tests.ps1 -CodeCoverage .\MathFunctions.psm1

Starting discovery in 1 files.
Discovery finished in 12ms.
Starting code coverage.
Running tests.
[+] .\tests\Add-Numbers.Tests.ps1 195ms (48ms|137ms)
Tests completed in 205ms
Tests Passed: 2, Failed: 0, Skipped: 0 NotRun: 0
Processing code coverage result.
Covered 18.18% / 75%. 11 analyzed Commands in 1 File.

Oh no! We’ve only tested 18.18% of our code. Let’s fix that!

.\tests\Subtract-Numbers.Tests.ps1

Describe 'Subtract-Numbers' {
    It "Given no parameters, will return 0" {
        $Value = Subtract-Numbers
        $Value | Should -Be 0
    }

    It "Given 2 numbers, 1 and 1 will return 2" {
        $Value = Subtract-Numbers 3 1
        $Value | Should -be 2
    }
}

.\tests\Multiply-Numbers.Tests.ps1

Describe 'Multiply-Numbers' {
    It "Given no parameters, will return 0" {
        $Value = Multiply-Numbers
        $Value | Should -Be 0
    }

    It "Given 2 numbers, 2 and 1 will return 2" {
        $Value = Multiply-Numbers 2 1
        $Value | Should -be 2
    }
}

.\tests\Divide-Numbers.Tests.ps1

Describe 'Divide-Numbers' {
    It "Given no parameters, will return an error" {
        try {
            $Value = Divide-Numbers
        }
        catch {
            $Value = "error"
        }
        $Value | Should -Be "error"
    }

    It "Given 2 numbers, 4 and 2 will return 2" {
        $AdditionValue = Divide-Numbers 4 2
        $AdditionValue | Should -be 2
    }
}

Now that we’ve added those additional tests, we can see our code coverage results are now 90%.

Starting discovery in 4 files.
Discovery finished in 16ms.
Starting code coverage.
Running tests.
[+] .\tests\Add-Numbers.Tests.ps1 80ms (4ms|70ms)
[+] .\tests\Divide-Numbers.Tests.ps1 78ms (4ms|71ms)
[+] .\tests\Multiply-Numbers.Tests.ps1 82ms (4ms|76ms)
[+] .\tests\Subtract-Numbers.Tests.ps1 81ms (4ms|74ms)
Tests completed in 328ms
Tests Passed: 8, Failed: 0, Skipped: 0 NotRun: 0
Processing code coverage result.
Covered 90.91% / 75%. 11 analyzed Commands in 1 File.

The remaining 9.09% is because of the Export-ModuleMember statement at the end of our MathFunctions.psm1, which I’m able to see in the file coverage.xml generated by Pester. This file can be interpreted by most CI/CD providers to let you know exactly how your tests are performing in a format you can easily understand. Anyway, let’s fix that.

To get to 100%, we need to finish making our module the right way. To do so, we need a module manifest. A module manifest is responsible to tell PowerShell which functions it’s allowed to use, which variables, and other important information. Using New-ModuleManifest MathFunctions.psd1 we can generate a blank manifest.

Inside the MathFunctions.psd1 we’ll find the FunctionsToExport line and add our functions to the array as such:

FunctionsToExport = @("Add-Numbers", "Subtract-Numbers", "Multiply-Numbers", "Divide-Numbers")

Now that we have a module manifest, we need to change the Import-Module .\MathFunctions.psm1 at the top of each *.Tests.ps1 file to Import-Module .\MathFunctions.psd1 so we can use our manifest to import the needed functions.

Now, our code coverage results are returning 100%!

Starting discovery in 4 files.
Discovery finished in 51ms.
Starting code coverage.
Running tests.
[+] .\tests\Add-Numbers.Tests.ps1 89ms (7ms|74ms)
[+] .\tests\Divide-Numbers.Tests.ps1 95ms (9ms|71ms)
[+] .\tests\Multiply-Numbers.Tests.ps1 92ms (8ms|70ms)
[+] .\tests\Subtract-Numbers.Tests.ps1 92ms (8ms|71ms)
Tests completed in 374ms
Tests Passed: 8, Failed: 0, Skipped: 0 NotRun: 0
Processing code coverage result.
Covered 100% / 75%. 11 analyzed Commands in 1 File.

Conclusion

In this blog post, we learned how to write unit tests and generate code coverage results for PowerShell scripts using the Pester framerwork. In the next post, we’ll cover how to use Github Actions to automate running these tests and linting your code.

If you have any questions, comments or wanna talk about how you use Pester, please reach out to me on twitter @scwheele or open an issue on the project’s repository on Github PowerShellDevOps

Until next time, go learn something new! :)