Test Driving PowerShell with Pester
Work in progress text and examples from an upcoming presentation.
Test Driven Development is Christmas themed - red green refactor cycle.
-
Why Pester
-
What is Pester
-
Describe (01Structure)
-
It
-
Should (02Should)
-
Formatted Output
- Success
- Failures
-
Simplest testing
-
Url testing (03Web) (04Links)
-
Testing & Documenting Webservices (05 StarWars)
-
Running suites of tests Invoke-Pester Pass variables.
-Tag Films | People | Planets - only runs those tags
- Output Format -File -OutputFormat xml - understood by CI tools - VSTS etc.
Christmas - Red Green is very festive. Let's look at import stuff. Write-Christmas module and test it.
-
Code Coverage Running any tests for christmas
-
TestDrive Set-Content
-
Mocking to make tests reliable
-
Code Coverage
RSpec
require 'rack/test'
require 'json'
module ExpenseTracker
RSpec.describe 'Expense Tracker API' do
it 'records submitted expenses' do
post '/expenses', JSON.generate(receipt)
end
end
end
Describe It and Should
Easy to get started, let's try running vs code and typing a simple test.
Import-Module Pester
Describe 'Pester' {
It 'Just Works' {
'toast' | Should Be 'test'
}
}
We can try Should Not Be, Be Like Be LikeExactly
Import-Module Pester
Describe 'Presenting in public' {
It 'Emotion should be positive' {
$Emotion = 'Kacking It'
$Emotion | Should Be 'Joy'
}
}
Ok, that fails, let's fix the test to make it more representative of reality.
Import-Module Pester
Describe 'Presenting in public' {
It 'Emotion may not be positive' {
$Emotion = 'Kacking It'
$Emotion | Should Not Be 'Joy'
}
}
Import-Module Pester
Describe 'Presenting in public' {
It 'should be like Macdonalds' {
$Emotion = "I'm Loathing It"
$Emotion | Should BeLike "I'm Lo*ing It"
}
}
Refactoring
We can have multiple describe blocks and multiple It blocks or collapse into one describe with multiple context blocks and they stack up as you would expect.
So we can test a website..
Import-Module Pester
Set-StrictMode -Version Latest
Describe 'Google' {
It 'Serves pages over http' {
Invoke-WebRequest -Uri 'http://google.com/' -UseBasicParsing |
Select-Object -ExpandProperty StatusCode |
Should Be 200
}
It 'Serves pages over https' {
Invoke-WebRequest -Uri 'https://google.co.uk/' -UseBasicParsing |
Select-Object -ExpandProperty StatusCode |
Should Be 200
}
}
$ImportantLinks = @(
'https://deejaygraham.github.io/2015/02/15/sketchnote-challenge/',
'https://deejaygraham.github.io/img/posts/sketchnoting-challenge/mac-power-users.webp'
)
Describe 'Externally Referenced Links' {
$ImportantLinks | ForEach-Object {
It "$_ is reachable" {
Invoke-WebRequest -uri $_ -UseBasicParsing |
Select-Object -ExpandProperty StatusCode |
Should Be 200
}
}
}
Documenting Web APIs
We can setup a test context for a specific Rest API - get and post.
Test Suite
Param (
[Parameter(Mandatory=$True)]
[string]$BaseUri
)
[string]$Resource = "$($BaseUri)films/"
Describe "Films in the Star Wars Universe" {
$AllFilms = Invoke-RestMethod -Method Get -Uri $Resource -UseBasicParsing
It 'Contains all 7 films' {
$AllFilms.Count | Should Be 7
}
}
Param (
[Parameter(Mandatory=$True)]
[string]$BaseUri
)
[string]$Resource = "$($BaseUri)planets/"
Describe "Planets in Star Wars" {
$AllPlanets = Invoke-RestMethod -Method Get -Uri $Resource -UseBasicParsing
It 'Contains a lot of planets' {
$AllPlanets.Count | Should Be 61
}
}
Param (
[Parameter(Mandatory=$True)]
[string]$BaseUri
)
[string]$Resource = "$($BaseUri)people/"
Describe "People in Star Wars" {
$Everyone = Invoke-RestMethod -Method Get -Uri $Resource -UseBasicParsing
It 'Contains a lot of people' {
$Everyone.Count | Should Be 87
}
It 'Luke Skywalker is Id = 1' {
$Luke = Invoke-RestMethod -Method Get -Uri "$($Resource)1" -UseBasicParsing
$Luke.name | Should Be 'Luke Skywalker'
}
}
Import-Module Pester
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
[string]$here = Split-Path -Path $MyInvocation.MyCommand.Path
[string]$BaseUri = 'https://swapi.co/api/'
Invoke-Pester -Script @{
Path = "$here\*.Tests.ps1"
Parameters = @{
BaseUri = $BaseUri
}
}
We can then invoke tests from a controlling script that will execute against all tests in a directory.
Import-Module Pester
$here = Split-Path -Path $MyInvocation.MyCommand.Path
Invoke-Pester -Script "$here\*.Tests.ps1"
We can even pass parameters to each script, like the base uri
Param (
[Parameter()]
[string]$BaseUri = 'https://swapi.co/api/'
)
Import-Module Pester
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$here = Split-Path -Path $MyInvocation.MyCommand.Path
Invoke-Pester -Script @{
Path = "$here\*.Tests.ps1"
Parameters = @{
BaseUri = $BaseUri
}
}
Christmas Driven Development
Now to the serious stuff
Like scrooge (more on him later), I need to know when Christmas is coming so I can make preparations. Let's write two functions, one to get some content from a website, and another to parse the text and find the value.
$SantaUrl = 'https://www.emailsanta.com/clock.asp'
$HtmlToMatch = '<span class="XmasDayemph">(.\*)</span>'
Function Get-WebPageContent {
Param(
[Parameter(Mandatory=$True)]
[string]$url
)
$response = Invoke-WebRequest -Uri $url -UseBasicParsing
Write-Output $response.RawContent
}
Function Get-HowLongUntilChristmas {
$response = Get-WebPageContent -Url "https://www.emailsanta.com/clock.asp"
If ($response -match $HtmlToMatch) {
Write-Output $matches[1]
}
}
Mocking
Initial set of tests...
Import-Module Pester
Describe 'Email Santa Service' {
Context 'Countdown to Christmas' {
It 'Expressed in days' {
Get-HowLongUntilChristmas | Should BeLike '* days'
}
It 'Calculates correctly' {
Get-HowLongUntilChristmas | Should Be '24 days'
}
}
}
Fails. We need some way to make this test work for every day of the year. Mock the content function and return a known value. Works all of the time.
Import-Module Pester
Describe 'Email Santa Service' {
Context 'Countdown to Christmas' {
$FakeWebPage = '<html><span class="XmasDayemph">24 days</span>'
Mock Get-WebPageContent { return $FakeWebPage }
It 'Expressed in days' {
Get-HowLongUntilChristmas | Should BeLike '* days'
}
It 'Calculates correctly' {
Get-HowLongUntilChristmas | Should Be '24 days'
}
}
}
# Evaluated in order
$filter = '(B|D|E)$'
Mock Select-String { "matched!" } -ParameterFilter { $Path -match $filter }
Mock Select-String
SDD
It should be Christmas Day, I am sure," said she, "What's to-day?" cried Scrooge, calling downward to a boy in Sunday clothes, who perhaps had loitered in to look about him.
Albert Finney (and my pop-in-law whose birthday it is) are both interested in whether today is christmas day.
To save them shouting out of the window to a passing urchin, I have written a function.
Function Test-ChristmasDay {
$Today = Get-Date
If ($Today.Month -eq 12 -and $Today.Day -eq 25) {
Write-Output $True
} Else {
Write-Output $False
}
}
So I know I should test that difficult logic:
Describe 'Scrooge' {
Context 'Before the Ghosts Visit' {
It 'Doesn't care about Christmas day' {
# when will this work, when will it not work
Test-ChristmasDay | Should Be $false
}
}
Context 'The Spirits have done it all in one night' {
It 'It is Christmas Day' {
Test-ChristmasDay | Should Be $True
}
}
}
Describe 'Scrooge' {
Context 'Before the Ghosts Visit' {
Mock Get-Date { New-Object DateTime (2018, 7, 10) }
It 'Doesn't care about Christmas day' {
Test-ChristmasDay | Should Be $false
}
}
Context 'The Spirits have done it all in one night' {
# Wizzard way of doing it.
Mock Get-Date { New-Object DateTime (2018, 12, 25) }
It 'It is Christmas Day' {
Test-ChristmasDay | Should Be $True
Assert-MockCalled Get-Date -Times 2 -Exactly
}
}
}
Made tests independent of dates and times. We can expect that our code is much more likely to succeed when put into a real environment. We can also make sure that the internals we are expecting are called the right number of times.
Modules
Testing modules and developing them side by side with the tests, it's a good idea to make sure we are working with the most up to date version
Get-Module Scrooge | Remove-Module -Force
Import-Module $here\Scrooge.psm1 -Force
Set-StrictMode -Version Latest
Function Get-Ghost {
[CmdletBinding()]
Param()
$Ghosts = @( 'Jacob Marley', 'Christmas Past', 'Christmas Present', 'Christmas Future', 'Patrick Swayze', '' )
Write-Output $Ghosts
}
Export-ModuleMember -Function \*
Describe 'Get-ChristmasCarolGhost' {
$Ghosts = Get-ChristmasCarolGhost
$FirstGhost = $Ghosts | Select-Object -First 1
It 'Three spirits shall visit scrooge' {
$Ghosts.Count | Should -Be 4
}
It 'First Ghost is Marley' {
$FirstGhost | Should -Be Like '*Marley'
}
}
Driving Home For Christmas
$here = Split-Path -Path $MyInvocation.MyCommand.Path
Get-Module ChrisRea | Remove-Module -Force
Import-Module $here\ChrisRea.psm1
Import-Module Pester
Describe 'Chris Rea' {
$Path = Join-Path $TestDrive -ChildPath 'ChrisRea.txt'
Set-Content -Path $Path -Content "I'm driving home for Christmas, Oh, I can't wait to see those faces"
It 'Song lyrics can be read from a local file' {
Get-ChristmasSong -Path $Path | Should Contain 'faces'
}
}
Write a function that prints the lyrics to a christmas song.
Set-StrictMode -Version Latest
Function Get-ChristmasSong {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$True)]
[string]$Path
)
Write-Output (Get-Content -Path $Path)
}
Export-ModuleMember -Function \*
CI
Invoke-Pester -OutputFile 'PesterResults.xml' -OutputFormat NUnitXml
Import-Module Pester
$here = Split-Path -Path $MyInvocation.MyCommand.Path
Invoke-Pester -Script @{
Path = "$here\*.Tests.ps1"
Parameters = @{
BaseUri = $BaseUri
}
}
Code Coverage
Invoke-Pester -Script Demo.Tests.ps1 -CodeCoverage Demo.ps1