In order to stabilize our middle office, we need to test it. Not that it’s buggy, but hey, testing is good.
We have a huge 500-lines script that processes PDF files through a bunch of programs. We need to test a few things:
- Every step and each piece of the code must be working
- The configuration files must be properly read
- The proper programs must be run in the proper order
- The processed PDFs must look like what we’re expecting
To do that, we have a lot of work to do.
There is a series of great articles on Pester on PowerShell Magazine.
Refactor your script
First, we need to extract the methods and code blocks we will test. Our script is not that hard to refactor into a bunch of methods, since it’s pretty well organized so far. A simple refactor will simply be moving a bunch of independent code blocks into methods, regrouped and externalized by feature.
Prefer using modules to do that. Create your methods into .psm1 files. It will allow you to just Import-Module mymodule.psm1 and use it the same way as if you were dot-sourcing your file (. .\myfile.ps1), but will allow you to do more awesome things later; for instance, you can get the list of available commands through Get-Command -Module mymodule, get the comment-based help of your module methods through Get-Help my-module-method, get tab-expansion on your methods, etc.
If you’re not sure how to refactor your script to extract units of work, I can’t really help you there, and you should go learn more about unit testing; there are a lot of places where you can do that.
Unit Test your extracted code
There is an awesome unit test and BDD tool called Pester. Download the latest nuget package into your scripts directory by running nuget install pester. PsGet also has a Pester package, but including the Pester files with your sources (or a way to get them like with Nuget) is very important if you intend to run your tests on a continuous integration platform, especially if you externalize them on a service like AppVeyor.
Create a _run_tests.ps1 file (or whatever your naming convention calls for), with the very simple following contents:
1 2 3 |
$here = Split-Path -Parent $MyInvocation.MyCommand.Path Import-Module "$here\Pester\Pester.psm1" Invoke-Pester |
Invoke-Pester will run all the “*.Tests.ps1” files it finds in the current directory. Unfortunately, the Pester Nuget package comes with the Pester tests, and it’s pretty annoying to see the thousands of unit tests in the middle of yours. You can either not use Invoke-Pester and roll your own “look for *.Tests.ps1 file except in the Pester folder” method, or (like I did) forget about nuget update pester and remove the test files from the Pester folder.
To create unit test files, you can either write them manually, or use the New-Fixture module command, which will create both a file to contain your methods, and a test file to test your methods. If you’re working with modules, you will not really be able to use the power of this command, but if you’re creating a new script, it will provide you with a BDD workflow.
Your test file for your module will look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
$here = Split-Path -Parent $MyInvocation.MyCommand.Path $root = "$here\..\.." Import-Module "$root\modules\mail.psm1" -Force Describe "mail" { Context "config file is provided" { It "gets sender from the config" { (Read-MailConfig -configFile "$here\mail.Tests.config").from | Should Be "test@example.com" } It "gets client from the config" { (Read-MailConfig -configFile "$here\mail.Tests.config").client.Host | Should Be "smtp.gmail.com" } It "gets login from the config" { (Read-MailConfig -configFile "$here\mail.Tests.config").client.Credentials.UserName | Should Be "testlogin" } } Context "config file is missing" { It "throws an error when no config is passed" { { Read-MailConfig } | Should Throw } } } |
As you can see, for now I’m using a custom config files with the expected values filled. This is not the best way to unit test, so we’re going to use mocking.
Mock the system methods
Here is the true power of Pester and Powershell: the ability to mock system methods. Your method reads files from the disk? No problem. Just provide an alternative implementation and you don’t have to setup a bunch of test data and config files.
My Read-MailConfig looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function Read-MailConfig ( [string]$configFile ) { if (($configFile -eq $null) -or ($configFile -eq "")) { throw "Mandatory config file to read" } $config = @{} [xml]$MainConfigEmail = Get-Content $configFile $config.from = $MainConfigEmail.infos.email.from $config.client = New-Object Net.Mail.SmtpClient($MainConfigEmail.infos.email.server) # further configure the client return (New-Object PsObject -Property $config) } |
So, there is a Get-Content method that I want to mock and control its return value. I can now modify my test so that the values used by my test are right next to them:
1 2 3 4 5 6 7 8 9 |
Describe "mail" { Context "config file is provided" { Mock -ModuleName mail Get-Content { "<infos><email><from>test@example.com</from><server>smtp.gmail.com</server><login>testlogin</login><pass>password</pass></email></infos>" } It "gets sender from the config" { (Read-MailConfig -configFile "whatever.config").from | Should Be "test@example.com" } } } |
Note the usage of -ModuleName mail in the Mock call: modules have their own scope (which is not the case of plain dot-sourced script files), and so need a bit more work to inject mocks.
My mail module actually sends emails through the System.Net.Mail classes, but Pester can’t mock .Net objects (note that .Net mocking frameworks can’t mock most system classes either).
In order to bypass that, we’re going to extract the .Net object calls into separate methods doing only that, we’re going to mock this extraction, and not test the .Net method call:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# Sends an error email to the ones in the config file function Send-ErrorMessage( [string]$Title, [string]$Body ) { $config = Read-MailConfig -configFile ".\general.config" try { $message = New-Object System.Net.Mail.MailMessage($config.from, $config.from, $Title, $Body) # configure $message Send-Mail -client $config.client -message $message } catch { # handle exception } } # sends an email function Send-Mail( [Net.Mail.SmtpClient]$client, [Net.Mail.MailMessage]$message ) { $client.send($message) } |
And the test:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Context "sends an email" { Mock -ModuleName mail Send-Mail { return "Success" } Mock -ModuleName mail Read-MailConfig { $config = @{} $config.client = $null $config.from = "test@example.com" return $config } It "sends an error message" { Send-ErrorMessage -Title "test" -Body "test" | Should Be "Success" } } |
Use TestDrive to test file system processes
If you need to test for complex file access, mocking system methods will quickly become too hard. For instance, I need to test two methods: one that removes files older than X days, and one that removes empty folders. Using TestDrive is much more straightforward, simple and compact than mocking the Get-ChildItem cmdlet:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
Describe "cleanup module" { Context "folder has old and new files" { New-item "TestDrive:\old.txt" -Type file # change the file creation date $oldFile = Get-Item "TestDrive:\old.txt" $oldFile.CreationTime = (Get-Date).AddDays(-3) New-item "TestDrive:\new.txt" -Type file It "removes the old files" { Remove-OldFiles -path "TestDrive:" -olderThan (Get-Date).AddDays(-2) Test-Path "TestDrive:\old.txt" | Should Be $false } It "leaves the new files" { Remove-OldFiles -path "TestDrive:" -olderThan (Get-Date).AddDays(-2) Test-Path "TestDrive:\new.txt" | Should Be $true } } Context "folders doesn't have old files" { New-item "TestDrive:\new1.txt" -Type file New-item "TestDrive:\new2.txt" -Type file It "does nothing" { Remove-OldFiles -path "TestDrive:" -olderThan (Get-Date).AddDays(-2) Test-Path "TestDrive:\new1.txt" | Should Be $true Test-Path "TestDrive:\new2.txt" | Should Be $true } } Context "path has empty folders" { New-item "TestDrive:\empty" -Type directory It "removes the folder" { Remove-EmptyFolders -path "TestDrive:" Test-Path "TestDrive:\empty" | Should Be $false } } Context "path does not have empty folders" { New-item "TestDrive:\notempty" -Type directory New-item "TestDrive:\notempty\file.txt" -Type file It "does not removes the folder" { Remove-EmptyFolders -path "TestDrive:" Test-Path "TestDrive:\notempty" | Should Be $true } } } |