Pester is a great testing framework for Powershell. And it can be used in a variety of different testing styles: TDD, BDD, etc. I’m going to look at two different styles, both of which are perfectly good to use.
TDD’ish with BeforeAll / AfterAll
# http://stackoverflow.com/questions/1183183/path-of-currently-executing-powershell-script | |
$root = Split-Path $MyInvocation.MyCommand.Path -Parent; | |
if($script:InvokePester -eq $false -or $null -eq $script:InvokePester) { | |
Import-Module DatabaseModule -Force | |
Import-Module Pester | |
} | |
Describe -Tag "Unit","Public" -Name "Convert-DbUpdateOutputToErrors" { | |
BeforeAll { | |
$script:originalConnectionString = $global:DatabaseModule.ConnectionString; | |
$global:DatabaseModule.ConnectionString = "localhost/somethingsomething" | |
} | |
InModuleScope "DatabaseModule" { | |
It "Errors" { | |
$outputString = @" | |
Switching database context to 'iSIS'. | |
RuntimeException: Msg 156, Level 15, State 1, Server XXXXX, Line 8 | |
Incorrect syntax near the keyword 'select'. | |
"@ | |
$output = $outputString -split [Environment]::NewLine | |
$result = Convert-DbUpdateOutputToErrors -Output $output | |
@($result).Count | Should Be 1 | |
$result[0].ErrorMessage | Should Be "Incorrect syntax near the keyword 'select'." | |
} | |
It "Errors - start in middle" { | |
$outputString = @" | |
Changed database context to 'ODS_Ext'. | |
Msg 2714, Level 16, State 3, Server XXXXX, Procedure usp_XXXXX_FlagAllGradStudentsByStartQuarter_AllQueues, Line 2 | |
There is already an object named 'usp_XXXXX_FlagAllGradStudentsByStartQuarter_AllQueues' in the database. | |
"@ | |
$output = $outputString -split [Environment]::NewLine | |
$result = Convert-DbUpdateOutputToErrors -Output $output | |
@($result).Count | Should Be 1 | |
$result[0].ErrorMessage | Should Be "There is already an object named 'usp_XXXXX_FlagAllGradStudentsByStartQuarter_AllQueues' in the database." | |
} | |
It "No Errors" { | |
$outputString = @" | |
Switching database context to 'XXXX'. | |
"@ | |
$output = $outputString -split [Environment]::NewLine | |
$result = Convert-DbUpdateOutputToErrors -Output $output | |
@($result).Count | Should Be 0 | |
} | |
AfterAll { | |
$global:DatabaseModule.ConnectionString = $script:originalConnectionString; | |
} | |
} |
Lines 4 through 7 are used to ensure that module don’t get repeatable imported, when this tests are run as part of a Test Suite. However, they will allow modules to be reloaded if you are running the individual test file within VSCode. For the most part, they can be ignored.
In this more Test Driven Development style test
- The Describe blocks name is the function under test
- And each It test is labelled to describe a specific scenario it is going to test
- All the logic for setting up the test and executing the test are contained within the It block
- This relies on the Should tests to have clear enough error messages that when reading through the unit tests output you can intuit what was the failing condition
This is a very straight forward approach and it’s really easy to see how all the pieces are setup. It’s also very easy for someone new to the project to add a test to it, because everything is so isolated. One thing that can really help future maintainers of a project is to write much lengthier and more descriptive It block names than the ones in the example, in order to help clarify what is under test.
Some things to note:
In this setup, the BeforeAll script is used to configure the environment to be ready for tests that are about to be run. Over time, this function has been replaced with BeforeEach, but for this example I’m using BeforeAll. The BeforeAll is setting up some values that I want available when the test is run, or a variable I want available when the test is run. I put a prefix of $script: on the variable created within the BeforeAll function because I have seen behavior where the variable was no longer defined outside of the scope of BeforeAll.
The AfterAll is a corresponding block to the BeforeAll, and is pretty self explanatory. The interesting part of the these two blocks is that they have to be declared within the Describe block and not within the InModuleScope block. They will not be run if they are declared in the InModuleScope block.
BDD’ish with try / finally
# Copyright (c) Microsoft Corporation. All rights reserved. | |
# Licensed under the MIT License. | |
<# | |
.Synopsis | |
Tests for GitHubReleases.ps1 script | |
#> | |
# This is common test code setup logic for all Pester test files | |
$moduleRootPath = Split-Path -Parent (Split-Path -Parent $Script:MyInvocation.MyCommand.Path) | |
. (Join-Path -Path $moduleRootPath -ChildPath 'Tests\Common.ps1') | |
try | |
{ | |
if ($accessTokenConfigured) | |
{ | |
Describe 'Getting releases from repository' { | |
$ownerName = "dotnet" | |
$repositoryName = "core" | |
$releases = Get-GitHubRelease -OwnerName $ownerName -RepositoryName $repositoryName | |
Context 'When getting all releases' { | |
It 'Should return multiple releases' { | |
$releases.Count | Should BeGreaterThan 1 | |
} | |
} | |
Context 'When getting the latest releases' { | |
$latest = Get-GitHubRelease -OwnerName $ownerName -RepositoryName $repositoryName -Latest | |
It 'Should return one value' { | |
@($latest).Count | Should Be 1 | |
} | |
It 'Should return the first release from the full releases list' { | |
$releases[0].url | Should Be $releases[0].url | |
$releases[0].name | Should Be $releases[0].name | |
} | |
} | |
Context 'When getting a specific release' { | |
$specificIndex = 5 | |
$specific = Get-GitHubRelease -OwnerName $ownerName -RepositoryName $repositoryName -ReleaseId $releases[$specificIndex].id | |
It 'Should return one value' { | |
@($specific).Count | Should Be 1 | |
} | |
It 'Should return the correct release' { | |
$specific.name | Should Be $releases[$specificIndex].name | |
} | |
} | |
Context 'When getting a tagged release' { | |
$taggedIndex = 8 | |
$tagged = Get-GitHubRelease -OwnerName $ownerName -RepositoryName $repositoryName -Tag $releases[$taggedIndex].tag_name | |
It 'Should return one value' { | |
@($tagged).Count | Should Be 1 | |
} | |
It 'Should return the correct release' { | |
$tagged.name | Should Be $releases[$taggedIndex].name | |
} | |
} | |
} | |
Describe 'Getting releases from default owner/repository' { | |
$originalOwnerName = Get-GitHubConfiguration -Name DefaultOwnerName | |
$originalRepositoryName = Get-GitHubConfiguration -Name DefaultRepositoryName | |
try { | |
Set-GitHubConfiguration -DefaultOwnerName "dotnet" | |
Set-GitHubConfiguration -DefaultRepositoryName "core" | |
$releases = Get-GitHubRelease | |
Context 'When getting all releases' { | |
It 'Should return multiple releases' { | |
$releases.Count | Should BeGreaterThan 1 | |
} | |
} | |
} finally { | |
Set-GitHubConfiguration -DefaultOwnerName $originalOwnerName | |
Set-GitHubConfiguration -DefaultRepositoryName $originalRepositoryName | |
} | |
} | |
} | |
} | |
finally | |
{ | |
if (Test-Path -Path $script:originalConfigFile -PathType Leaf) | |
{ | |
# Restore the user's configuration to its pre-test state | |
Restore-GitHubConfiguration -Path $script:originalConfigFile | |
$script:originalConfigFile = $null | |
} | |
} |
Lines 10 and 11 are used to ensure that that module has been configured correctly (for normal usage … not specific to the tests) and ensuring that the module isn’t being reloaded when being run in a Test Suite.
In this more Behavior Driven Development style test
- Uses the Describe block to outline the preconditions for the tests
- Immediately following the declaration of the Describe block, it has the code which will setup the preconditions
- Uses the Context block to outline the specific scenario the user would be trying
- And, immediately following the declaration, it has the code which will execute that scenario
- Uses the It blocks to outline the specific condition that is being tested.
- This requires more code, but makes it clearer what condition actually failed when reviewing unit test output
This is not as straight forward of an approach, as different areas of the code create the conditions which are being tested. You might have to search around a bit to fully understand the test setup. It also adds a little more overhead when testing multiple conditions as you will be writing more It block statements. The upside of that extra work is that the unit test output is easier to understand.
Some things to note:
In this setup, variable scope is less of an issue because variables are defined at the highest scope needed to be available in all tests.
The BeforeAll/AfterAll blocks have also been replaced with try/finally blocks. This alternative approach is better supported by Pester, and it can also help new developers make a key insight into the way Pester tests are run: They are not run in parallel, but instead are run in order from top to bottom. Because of this, you can use some programming tricks to mock and redefine variables in particular sections of the code without having to worry about affecting the results of other tests.