-
-
►Practical PowerShell Unit-Testing: Mock Objects
-
Contents
In the previous installment, you saw how to install and use Pester, and the basic structure of unit tests in PowerShell. This article covers mocking and parameterized test cases. The third installment will conclude with data and program flow validation.
Mocking
Mock objects, or ‘mocks’, are objects that are deliberately created to simulate other objects as simply as possible. They are frequently used in unit-testing to mimic the behavior of real objects in controlled ways, when it is impractical or time-consuming to use the real object. Pester creates mocks with the Mock
command, which has a very simple syntax yet provides a lot of flexibility at the same time. Let’s see how it works.
Say you want to create a simple function that returns a list of file names in the current directory that are text files (i.e. those with a .txt suffix). The first test you might write is to see if it can return a single such file:
1 2 3 4 5 |
Describe 'Get-TextFileNames' { It 'returns one text file' { Get-TextFiles | Should Be 'a923e023.txt' } } |
You might write the code for Get-TextFileNames
like this:
1 2 3 |
function Get-TextFileNames() { Get-ChildItem | Where Name -like *.txt | Select -expand Name } |
That is, use Get-ChildItem
to list the files in the current directory, filter the list to those ending in .txt
, and from the remaining items-a list of FileInfo
objects-return just the name.
Run that test and it will fail (unless you happen to have a file called a923e023.txt
-and no other text files-in your directory. With no text files in your directory, you would see this:
1 2 3 4 5 6 |
PS> Invoke-Pester Executing all tests in 'C:\usr\tmp' Describing Get-TextFileNames [-] returns text files 346ms Expected: {a923e023.txt} But was: {} |
You can immediately see that it is a fragile test indeed that happens to depend on the contents of your current directory. Even if you create that text file here for the sake of the test, the next time you want to run your test you might be in a different directory. Or perhaps a colleague wants to run the test on his/her machine. Or you want your build machine that supports your continuous integration to run it. The goal, then, is to isolate tests from outside influences-like your file system. This is a great use case for mocks.
For this test, then, we want to mock the Get-ChildItem
cmdlet so that it returns a single FileInfo
object representing a file named a923e023.txt.
To do this we use the Mock
command, indicating the command we want to mock and what we want it to return rather than calling the real Get-ChildItem
:
1 2 3 |
Mock -CommandName Get-ChildItem -MockWith { [PSCustomObject]@{ Name = 'a923e023.txt' } } |
The MockWith
argument is a PowerShell script block, i.e. just a set of statements surrounded by braces. In this case, we have a single statement:
1 |
[PSCustomObject]@{ Name = 'a923e023.txt' } |
This is a concise way to construct a PowerShell object that looks sufficiently like a particular real object for our needs. In this case, we are creating an object with a single Name
property, because that is precisely the property that our code is using; here it is again:
Now let’s put that mock into the test shown earlier (here I have abbreviated the command by omitting the parameter names -CommandName
and -MockWith
).
1 2 3 4 5 6 |
Describe 'Get-TextFileNames' { It 'returns one text file' { Mock Get-ChildItem { [PSCustomObject]@{ Name = 'a923e023.txt' } } Get-TextFileNames | Should Be 'a923e023.txt' } } |
This test will now pass no matter what directory you are in or what machine you are on. We have completely isolated the test from the real Get-ChildItem
and thus from the real file system.
This test might seem somewhat pointless, though, because I fed it the precise input I am attempting to validate against. But that is not the case! This is a real test and a useful one. It validates that the current implementation of Get-TextFileNames
is working (for this one set of data). Here are just a couple variations of the implementation where that test would have failed:
By controlling for Get-ChildItem
(with a mock) we are allowing the test to validate the remaining cmdlets within Get-TextFileNames
. Let’s really give Get-TextFileNames
a workout by expanding our test coverage a bit to see how robust it is. Using the same simple mock, returning different sets of data, we can mimic a real directory that could have varied contents. I have included the test above as the first test here, but expanded its description to make it clear what that test is validating.
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 |
Describe 'Get-TextFileNames' { It 'returns one text file when that is all there is' { Mock Get-ChildItem { [PSCustomObject]@{ Name = 'a923e023.txt' } } Get-TextFileNames | Should Be 'a923e023.txt' } It 'returns one text file when there are assorted files' { Mock Get-ChildItem { [PSCustomObject]@{ Name = 'a923e023.txt' }, [PSCustomObject]@{ Name = 'wlke93jw3.doc' } } Get-TextFileNames | Should Be 'a923e023.txt' } It 'returns multiple text files amongst assorted files' { Mock Get-ChildItem { [PSCustomObject]@{ Name = 'a923e023.txt' }, [PSCustomObject]@{ Name = 'wlke93jw3.doc' }, [PSCustomObject]@{ Name = 'ke923jd.txt' }, [PSCustomObject]@{ Name = 'qq02000.doc' } } Get-TextFileNames | Should Be ('a923e023.txt','ke923jd.txt') } It 'returns nothing when there are no text files' { Mock Get-ChildItem { [PSCustomObject]@{ Name = 'wlke93jw3.doc' }, [PSCustomObject]@{ Name = 'qq02000.doc' } } Get-TextFileNames | Should BeNullOrEmpty } } |
There are several more tests I would include, even for a function as simple as this, but I have kept the list short for this article. The above is a perfectly valid set of tests you might use, but in practice, I would use the power of PowerShell to streamline that a bit. First, introduce a helper function, a mini-factory, to create my data set. Here I am taking in a list of names and generating FileInfo
-like objects, including both the Name
property that you have seen in the above tests, and the FullName
property, which you might use in additional tests.
1 2 3 4 5 6 7 |
function CreateFileList([string[]]$names) { $names | ForEach { [PSCustomObject]@{ FullName = "c:\foo\bar\$_"; Name = $_; } } } |
With that helper function added to the test file, the above set of tests can now be written more concisely as:
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 |
Describe 'Get-TextFileNames' { It 'returns one text file when that is all there is' { $myList = 'a923e023.txt' Mock Get-ChildItem { CreateFileList $myList } Get-TextFileNames | Should Be 'a923e023.txt' } It 'returns one text file when there are assorted files' { $myList = 'a923e023.txt','wlke93jw3.doc' Mock Get-ChildItem { CreateFileList $myList } Get-TextFileNames | Should Be 'a923e023.txt' } It 'returns multiple text files amongst assorted files' { $myList = 'a923e023.txt','wlke93jw3.doc','ke923jd.txt','qq02000.doc' Mock Get-ChildItem { CreateFileList $myList } Get-TextFileNames | Should Be ('a923e023.txt','ke923jd.txt') } It 'returns nothing when there are no text files' { $myList = 'wlke93jw3.doc','qq02000.doc' Mock Get-ChildItem { CreateFileList $myList } Get-TextFileNames | Should BeNullOrEmpty } } |
Using the CreateFileList
mini-factory function, we reduced the suite of unit tests by about one-third. After introducing a few more Pester concepts, you will see a further substantial reduction for this same group of tests.
Mocking gets particularly interesting when you also add the ParameterFilter
and/or use multiple mocks, discussed shortly.
But first, I want to mention the potential for abuse with mocks. This is not peculiar to PowerShell, but it certainly applies to PowerShell now that you have mocking capability with Pester. The issue is that of state-based testing (testing the result of a method) vs. behavior-based testing (testing what happened during a method call). While there are arguments for both sides of the issue, I believe that judicious use of both techniques provides the best results. Often the code itself tells you what kind of test might be a better fit: if you have a method that generates no side effects and returns a value, then clearly state-based testing is better. For a method that has side effects and returns no result, then clearly behavior-based testing is appropriate. The problems with behavior-based testing are nicely summarized by Matt Wrock in his post Unit Testing PowerShell and Hello Pester:
“We are reaching deeper into the function and assuming we know more that we should about what the function is doing. We are testing the implementation details of the function rather that the outcome of its state. The behavior based tests open the testing framework up to added fragility since changes in implementation often requires changing the tests. Mocking can lead developers to overusing behavior based tests since it makes it so easy to test the function’s interactions at various points of the function’s logic.”
PowerShell, as Wrock further points out, is perhaps more challenging to do state-based testing just due to the nature of the “PowerShell ecosystem” (Wrock’s phrase, which I think is particularly apropos). With my own explorations, thus far, I aim for state-based testing, but I still find I need to use behavior-based testing in many cases.
Multiple Mocks Example
Here I am setting up two mocks for the same cmdlet, Select-String
. The first mock introduces the ParameterFilter
argument that lets you limit the set of inputs for which a mock will trigger. Here it is rather selective; it will be triggered only for values of $Path
sent to Select-String
that match the filter (which is a regular expression specifying to match anything ending in B, D, or E).
There are two key things to know when using multiple mocks. First, mocks are examined for triggering in the order you define them. Second, only one mock gets triggered for a given CommandName
value. Therefore, because the unconditional mock occurs after the more selective mock, the second mock is a “catch-all”; anything that the first mock chose to ignore will trigger the second mock.
1 2 3 |
$filter = '(B|D|E)$' Mock Select-String { "matched!" } -ParameterFilter { $Path -match $filter } Mock Select-String |
Thus, the first mock will trigger for “fileB”, returning “matched!”, while for an input of “fileA” the second mock will trigger, returning nothing (because the MockWith
parameter is not specified). Extend that with more cases and you have successfully emulated a standard switch
statement.
Logic-Added Mock Example
The logic in this next example is just another way to produce results identical to the previous example. Here, instead of using multiple mocks and a ParameterFilter
, though, it uses additional logic within a single mock script block.
1 2 3 4 |
$filter = '(B|D|E)$' Mock Select-String { if ($Path -match $filter) { "matches found" } } |
The MockWith
parameter is just a standard PowerShell script block, so you may include whatever logic you wish. And here, you could even use a standard switch
statement, if you had more cases to handle.
Now let’s put it all together. Here is a function under test (GetFilesLackingItem
) and a single unit test for it. The function returns the names of files that do not contain a line having the specified pattern. It uses the standard PowerShell cmdlets Get-ChildItem
and Select-String
to do most of the work, then decorates them with some simple manipulations. It is those manipulations that need to be tested rather than the functionality of either cmdlet. Thus, the test sets up mocks for both Select-String
and Get-ChildItem
. You will see the data generation technique described just above used here to provide mock data for Get-ChildItem
. You will also see the multiple-mock technique described above to provide intelligence to the mock for Select-String
. (You can try out this code by copying both source and test into a single Demo.Tests.ps1 file and then running Invoke-Pester
on the containing directory.)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
function GetFilesLackingItem([string]$pattern) { $filesFound = Get-ChildItem -r *.csproj | ? { !( Select-String -pattern $pattern -path $_.FullName ) } if ($filesFound) { $filesFound.Name } else { @() } } Describe "GetFilesLackingItem" { Context "checks some files" { It "reports subset of files missing item" { $fileList = "nameA", "nameB", "nameC", "nameD", "nameE" | % { [PSCustomObject]@{ FullName = $_; Name = $_; } } Mock Get-ChildItem { return $fileList } $filter = '(B|D|E)$' Mock Select-String { "matches found" } -param { $Path -match $filter } Mock Select-String $result = GetFilesLackingItem "dummy" $result.Count | Should Be ($fileList.Name -notmatch $filter).Count } } } |
Use one or more mocks to isolate your unit under test.
Parameterized Test Cases
A parameterized test case is a simple but powerful testing technique, but surprisingly unknown enough to not even have spawned a Wikipedia entry! It is really as straightforward as it sounds: a test case that takes parameters. It is analogous to a function. Consider first this function without parameters:
1 2 3 4 |
function f() { return 5 + 23; } |
When you invoke f() you always get the sum of 5 and 23. Quite… unexciting. Now jazz that up with parameters creating-you guessed it-a parameterized function:
1 2 3 4 |
function f(int a, int b) { return a + b; } |
Now you can still get 28 by invoking f(5, 23) but having parameterized your inputs you now have the freedom to do f(0, -123), f(42, 42), and so on ad infinitum.
A parameterized test serves exactly the same purpose. Say you write a regular test case that validates some function f with 1 feinberger returns 2 ounces of quadrotriticale. Then you write another test that validates f with 2 feinbergers returns 3 ounces of quadrotriticale. This second test is virtually identical to the first, except for the size of the input and the resultant size of the output. To eliminate all the duplicated code, you just have to feed sets of input/output parameters to a parameterized version of the test function.
You can find support for parameterized test cases in NUnit for .NET but Pester does not have native support for this… yet! I submitted a feature request for this recently and the Pester crew responded readily to adding native support in the near future. But you do not have to wait; due to the nature of PowerShell it is elegantly simple to do this yourself.
Simple Parameterized Test Example
First, wrap your test inside a function. The function signature should include all the input and output elements you wish to parameterize and, of course, the body of the function should use those parameters (the Test-SafeProperties
function in the code below). Next specify each set of inputs and outputs to feed to the function as an array whose elements are ordered to match the parameter order of the function signature. Add as many data sets (tests) to this list as you wish (the $dataSets
variable in the code). Finally, marry the two together by piping the data to the function applying PowerShell’s splatting functionality (see about_splatting).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
Context "for deep property failure" { # wrap the test in a function function Test-SafeProperties($parent, $leaf, $description, $defaultValue = "") { It "returns default when $description" { $result = Get-SafeProperty $testItem $parent,$leaf $defaultValue $result | Should Be $defaultValue } } # define multiple sets of test data $dataSets = ( ("BaseItem", "", "no leaf specified"), ("BaseItem", "NonExistent", "non-existent leaf specified", "none"), ("NonExistent", "ChildItem", "non-existent parent specified", 25) ) # feed the data to the test function to run all tests $dataSets | % { Test-SafeProperties @_ } } |
Parameterized Test Example with ContextUsing
The example above actually provides a complete, viable technique to do parameterized tests. However, I try to aspire to the three great virtues of a programmer (as espoused by Larry Wall, the author of Perl); in this case, laziness is particularly apropos. To be able to write a more concise version of the above code that performs equivalently, I wrote an extension for Pester’s Context
command called ContextUsing
. The original syntax of the Context
command is:
1 |
Context [-Name] <String> [-Fixture] <ScriptBlock> |
The new ContextUsing
command adds the TestCases
parameter:
1 |
ContextUsing [-Name] <String> [-TestCases] <Object[][]> [-Fixture] <ScriptBlock> |
The code below shows one example usage. Notice that the script block must start with a param()
statement that enumerates the parameters of the script block (or anonymous function if you prefer). Each test case, then, must specify values for each of those parameters.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
ContextUsing "for deep property failure" ( ("BaseItem", "", "no leaf specified"), ("BaseItem", "NonExistent", "non-existent leaf specified", "none"), ("NonExistent", "ChildItem", "non-existent parent specified", 25) ) { param($parent, $leaf, $description, $defaultValue = "") It "returns default when $description" { $result = Get-SafeProperty $testItem $parent,$leaf $defaultValue $result | Should Be $defaultValue } } |
Use a parameterized test to avoid massive code duplication.
Note 1: Just as the Test Anatomy section in part 1 warned you about placement of opening braces for script blocks, the opening parenthesis of the test case list here must start on the same line as the ContextUsing
as shown, and the closing parenthesis must be on the same line as the opening brace of the script block.
Note 2: A handy technique is to add a parameter ($description
in the above code) that rather than being used by the test for validation just describes the test. That parameter is used in the description of the It
command, and is displayed in your test output.
Note 3: To add ContextUsing
to your Pester distribution, simply copy the ContextUsing.ps1 file (attached to this article) into your Pester distribution, then re-import the Pester module into your PowerShell window (i.e. use Import-Module
with the -Force
parameter).
Note 4: ContextUsing
does not support passing only a single test case. That is, there must be at least two sets of data supplied to the TestCases
parameter. This is not really a limitation per se, because if you only have one test case, you do not need ContextUsing
!
Get-TextFileNames Example Revisited
As promised earlier, let’s go back to the Get-TextFileNames
example at the top of this article and see how, with parameterized tests you can achieve yet another dramatic reduction in line count. If you look back at the 4 tests for Get-TextFileNames
, you will observe that all four tests are exactly the same except for the list of files being fed into the mock and the result that we expect to get back from the function under test. In this case, it is only a couple lines of code but you can imagine that more involved tests with perhaps a dozen or two dozen lines would cause a tremendous amount of duplicated code. Parameterized tests, as you have seen, eliminate this code duplication. Without further ado, here is the set of 4 test for Get-TextFileNames
rewritten as a set of parameterized tests. I have spread out and commented the test data to make it easier to visually distinguish the arguments, but there are now only two lines of actionable code: one to define the mock and one to validate the function with the configured mock.
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 |
Describe 'Get-TextFileNames' { ContextUsing "file combinations" ( # Data for test #1 ('a923e023.txt', # the $files parameter 'a923e023.txt', # the $expectedResult parameter 'one text file when that is all there is'), # the description parameter # Data for test #2 (('a923e023.txt','wlke93jw3.doc'), 'a923e023.txt', 'one text file when there are assorted files'), # Data for test #3 (('a923e023.txt','wlke93jw3.doc','ke923jd.txt','qq02000.doc'), ('a923e023.txt','ke923jd.txt'), 'multiple text files amongst assorted files'), # Data for test #4 (('wlke93jw3.doc','qq02000.doc'), $null, 'nothing when there are no text files') ) { param($files, $expectedResult, $description) It "returns $description" { Mock Get-ChildItem { CreateFileList $files } Get-TextFileNames | Should Be $expectedResult } } } |
Note that only two of the three parameters are used in running the test: $files
and $expectedResult
. The third parameter ($description
), as mentioned above, is for identifying the test. Run the tests and you will see that the description is valuable to have:
1 2 3 4 5 6 |
Describing Get-TextFileNames Context file combinations [+] returns one text file when that is all there is 155ms [+] returns one text file when there are assorted files 34ms [+] returns multiple text files amongst assorted files 35ms [+] returns nothing when there are no text files 24ms |
Conclusion
This article showed how to make one test function actually cover multiple test cases; PowerShell makes that particularly easy to do. It also introduced mocks, which, as you have seen, make it easy to isolate components so you can focus on testing just your chunk of code without external influences. But mocks serve an additional purpose, providing hooks to validate program flow. The next installment explains how to do that, as well as validating data, providing some additional support to validate array data.
Load comments