Unit testing in PowerShell

With the reoccurring need of PowerShell to manage infrastructure setup, monitoring and deployment, it's vastly important that we have the right tools in place in order to keep PowerShell scripts organized and properly maintained. Nowadays, the development of software applications require a common set of strategies/practices so developers can feel confident about making new changes and/or refactoring existing code. Techniques like test-driven development are quite common in the object-oriented programming world. Unfortunately, when it comes to infrastructure code, developers tend to ignore the same strategies. Mostly due to time constrains or the lack of reliable tools to test PowerShell, Shell or Batch scripts.

This article will uniquely focus on PowerShell unit test and static code analysis, providing an option for each scenario. Although this tools are widely recommended, in Software Development there are no "silver-bullets". They might fit some of your needs, but do not expect 100% unit test code coverage and/or unlimited flexibility. As in test-driven development, it's expected that a particular set of guidelines are followed in order to properly test most of your code. On the other hand, we want to test a very versatile and powerful scripting language, capable of doing simple string formats to start provisioning a Virtual Machine in Azure. Obviously we want to be realistic and mindful about what we can and cannot test (do we really want to spin-up a new Azure VM every time the unit tests run?).

Unit test your PowerShell scripts with Pester

As described on Pester's GitHub repository "Pester provides a framework for running unit tests to execute and validate PowerShell commands from within PowerShell". Expect all common set of functionalities you can find in any unit test framework like: assertion; before/after test execution actions; test contexts; test cases (or triangulation). Also, expect more advanced functionalities like: mocking existing commands (your own or PowerShell commands like Write-Host); file operation isolation; code coverage metrics.

With Pester both PowerShell script files (.ps1) and PowerShell modules (.psm1) can be tested. However, when testing script files, you must be aware that Pester will execute the entire file (this loads all defined functions). Any code defined outside of a function will always be executed, making it impossible to mock. Rule of thumb: move all of your code into functions. PowerShell caches modules to speed up dependency lookup. Keep that in mind when testing modules, since an older (cached) version might used instead of the latest. Rule of thumb: importing modules in test files, always use -Force switch with Import-Module cmdlet to force reload the latest module version.

Getting started

Assuming the following script is saved as 'numbers.ps1'

param([switch]$shouldWriteToHost)


function Get-RandomNumberBetweenZeroAndNine {
  return Get-Random -Minimum 0 -Maximum 10
}

function Sum-Numbers {
  param([int]$a, [int] $b)

  return $a + $b
}

$a = Get-RandomNumberBetweenZeroAndNine
$b = Get-RandomNumberBetweenZeroAndNine
$result = Sum-Numbers $a $b


if($shouldWriteToHost) {
  Write-Host "$a + $b = $result"
}

Noticeably a portion of the code is not wrapped in a function. As previously stated, it's highly recommended that we place everything in PowerShell functions. Let's quickly refactor this script to improve testability.

function Get-RandomNumberBetweenZeroAndNine {
  return Get-Random -Minimum 0 -Maximum 10
}

function Sum-Numbers {
  param([int]$a, [int] $b)

  return $a + $b
}

function Invoke-SumRandomNumbers {
  param([switch]$shouldWriteToHost)

  $a = Get-RandomNumberBetweenZeroAndNine
  $b = Get-RandomNumberBetweenZeroAndNine
  $result = Sum-Numbers $a $b

  if($shouldWriteToHost) {
    Write-Host "$a + $b = $result"
  }
}

One particular problem with this new refactored script is that it doesn't automatically execute. All functions are loaded, but no function gets called, so practically no code gets executed. Invoke-SumRandomNumber needs to be explicitly called to achieve the same behaviour. Later on I will demonstrate how the same behaviour can be replicated by moving all the code in to a PowerShell module. For now let's focus on the tests.

