Unit Testing PowerShell Classes with Pester
- The many layers of an onion
- Building your first PowerShell Class
- Deconstructing the components of a Class
- Expanding on your first PowerShell class
- Unit Testing PowerShell Classes with Pester
One of the most critical aspects to any development project is a solid test plan. Over the years, I have learned my lesson the hard way that everything should include some level of testing. I started our like many of you by simply writing a script and manually trying different scenarios. This ‘trial-and-error’ style of development is painful and just plain error prone. At the time of this writing, I found a lot of details on how to test traditional PowerShell functions but very limited details on Classes. Since our goal is to create class based Desired State Configuration modules, we will learn about Class-based testing.
In today’s post, we will talk about Unit Testing PowerShell classes with Pester. The benefit to using Pester or just about any automated testing platform comes when you realize the confidence that it gives you in your code. Changes to code or features will introduce potential bugs. It is critical that I keep the amount of time in QA down and the amount of time in development UP. Too many times, I have seen small teams sacrifice code integrity for the sake of speed and the all to familiar ‘Humph, Testing is for QA. They can figure it out.’
Before we begin, we want to install the latest version of Pester in our environment. We will use the PowerShell gallery to pull down the current published version. You could also download directly from source code at the Official Pester GitHub page
# Install NuGet to enable pulling the Pester module from the gallery Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force # Install Pester for testing Find-Module Pester | Install-Module -Confirm:$False -Force
Note: We are going to use the code from our previous article in this series –Expanding on your first PowerShell class
Now that we have install the tool, we can create our first test for our ToyBox class. We need to create a base set of tests. Since we already have some code in place, I will start with testing the basic GET()
method. Let’s start by declaring our test to run in the scope of our module by invoking the InModuleScope
function of Pester. This function allows us to drill into the existing module and access the methods that are otherwise not exported. Normally, Pester could only access modules that we exported as part of the Import-Module process but this becomes an issue when dealing with Classes directly.
# Begin Testing try { InModuleScope -ModuleName ToyBox -ScriptBlock { } #InModuleScope } # try finally { # We are done but this could be where we run cleanup of the module or any other action. This code will always run even on failure. } # finally
Now that we have stared down this journey, we will add the defaults for our states. Since we are handling existing and non-existing toys, we need to include both states. It is good to add them as variables that we can call later. I will also add a function to allow us to easily mock and change these between our tests. This code will go inside of the script block for our InModuleScope
Function Set-ObjectMockProperties ($object,[switch]$Exists) { # This function will load the object and set the global script variables with the values supplied. # By doing this, we can later easily change how we interact with the object and make edits along # the way in other Mocks or Tests. New-VerboseMessage 'Establish Aggregate Mock variables and settings' [string]$script:___CurrentName=$object.Name [string[]]$script:___CurrentState = $object.State [int]$script:___CurrentFavorite = $object.Favorite if ($Exists) {$script:DeadmanObjectCreatedTrigger = $true;} else {$script:DeadmanObjectCreatedTrigger = $False;} } # TODO: Add Mock Functions # TODO: Add Mock calls # When testing large sections of code, it is always a good idea to distinguish messages from your test and any that may come from # your code. This function will handle the way that we print out the code moving forward. Function New-VerboseMessage ($message) { $Classifier = '::PESTER:::::' # NOTE: Interpolation of strings is a common trick that I use with messages. In this case, I have a message that I would like # to inject the value of two variables. The '-f' identifies that these variables should be inserted into the string in and # translated to their position in the array that follows. This allows me to easily make changes to the variables while maintaining # and easy to read command. I typically use this technique in my large scripts, modules, and classes to streamline how I print the # the output. write-verbose -message ("{0}`t`t{1}" -f $Classifier, $Message) } # # This an Array of properties that exist in our object and will be referenced in other tests to ensure # all of the data is being returned from the Mocks. This should be a list of ALL the properties in # the object scope. # $Test_Properties = @( 'Name' , 'State' , 'Favorite' ) # # Establish both Passing and Failing Test Parameter Objects for using in the validation tests to streamline # the behavior of the tests. Not setting a property will accept our defaults from the Mock $negativeTestParameters = @{ Name = 'mytoy' State = 'Missing' Favorite = $false } $positiveTestParameters = @{ Name = 'my new toy' State = 'Playing' Favorite = $true } # TODO: Add Test Suites
Now, we should add our first test suite. You will notice that we left a spot in our previous code snippet for the Test Suites. This is always a good way to remember things. If you are like me and tend to do 20 things at a time, you will find this trick very helpful. We will include our basic tests. Pester uses the concept of a Describe as the top-level object in a suite. Typically, you would describe a function or in our case a Method and do certain contextual tests underneath. It is also helpful to note that you can assign tags to a describe which can then be used to run subset of tests across a larger test solution. A good example is a recent DSC project that I did that had over 980 unit tests covering 10 DSC resources. I used tags to filter my tests for specific actions and resources.
Our first goal will be to handle testing the GET()
method of our Class. There are essentially two states in this case – Found or Missing. We will add two contextual test suites in our describe (We define these as Context
calls and give them an easy to read name. The tests below will in the context of the description that we gave. This makes it easy for anyone else to pickup and read or even change our tests. I added a bunch of notes into the components so make sure that you read over the what and why of each object.
# # ToyBox::get() Validation Tests # Describe -Name 'ToyBox Get() Tests' -Tags @('ToyBox','ToyBoxGet','Unit','Get') -Fixture { Set-MockInstances # Initiate the instances of Mock Servers and Environments for the suite Context 'When the Toy does not exist' { BeforeAll { # The BeforeAll will be triggered before each indiviual test to ensure that the data is # loaded and ready. This gives us a clean slate opportunity between tests. Set-ObjectMockProperties -object $negativeTestParameters } # Since our method contains an overload for the Name property when searching, we are goung to create # a new instance of the ToyBox by calling our invoke function 'New-ToyBox' which ultimately leverages # our Mock and then initiate a call to GET() based on the Name property for our $negativeTestParameters # variable. Since this item does not exist, it will return a $NULL name and missing. $result = (New-ToyBox @negativeTestParameters).get($negativeTestParameters.Name) It 'Should return the name as $Null and State as Missing' { $result.Name | Should be $Null $result.State | Should Be 'Missing' } It 'Should call the mock function New-ToyBox' { # Asserting a Mock is a great way to ensure that the behavior in this test matches the path that # we expect our code to take. This is also a great way to capture inefficient code paths (Am I # making too many calls to the same function, Did I call something that I shouldn't, etc) # # If the code should NOT call a mock (For example, if we have a function that would be called on a Save), then # we can easily Asset that the Mock is called -Times 0. This trick will help to ensure that we don't have a mistake # in the code pattern. Assert-MockCalled New-ToyBox -Exactly -Times 1 -Scope Context } }#context Context 'When the ToyExists' { BeforeAll { # We are testing the behvaior that the object should exist when called. We can leverage this call and include our trigger # parameter -Exists to set the DeadmanObjectCreatedTrigger variable to $true. This allows us to stub the output with a valid # object as if it was previously saved. Set-ObjectMockProperties -object $positiveTestParameters -Exists } # We have multiple tests that need to call on the same data. In this case, we will return to the variable the object and use it # across the multiple tests. $result = (New-ToyBox @positiveTestParameters).get($positiveTestParameters.Name) It 'Should return the desired state as Stored' { $result.Name | Should Be 'my new toy' $result.State | Should Be 'Playing' $result.Favorite | Should Be $True } It 'Should return the same values as passed as parameters' { # Previously we declared an array with all of our property names. We will now loop through this to ensure that we are returning # the correct values based on our expectations. This because a very value test when we start changing values in later tests. Foreach ($t in ($Test_Properties| Where-object {$_})) { New-VerboseMessage ("Testing ($t) : $($result.$($t)) | Should Be $( $positiveTestParameters.$($t) )") $result.$($t) | Should Be $positiveTestParameters.$($t) } } It 'Should call the mock function New-ToyBox' { Assert-MockCalled New-ToyBox -Exactly -Times 1 -Scope Context } Assert-VerifiableMocks }#context }
The last piece to this puzzle will be the use of Mocks. In the world of Unit Testing, you want to test your code and how it should behave to potential real-world scenarios. All of these scenarios are important to handling the behavior of the code and some are just difficult to make happen (think Exception faults). A Mock is where we tell the system to not call the real function but instead use our predetermined code instead. We can then stub the output by declaring how we would like for it to behave when we call the function. You will notice that I declare both a New-MockToyBox
function and a Mock New-ToyBox
. The first is a way to easily handle large code bases where I might need to stub this behavior across multiple test suites while the second tells Pester to not trigger the real function but use this one instead. For my larger modules, I typically put the custom Mock Functions like New-MockToyBox
in a separate module that I loaded during my testing. This just keeps things clean and easy to handle.
Function New-MockToyBox { # This function will act as a Mock for our existing New-ToyBox function. The benefit to this behavior is that # we can easily stub out the expected responses for the call. We have included a Switch parameter call Empty # to allow us the ability to return either an item with default values (in the event that we want to test the output) # or an empty object to which we can assign any value. [CmdletBinding(supportsshouldprocess=$true)] Param([switch]$empty) $_mockToyBox = [ToyBox]::new() # The NEW() method is inherited by default for a class and does not need to be created. if ($empty) { # Since the empty parameter was sent, we will need to return just be new object with no data. return $_mockToyBox; } else { $_mockToyBox.Name = 'mytoy' $_mockToyBox.State = 'Missing' $_mockToyBox.Favorite = $false return $_mockToyBox; } } # This is a function of Mocks and makes life easier when we need to re-initialize Mocks between test suites. The benefit to # to leveraging this function is seen when we have multiple suites that are not interdependent on each other. Each test should # be treated as an independent state with no dependency on other tests. This will ensure that the code that we test meets all # requirements and the correct behavior can be tested. Function Set-MockInstances () { Mock New-ToyBox{ New-VerboseMessage 'Mock New-ToyBox' if ($script:DeadmanObjectCreatedTrigger) { $ToyBox = New-MockToyBox $ToyBox.Name = $___CurrentName $ToyBox.State = $___CurrentState $ToyBox.Favorite = $___CurrentFavorite return $ToyBox } else { return (New-MockToyBox -Empty) } } }
Hopefully this post helps you think about your testing and configurations. As we move forward, we will start working on PowerShell Desired State Configurations and leveraging Pester as our testing platform. In upcoming sections, we will also explore how to create our first Desired State Configuration and writing tests to improve our code.
All of the code from today is found here: https://gist.github.com/exospheredata/c93564b844a3d85d8dca2e3299cc16c1