Create a new file PowerShell script file to write your test definitions. As an example let's Mock Get-RandomNumberBetweenZeroAndNine to control the output and verify if it's called twice. Also let's check if Write-Host let's called with the expected result.

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path) -replace '\.Tests\.', '.'
# execute all files that do not contain 'Tests' in the name (to load all necessary functions)
. "$here\$sut"

Describe "Invoke-SumRandomNumbers" {
  Context "With input parameters" {
    # arrange
    Mock Get-RandomNumberBetweenZeroAndNine -MockWith { return 2 }
    Mock Write-Host {}

    # act
    Invoke-SumRandomNumbers -shouldWriteToHost

    # assert
    It "Should call 'Write-Host' with the expected result" {
      Assert-MockCalled Write-Host -Exactly 1 -ParameterFilter { $Object -eq '2 + 2 = 4' }
    }
    It "Should call 'Get-RandomNumberBetweenZeroAndNine' twice" {
      Assert-MockCalled Get-RandomNumberBetweenZeroAndNine -Exactly 2
    }
  }
}

Finally let's run our unit tests. If Pester module is installed, just run open a PowerShell console, navigate to the directory where the scripts are hosted and run: Invoke-Pester You should get a report similiar to this: Pester run example

Going back to describe how can PowerShell can be used. Start with renaming 'numbers.ps1' to 'numbers.psm1' and define what functions should be public. In this case we are just interest in Invoke-SumRandomNumbers

function Get-RandomNumberBetweenZeroAndNine {
  return Get-Random -Minimum 0 -Maximum 10
}

function Sum-Numbers {
  param([int]$a, [int] $b)

  return $a + $b
}

function Invoke-SumRandomNumbers {
  param([switch]$shouldWriteToHost)

  $a = Get-RandomNumberBetweenZeroAndNine
  $b = Get-RandomNumberBetweenZeroAndNine
  $result = Sum-Numbers $a $b

  if($shouldWriteToHost) {
    Write-Host "$a + $b = $result"
  }
}

Export-ModuleMember -Function Invoke-SumRandomNumbers

On the unit test defintion file, import our module and wrap all test contexts with InModuleScope numbers

# always use '-Force' to load the latest version of the module
Import-Module ".\numbers.psm1" -Force

Describe "Invoke-SumRandomNumbers" {
  InModuleScope numbers {
    Context "With input parameters" {
      # arrange
      Mock Get-RandomNumberBetweenZeroAndNine -MockWith { return 2 }
      Mock Write-Host {}

      # act
      Invoke-SumRandomNumbers -shouldWriteToHost

      # assert
      It "Should call 'Write-Host' with the expected result" {
        Assert-MockCalled Write-Host -Exactly 1 -ParameterFilter { $Object -eq '2 + 2 = 4' }
      }
      It "Should call 'Get-RandomNumberBetweenZeroAndNine' twice" {
        Assert-MockCalled Get-RandomNumberBetweenZeroAndNine -Exactly 2
      }
    }
  }
}

That's it! You're all set.

Installation instructions

# Installing items from the Gallery requires the latest version of the PowerShellGet module, 
# which is available in Windows 10, in Windows Management Framework (WMF) 5.0, or in 
# the MSI-based installer (for PowerShell 3 and 4).
# Check https://www.powershellgallery.com/ for more details.


# Open a PowerShell command prompt in Administrator mode

# add 'PSGallery' as a trusted PowerShell module repository
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted

# Download and install modules from PSGallery
Install-Module -Name PSScriptAnalyzer
Install-Module -Name Pester 

References

  • Pester source code: https://github.com/pester/Pester
  • PowerShell Gallery is central repository for PowerShell content (just like NuGet.org for NuGet packages) https://www.powershellgallery.com/
  • Pester module: https://www.powershellgallery.com/packages/Pester/
  • Windows Management Framework 5.0 download link (already pre-installed on Windows 10) https://www.microsoft.com/en-us/download/details.aspx?id=50395
  • PowerShellGet (previously known as OneGet) module cmdlets: https://technet.microsoft.com/en-us/library/dn807169.aspx