Monday, October 15, 2018

Testing PowerShell Modules with Pester

The series so far:

  1. Introduction to Testing Your PowerShell Code with Pester
  2. Advanced Testing of Your PowerShell Code with Pester

In the first article of this series, I introduced testing PowerShell code with Pester, covering the use of the Describe, It and Should functions. The second article built on this knowledge to show the use of mocks, the test drive folder, and showed examples of what unit, integration, and acceptance tests may look like. In this article I’ll build on that to show how to match tests with requirements, and how to properly test a PowerShell module.

Before I begin, I do want to mention a few things. First, I’m going to assume you are comfortable with module development, so I won’t delve into details like the difference in PSM1 and PSD1 files. Second, I’ll assume you’ve read the previous two articles on Pester and have already installed Pester on your system. Finally, there is a lot of code for the complete project, too much to attempt to reprint. In order to get full value from this article you will want to download the demo project from my github site. There is a zipped file that you can download to avoid dealing with individual files.

Requirements

In my previous article, I stressed how important requirements were to the success of a testing endeavor. For this article, you are going to create a fictitious scenario. You work for a company called PodcastSight, which aggregates podcast feeds from around the world. For each podcast you create a PowerShell module to manage that particular podcast. The module should read the list of available episodes via its RSS feed, and download both the episode art and the episode itself. In addition, the module should create an HTML webpage with the current RSS feed data, as well as an XML file that will be used by other components within the fictitious PodcastSight company. You have been given a set of requirements to develop this module.

Each module should be named Podcast-podcastname. For this demo, you will be using a podcast called No Agenda (http://noagendashow.com). This podcast has a license for unrestricted use, in other words, it can be used however you wish without concern for legal issues. In addition, each episode has new podcast art, making that part of the demo much more interesting. Therefore, the first requirement dictates the module be named Podcast-NoAgenda. In addition, requirements dictate the module must have both a PSM1 and PSD1 file.

There are also structural requirements, ones that dictate how the module should be assembled.

  • The code for each function should be in its own PS1 file.
  • Each file with a function must begin with the name function-, then the name of the function, with of course a ps1 extension.
  • Each function must have its own corresponding test file.
  • Each function must have a help section, which includes the synopsis, description, and at least one example.
  • All functions must contain at least one Write-Verbose statement, letting the caller know the function is executing.
  • Functions must be coded as advanced functions. They need a clearly defined parameter block, and must be pipeline enabled where it makes sense.

Now you come to the requirements that implement the functionality for the module. Below is a list of functions the designer has created, and their purpose. The first two are to be implemented as public functions, meaning they can be called outside the module. The remaining functions are private, meaning they can only be called from within the module itself.

Get-NoAgenda – This is the master function. Calling it will execute all of the other functions needed to download the episodes and artwork, as well as create the XML and HTML files.

Get-PodcastData – This function will call the RSS feed and then parse the data, putting into an array of custom objects you will refer to as Podcast Data. The output of this function, Podcast Data, will be used to feed all of the other functions.

Get-PodcastImage – For this function you pass in the Podcast Data. It will then download the artwork for each episode into a location specified by the user, or a default location.

Get-PodcastMedia – This function will take the Podcast Data and download each episode; the files from NoAgenda are in MP3 format.

ConvertTo-PodcastXML – Will take the Podcast Data array and generate an XML structure needed by PodcastSight.

Write-PodcastXML – This function takes the output of ConvertTo-PodcastXML and writes it to a disk location specified by the user or uses the default location defined in the parameters.

ConvertTo-PodcastHTML – Takes the Podcast Data array and generates an HTML page that could be used on the PodcastSight website.

Write-PodcastHTML – This takes the output of ConvertTo-PodcastHTML and writes it to either the default location defined in the parameters or a disk location specified by the user.

Each function should have a set of tests to ensure they meet the basic requirements. In addition, there should be tests for the structure of the module, i.e., ensuring help is present, there are Write-Verbose, and the like. PodcastSight allows the Acceptance tests to also serve as Integration tests, so it is only necessary to author Unit tests and Acceptance tests.

A Note on Requirements Documents By no means should you consider the above section a detailed requirements document. A true requirements document would be much more detailed and many pages long. It would include an explicit list of parameters for each function, data types, return data types, and more. This was just to provide a quick overview of requirements for this article.

The Demo

You will find the demo for this article on my GitHub site. As with my previous articles, I’ll be using my C:\PowerShell folder to store them locally, for this article they will go into C:\PowerShell\Pester-Module. Within this folder you will download the module sample and place it into C:\PowerShell\Pester-Module\Podcast-NoAgenda. Before you attempt to run any code, you’ll also need to create two additional folders in C:\PowerShell\Pester-Module, Podcast-Data and Podcast-Test. The first folder is where files will go to by default when they are downloaded. The latter folder is used by the tests as a temporary location during their execution.

Next, there are two files you will want to place directly in C:\PowerShell\Pester-Module. The first is Execute-Tests.ps1. This has code you can use to execute each individual test, or all of the tests at once if you desire. The second is DeployModule.ps1. It will take your module and deploy it to your PowerShell folder inside your Documents folder so you can call the module directly without having to explicitly load it from a folder location. As a break down of it would make this article too long, I have a complete writeup on my blog.

Testing the Structure

The first set of tests are focused on the structure of the module. Does it have all the required files? Does each file have the needed components? Those sorts of things will be validated in the structure tests.

For each example in this article I’ll list the code, and will include line numbers to make it easy to reference in the description. If you choose to copy and paste code from here be sure to remove any line numbers, or even better just download the samples which have the line numbers removed.

The structure tests can be found in the Podcast-NoAgenda.Module.Tests.ps1 file.

01: $here = Split-Path -Parent $MyInvocation.MyCommand.Path
02: 
03: $module = 'Podcast-NoAgenda'
04: 
05: Describe -Tags ('Unit', 'Acceptance') "$module Module Tests"  {
06: 
07:   Context 'Module Setup' {
08:     It "has the root module $module.psm1" {
09:       "$here\$module.psm1" | Should -Exist
10:     }
11: 
12:     It "has the a manifest file of $module.psd1" {
13:       "$here\$module.psd1" | Should -Exist
14:       "$here\$module.psd1" | Should -FileContentMatch "$module.psm1"
15:     }
16:     
17:     It "$module folder has functions" {
18:       "$here\function-*.ps1" | Should -Exist
19:     }
20: 
21:     It "$module is valid PowerShell code" {
22:       $psFile = Get-Content -Path "$here\$module.psm1" `
23:                             -ErrorAction Stop
24:       $errors = $null
25:       $null = [System.Management.Automation.PSParser]::Tokenize($psFile, [ref]$errors)
26:       $errors.Count | Should -Be 0
27:     }
28: 
29:   } # Context 'Module Setup'
30: 
31: 
32:   $functions = ('Get-NoAgenda',
33:                 'Get-PodcastData',
34:                 'Get-PodcastMedia',
35:                 'Get-PodcastImage',
36:                 'ConvertTo-PodcastHtml',
37:                 'ConvertTo-PodcastXML',
38:                 'Write-PodcastHtml', 
39:                 'Write-PodcastXML'
40:                )
41: 
42:   foreach ($function in $functions)
43:   {
44:   
45:     Context "Test Function $function" {
46:       
47:       It "$function.ps1 should exist" {
48:         "$here\function-$function.ps1" | Should -Exist
49:       }
50:     
51:       It "$function.ps1 should have help block" {
52:         "$here\function-$function.ps1" | Should -FileContentMatch '<#'
53:         "$here\function-$function.ps1" | Should -FileContentMatch '#>'
54:       }
55: 
56:       It "$function.ps1 should have a SYNOPSIS section in the help block" {
57:         "$here\function-$function.ps1" | Should -FileContentMatch '.SYNOPSIS'
58:       }
59:     
60:       It "$function.ps1 should have a DESCRIPTION section in the help block" {
61:         "$here\function-$function.ps1" | Should -FileContentMatch '.DESCRIPTION'
62:       }
63: 
64:       It "$function.ps1 should have a EXAMPLE section in the help block" {
65:         "$here\function-$function.ps1" | Should -FileContentMatch '.EXAMPLE'
66:       }
67:     
68:       It "$function.ps1 should be an advanced function" {
69:         "$here\function-$function.ps1" | Should -FileContentMatch 'function'
70:         "$here\function-$function.ps1" | Should -FileContentMatch 'cmdletbinding'
71:         "$here\function-$function.ps1" | Should -FileContentMatch 'param'
72:       }
73:       
74:       It "$function.ps1 should contain Write-Verbose blocks" {
75:         "$here\function-$function.ps1" | Should -FileContentMatch 'Write-Verbose'
76:       }
77:     
78:       It "$function.ps1 is valid PowerShell code" {
79:         $psFile = Get-Content -Path "$here\function-$function.ps1" `
80:                               -ErrorAction Stop
81:         $errors = $null
82:         $null = [System.Management.Automation.PSParser]::Tokenize($psFile, [ref]$errors)
83:         $errors.Count | Should -Be 0
84:       }
85: 
86:     
87:     } # Context "Test Function $function"
88: 
89:     Context "$function has tests" {
90:       It "function-$($function).Tests.ps1 should exist" {
91:         "$here\function-$($function).Tests.ps1" | Should -Exist
92:       }
93:     }
94:   
95:   } # foreach ($function in $functions)
96: 
97: }

Line 1 contains something you’ll see common to most tests. The test needs to determine the folder it is running in; for that you can reference the built-in $MyInvocation variable. This has information about the script PowerShell is currently executing. One of the properties is MyCommand which has the full path and file name being run. Here you can just reference its Path property to get the folder name and place it in a variable named $here.

In line 3, the name of the module being tested is placed into a variable. I have found the structural requirements for testing modules to be fairly consistent across modules, by placing the module name into a variable, it allows for greater reuse. This test could simply be copied and used with another module with a quick update to this variable, and one other which will be mentioned shortly.

Line 5 begins the Describe block for this test. With this, the same test can be used for both Unit and Acceptance tests, so both have been placed as tags. The name for the describe block is found next. Note how you can leverage the power of PowerShell’s string interpolation to generate the name using the variable.

This test script is a bit long so it was broken into logical parts using Context blocks, the first of which begins at line 7.

Line 8 has the first test, to ensure the module’s PSM file exists. This may seem a bit odd, but recall from a previous article the concept of Test Driven Development (TDD). With TDD, you write all of your tests first, then begin to author your actual code. Running this test without having written any of the modules code will help you keep track of what has been done and what still remains.

In line 12, there is another It block, and it actually contains two tests. The first test on line 13 simply checks to see if the manifest file exists. In line 14, you must make sure the manifest contains a correct reference to the module. Often people will copy over manifest files from another project to use as a starting point. The -FileContentMatch switch will look inside the PSM1 file to ensure the module name exists inside the manifest, helpful in case you had copied from another project and forgotten to update it with the correct module name.

Line 17 validates that at least one function exists. In a moment you will look for the existence of specific functions, but for now this test will ensure at least one has been created.

Line 21 contains an interesting test. Is the code in the module valid, in other words are there any syntax errors? You’ve often heard PowerShell is built on .NET. The specific set of libraries for PowerShell can be found in System.Management.Automation. One of the many classes it contains is PSParser, which has a static method named Tokenize. Static methods can be executed without having to generate an object from them. You do so by using the syntax shown here, the name of the class, followed by two colons, and finally the method to execute.

The Tokenize function needs two parameters. The first is the code to be analyzed. You get the code in lines 22 – 23, using Get-Content to load it in memory, and if there was an error you stop the test. The second parameter is actually an output parameter, an array of errors it finds. First then you’ll have to create a variable to hold the output; this is done in line 24. The variable can be empty, hence you can set it to null, but it does have to exist in PowerShell’s scope prior to calling the Tokenize method.

Line 25 makes the call to Tokenize. Note the use of [ref] before the $errors variable name, this will pass the memory address of the $errors variable so it can be updated.

Finally, line 26 accesses the count property of the $errors array. If it is zero, there are no syntax errors and you are good to go. Otherwise this test fails. You could go further and display the contents of $error but for this simple test, this will be sufficient. The test for valid PowerShell code will close out the beginning context block.

Beginning in line 32, an array is loaded with the names of the module’s functions. This is the second variable you’d need to change when copying this script to test another module. Following this, on line 42, you begin a foreach loop which will iterate over each function name in the array.

Line 45 declares a context block. By doing it inside the loop, you generate a context block for each individual function. This makes the output much easier to read. The first test, on line 47, simply checks to see if the file exists yet. Remember the requirement that the file name for each function begins with function- followed by the name of the function.

Lines 51 to 66 represent the checks for help text. It uses the Should functions -FileContentMatch to ensure the text needed for a help section is present. Contrast this section with the code in lines 68 to 71. In that area you are validating that the keywords to make this function an advanced function are present. All three checks were done in a single It block.

What is the advantage of one over the other? With the second example, all three of the checks have to pass for the advanced function test to pass. If any of the three fail, the advanced function will fail, but you may not necessarily know which of the conditions caused it to fail. By breaking it up into individual tests, as you did with the test for help text, you will know exactly which condition failed to meet requirements.

Which way you go is up to you. For this test, I decided if the function test failed, it would be fairly easy to diagnose as the function declaration is easy to find in source code with the cmdletbinding and param blocks close by. Help text on the other hand can get long, and it may not be as easy to determine which of the required components were missing, hence the decision to break them into individual tests. Again, you will have to evaluate your situation and decide which conditions to group together inside your It blocks.

The remainder of the test is pretty straightforward. In line 74, a check to validate the code has at least one Write-Verbose block is made. Then in line 78, the Tokenizer validates the function is valid PowerShell code, in other words, there are no syntax errors.

Line 89 wraps up the structural tests on line 89 with one final context block that validates that the function has a corresponding tests file.

Get-PodcastData – The Function

For this article you’ll select two functions which will exemplify the type of code you’ll see when authoring function tests. The first of these is the Get-PodcastData function. As you can see, it is fairly simple.

01: function Get-PodcastData()
02: {
03:   [CmdletBinding()]
04:   param
05:   (
06:     [parameter (Mandatory = $false) ]
07:     [string] $rssFeed = 'http://feed.nashownotes.com/rss.xml'
08:   )
09: 
10:   Write-Verbose "Get-PodcastData: Getting RSS Data from $rssfeed"
11:   
12:   $webData = Invoke-RestMethod $rssFeed
13: 
14:   # Use Select-Object to take each object returned and convert it to
15:   # custom objects. Name will become the property name of the new object,
16:   # Expression is the value we are extracting from the returned web data
17:   $rssData = @()
18:   foreach($rss in $webData)
19:   {
20:     # Hosts needs extra cleanup. Originally it came in the feed as a
21:     # string, now it seems to be coming in as an array
22:     if ($rss.author.GetType() -eq 'string')
23:     {
24:       $hosts = $rss.author
25:     }
26:     else
27:     {
28:       $hosts = $rss.author[0]
29:     }
30:     $hosts = $hosts.Replace('&', 'and') # & messes up XML
31: 
32:     $rssData += [PSCustomObject][Ordered]@{
33:       # Note the & will mess up the XML, so we'll replace it
34:       PSTypeName = 'PodcastSight.Podcast'
35:       Title = $rss.title.Replace('&', 'and')
36:       ShowUrl = $rss.link
37:       EmbeddedHTML = $rss.summary
38:       Hosts = $hosts
39:       PublicationDate = $rss.pubDate
40:       ImageUrl = $rss.image.href
41:       AudioUrl = $rss.enclosure.url
42:       AudioLength = $rss.enclosure.length 
43:     }
44:   }
45:  
46:   return $rssData
47: 
48: }

Lines 1 to 8 are the function declaration. You have one parameter, which defaults to the RSS feed URL for the show. This allows you to overwrite it should the feed ever get moved, but PodcastSight doesn’t wish to make changes to the code. Line 10 has the required Write-Verbose statement.

Line 12 is the heart of the function; it uses Invoke-RestMethod to read the RSS feed and return it as a blob of XML data. It’s a bit difficult to use XML effectively, so in the next section you convert it to a custom object.

Line 17 declares an empty array variable; this will hold the output of the cleanup routine. Line 18 begins a loop that iterates over each row in the XML web data.

Within lines 22 to 30, an issue specific to the No Agenda podcast is addressed. Originally the RSS feed brought back the authors as a single string. At some point, however, it was modified to return an array. In this block of code, you determine which it is, and use the appropriate syntax to copy it into the $hosts variable. As long as you’re working with this variable, you replace any ampersand (&) characters with the word ‘and’ as ampersands play havoc with the XML you want to output later.

Next up, you take the XML row of data and convert it to an object, adding it to the array in the process. In line 32 the += will add everything following it as a new item to the $rssData array. You then create a hash table, but by prepending it with the [PSCustomObject]. PowerShell will actually convert the hash table to a custom object. The inclusion of [Ordered] will ensure PowerShell keeps the properties in the order you declare them.

PowerShell allows creating a custom type name instead of being stuck with the PSCustomObject default. To do so, you declare a property of PSTypeName and assign a value to it. When the object is created, it will then have the type name you give it, as done in line 34.

So now that you’ve seen the function, take a look at the code to test it.

Get-PodcastData – The Tests

As you’ve seen, the Get-PodcastData function is fairly simple. The only thing that you might consider mocking is the Invoke-RestMethod cmdlet. However, to mock it you would just return a set of hard coded XML, which would always pass the test, and not be of much value. As such PodcastSight has decided to let the same test work for both Acceptance and Unit testing. Here is the test:

01: # Get the path the script is executing from
02: $here = Split-Path -Parent $MyInvocation.MyCommand.Path
03: 
04: # If the module is already in memory, remove it
05: Get-Module Podcast-NoAgenda | Remove-Module -Force
06: 
07: # Import the module from the local path, not from the users Documents folder
08: Import-Module $here\Podcast-NoAgenda.psm1 -Force
09: 
10: Describe 'Get-PodcastData Tests' {
11:
12:   $rssData = Get-PodcastData
13: 
14:   $rowNum = 0
15:   foreach ($podcast in $rssData)
16:   {
17:     $rowNum++
18:     Context "Podcast $rowNum has the correct properties" {
19:       # Load an array with the properties we need to look for
20:       $properties = ('Title', 'ShowUrl', 'EmbeddedHTML', 'Hosts', 
21:                      'PublicationDate', 'ImageUrl', 'AudioUrl', 'AudioLength')
22:       
23:       foreach ($property in $properties)
24:       { 
25:         It "Podcast $rowNum should have a property of $property" {
26:           [bool]($podcast.PSObject.Properties.Name -match $property) |
27:             Should -BeTrue
28:         }
29:       }
30:     
31:     } # Context 'Individual Podcast Properties' 
32:   } # foreach ($podcast in $rssData)
33: 
34:   Context 'Podcast Collection Values' {
35:     It 'should have at least 15 rows' {
36:       $rssData.Count | Should -BeGreaterOrEqual 15
37:     }
38: 
39:   } # Context 'Podcast Collection Values'
40: 
41:   $rowNum = 0
42:   foreach ($podcast in $rssData)
43:   {
44:     $rowNum++
45:     Context "Podcast Values for row $rowNum Episode $($podcast.Title)" {
46:       
47:       It 'ImageUrl should end with .jpg or .png' {
48:         $($podcast.ImageUrl.EndsWith('.jpg')) -or `
49:           $($podcast.ImageUrl.EndsWith('.png')) |
50:           Should -BeTrue
51:       }
52:     
53:       It 'AudioUrl should end with .mp3' {
54:         $podcast.AudioUrl.EndsWith('.mp3') | Should -BeTrue
55:       }
56:     
57:       It 'ShowUrl should contain noagendanotes' {
58:         $podcast.ShowUrl.Contains('noagendanotes') -or $podcast.ShowUrl.Contains('nashownotes') |
59:           Should -BeTrue
60:       }
61:     
62:       It 'Hosts should contain Adam Curry' {
63:         $podcast.Hosts.Contains('Adam Curry') | Should -BeTrue
64:       }
65:     
66:       It 'Hosts should contain John C. Dvorak' {
67:         $podcast.Hosts.Contains('John C. Dvorak') | Should -BeTrue
68:       }
69:     } # Context 'Podcast Values'
70:   } # foreach ($podcast in $rssData)
71: 
72: } #Describe 'Get-PodcastData'

In line 2, the current location is stored in the $here variable, as was done in the structure test.

Lines 5 and 8 are extremely important. Often times during development you make changes, run a test, then make modifications to the code in response to the test results. In that situation, you want to make sure the module in memory is the most current update. Line 5 will remove the module from memory, if it is in memory. Line 8 then loads the module, but it does so from the current folder. By being specific, you avoid the module being loaded from one of the Windows default locations, such as the users Documents folder.

The describe block begins in line 10, then, in line 12, the call to the function you are testing is made. Next, you will look at the contents of the return data to ensure it has the data you need.

Line 14 declares a variable $rowNum. This is strictly a matter of convenience. Each time through the foreach loop it’s incremented, then it’s displayed with the results. This way, if a particular row fails a test, it will be easy to find that row in the data.

The first Context block begins at line 18. This context block validates that each object in the array has the correct properties. As in a previous demo, by placing it inside the loop, it will generate a context block for each row you examine.

Line 20 loads an array up with the list of properties the podcast objects should have. Line 23 starts a foreach loop that will cycle over each property name in the $properties array. Each PowerShell based object has a collection called, appropriately enough Properties. What you want to do is see if the property name from the collection can be found in the properties of the podcast object. If so, the test passes.

The next test, starting with its context block on line 34, is pretty simple. You know the No Agenda RSS feed has at least 15 podcasts in it. So, you check the Count property of the array of podcasts (held in $rssData) to make sure it has at least 15 items in it.

The final loop, starting on line 42, will generate a Context block for each podcast in the RSS data variable. In this block you will validate the data held inside is valid. For example, you know the images will always be either a JPG or PNG image. Line 47 begins a test to validate the file extensions of the image. The remainder of the tests follow a similar pattern, validating things like the media format and names of the hosts.

Get-PodcastImage – The Function

The second function is a bit more challenging in terms of testing, although not horribly complex in design.

01: function Get-PodcastImage()
02: {    
03:   [CmdletBinding()]
04:   param
05:   (
06:     [parameter (Mandatory = $true
07:                , ValueFromPipeline = $true
08:                , ValueFromPipelineByPropertyName = $true
09:                ) 
10:     ]
11:     $rssData
12:     ,   
13:     [parameter (Mandatory = $false) ]
14:     [string] $OutputPathFolder = 'C:\PowerShell\Pester-Module\Podcast-Data\'
15:   )
16: 
17:   begin 
18:   {
19:     Write-Verbose 'Get-PodcastImage: Starting'
20:   }
21:   
22:   process 
23:   {
24:     foreach($podcast in $rssData)
25:     {
26:       $imgFileName = $podcast.ImageUrl.Split('/')[-1]
27:       $outFileName = "$($OutputPathFolder)$($imgFileName)"
28:     
29:       # If the file exists, skip it, otherwise download it    
30:       if ( Test-Path $outFileName )
31:       {
32:         Write-Verbose "`r`nGet-PodcastImage: Skipping $imgFileName, it already exists as $outFileName`r`n"
33:       }
34:       else
35:       {      
36:         Write-Verbose "`r`nGet-PodcastImage: Downloading $imgFileName from $($podcast.ImageUrl) `r`n"
37:         Invoke-WebRequest $podcast.ImageUrl -OutFile $outFileName
38:         Write-Output $imgfileName 
39:       }
40:     
41:     } # foreach($podcast in $rssData)
42:   } # process
43: 
44:   end
45:   {
46:     Write-Verbose 'Get-PodcastImage: Ending'
47:   }
48: }

The first few lines declare the function and add the keywords to make it an advanced function. The function has two parameters, the first is the collection of podcast objects from the RSS feed (retrieved with Get-PodcastData). The second parameter is the location to store the images you download. If you opt to locate your samples in a place other than C:\PowerShell\Pester-Module, you’ll need to update the default output paths throughout the demo code.

Since the function is pipeline enabled, there is a begin block found on line 17. In it, you are just calling Write-Verbose to let the user know we’ve started the function.

Line 22 begins the process block. You immediately fall into a foreach loop, iterating over each podcast in the RSS data feed. The first thing you do is break off the image file name from the rest of the URL. In line 27 you take the image file name and append it to the output folder, giving us a destination file name.

Line 30 has an if statement which calls Test-Path to see if the target file is already there. If so, it skips the download (no sense downloading something that is already present). Otherwise, you use Invoke-WebRequest to download the file in line 37. Line 38 simply passes the name of the file back, so the calling code will have a list of the files that were downloaded.

The end block, line 44, just sends a Write-Verbose to let the caller know it’s finished then the function ends.

Get-PodcastImage – The Unit Tests

Unlike the previous function, you need to create separate tests for Unit and Acceptance. These are covered individually, but before you look at the tests, you should understand there are some unique challenges to tackle to create an effective Unit test. First, recall that Unit tests should be done in isolation as much as possible. This means you should not call functions that you aren’t testing. Of course, this means the Get-PodcastData function, but it also extends to the built in Test-Path cmdlet.

The second issue is much subtler. In the test, you will be loading the module, then calling the Get-PodcastImage function. If you looked at the modules PSM1, file you may have a hint, but for your convenience here is the important part.

01: . $PSScriptRoot\function-Get-PodcastData.ps1
02: . $PSScriptRoot\function-Get-PodcastMedia.ps1
03: . $PSScriptRoot\function-Get-PodcastImage.ps1
04: . $PSScriptRoot\function-Format-PodcastHtml.ps1
05: . $PSScriptRoot\function-Format-PodcastXml.ps1
06: . $PSScriptRoot\function-ConvertTo-PodcastHtml.ps1
07: . $PSScriptRoot\function-ConvertTo-PodcastXml.ps1
08: . $PSScriptRoot\function-Write-PodcastHtml.ps1
09: . $PSScriptRoot\function-Write-PodcastXML.ps1
10: . $PSScriptRoot\function-Get-NoAgenda.ps1
11: 
12: Export-ModuleMember Get-NoAgenda
13: Export-ModuleMember Get-PodcastData

Lines 1 to 10 simply execute the code in each function to load it in memory. Lines 12 and 13 uses Export-ModuleMember on two of the functions. If you are not familiar with it, Export-ModuleMember will make the function name passed in visible outside the module, so it can be used. All other functions are private, and only callable from within the module.

But wait, some of you are saying. Get-PodcastImage is not exported! Which means you cannot call it from outside the module, so how do you test it?

That’s where the Pester function InModuleScope comes into play. Take a look at the test for Get-PodcastImage.

001: $here = Split-Path -Parent $MyInvocation.MyCommand.Path
002: 
003: Get-Module Podcast-NoAgenda | Remove-Module -Force
004: Import-Module $here\Podcast-NoAgenda.psm1 -Force
005: 
006: InModuleScope Podcast-NoAgenda { 
007: 
008:   Describe 'Get-PodcastImage Unit Tests Parameter' -Tags 'Unit' {
009: 
010:   
011:     $mockRssData = @'
012: <Objs Version="1.1.0.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">
013:   <Obj RefId="0">
014:     <TN RefId="0">
015:       <T>PodcastSight.Podcast</T>
016:       <T>System.Management.Automation.PSCustomObject</T>
017:       <T>System.Object</T>
018:     </TN>
019:     <MS>
020:       <S N="Title">819: non-binary person</S>
021:       <S N="ShowUrl">http://819.noagendanotes.com/</S>
022:       <S N="EmbeddedHTML">EMBEDDED HTML GOES HERE</S>
023:       <S N="Hosts">Adam Curry and John C. Dvorak</S>
024:       <S N="PublicationDate">Sun, 24 Apr 2016 20:08:15 GMT</S>
025:       <S N="ImageUrl">http://adam.curry.com/enc/20160424200416_na-819-art-feed.jpg</S>
026:       <S N="AudioUrl">http://mp3s.nashownotes.com/NA-819-2016-04-24-Final.mp3</S>
027:       <S N="AudioLength">127171457</S>
028:     </MS>
029:   </Obj>
**** LINES 30 to 211 removed for brevity, please see the downloadable samples for the full text
212: </Objs>
213: '@
214: 
215:   
216:     $rssData = [System.Management.Automation.PSSerializer]::DeserializeAsList($mockRssData)
217: 
218:     <#--------------------------------------------------------------------------------------------------- 
219:        For the first set of tests, we will call the function using an empty folder. The first will test 
220:        calling using the parameter, the second using a parameter. 
221:       
222:        Since these are virtually identical, we'll use a simple loop and call the tests, just altering 
223:        the call and the folder name
224:     ---------------------------------------------------------------------------------------------------#>
225: 
226:     $loops = 'parameter', 'pipeline'
227:     foreach ($loop in $loops)
228:     { 
229:       Context "Unit Test Get-PodcastImage for each file using the $loop" {
230:         # Because the TestDrive won't get cleared out between context calls we'll
231:         # just create a subfolder for each test and put the files there
232:         $testDriveFolder = "$($TestDrive)\$($loop)\"
233:         New-Item $testDriveFolder -ItemType directory
234:         
235:         # The function calls Test-Path, we should Mock it
236:         # Since this is an empty folder, Test-Path should always return false
237:         Mock Test-Path { return $false }
238:         
239:         if ($loop -eq 'parameter')                             # Execute the function using a parameter
240:           { $downloadedImages = Get-PodcastImage -rssData $rssData -OutputPathFolder $testDriveFolder }
241:         else                                                   # Execute the function using the pipeline
242:           { $downloadedImages = $rssData | Get-PodcastImage -OutputPathFolder $testDriveFolder }
243:   
244:         foreach($podcast in $rssData)
245:         {
246:           $imgFileName = $podcast.ImageURL.Split('/')[-1]
247:           $outFileName = "$($testDriveFolder)$($imgFileName)"
248:           It "Image $imgFileName should exist" {      
249:             $outFileName | Should Exist
250:           }
251:   
252:           It "Image $imgFileName should exist in download list" {
253:             [bool]($imgFileName -in $downloadedImages) | Should -BeTrue
254:           }
255:         } # foreach($podcast in $rssData)
256:   
257:       } # Context "Unit Test Get-PodcastImage for each file using the $loop"
258:     } # foreach ($loop in $loops)
259: 
260: 
261:     <#--------------------------------------------------------------------------------------------------- 
262:        In the second set of tests, we will fake an existing file, so that it will trigger the do not 
263:        download flag within the function. This will let us know it is correctly skipping over files 
264:        to preserve our bandwidth
265:     ---------------------------------------------------------------------------------------------------#>
266:     $loops = 'parameter', 'pipeline'
267:     foreach ($loop in $loops)
268:     { 
269:       Context "Unit Test Get-PodcastImage $loop test with existing files" {
270:         # Because the TestDrive won't get cleared out between context calls we'll
271:         # just create a subfolder for each test and put the files there
272:         $testDriveFolder = "$($TestDrive)\$($loop)Exist\"
273:         New-Item $testDriveFolder -ItemType directory
274:      
275:         # For this test, we need to ensure it is not downloading files that
276:         # already exist. To do so, we'll begin by taking the first seven files
277:         # from our mock data and adding them to an array 
278:         $existingFiles = @()
279:         for ($x = 0; $x -lt 7; $x += 1) 
280:           { $existingFiles += $($rssData[$x].ImageURL.Split('/')[-1]) }
281:      
282:         <#
283:            Next we need to mock Test-Path, to fake the existance of one or more files. This triggers the
284:            functions do not d/l me logic so we can test it. 
285:            
286:            Note we don't want to actually create files, we just need to have our fake Test-Path tell 
287:            the Get-PodcastImage function they exist so it will not download them. We'll use the 
288:            non-existance of the file in one of our tests.
289:         #>
290:         Mock Test-Path {
291:           # Note the Mock automatically adds the $path variable based on the
292:           # signature of Test-Path, i.e. its -Path parameter
293:           $fileName = $path.Split('\')[-1]
294:           if ($fileName -in $existingFiles)
295:             { $retValue = $true }
296:           else
297:             { $retValue = $false }
298:           return $retValue 
299:         }
300:      
301:         if ($loop -eq 'parameter')                             # Execute the function using a parameter
302:           { $downloadedImages = Get-PodcastImage -rssData $rssData -OutputPathFolder $testDriveFolder }
303:         else                                                   # Execute the function using the pipeline
304:           { $downloadedImages = $rssData | Get-PodcastImage -OutputPathFolder $testDriveFolder }
305:         
306:         # For the first test, ensure the files the function reported as downloaded actually were
307:         foreach($imageFile in $downloadedImages)
308:         {        
309:           $outFileName = "$($testDriveFolder)$($imageFile)"
310:           It "Image $imageFile have been downloaded, and thus should exist" {      
311:             $outFileName | Should -Exist
312:           }     
313:         } # foreach($podcast in $rssData)
314:         
315:         # For the files that are supposed to already exist, we'll do two tests
316:         foreach ($imageFile in $existingFiles)
317:         {
318:           # First, we'll make sure the supposedly existing file was not reported as downloaded
319:           It "$imageFile should not exist in the list of downloaded images" {
320:             [bool]($imageFile -in $downloadedImages) | Should -BeFalse
321:           }
322:      
323:           # Next, validate the file DOESN'T exist. In otherwords, since it wasn't supposed to download, 
324:           # we'll make sure it didn't
325:           $outFileName = "$($testDriveFolder)$($imageFile)"
326:           It "$imageFile should not have been downloaded, and thus should not exist" {
327:             $outFileName | Should -Not -Exist
328:           }
329:      
330:         }
331:      
332:       } # Context "Unit Test Get-PodcastImage $loop test with existing files"
333:     } # foreach ($loop in $loops)
334:     
335:   } # Describe 'Get-PodcastImage Unit Tests'
336: 
337: } # InModuleScope Podcast-NoAgenda

Lines 1 to 4 you’ve seen in the last example; a variable is loaded with the current directory then unload and reload the module fresh. It’s line 6 where the magic occurs. Here, InModuleScope is followed by the name of the module you want to change the scope to.

Hopefully, you are familiar with the concept of scopes. If you want a technical refresher you can read more here. Briefly, think about scope like rooms in a house. If you are in your living room, and have a glass of water you can see it, drink it, or give some to your cat. If you put the glass down and go into your bedroom, for all practical purposes the glass no longer exists. You can’t see it, touch it, or determine if your cat has drunk all of it.

Scope is similar. You can think of your test as the living room and the module as your bedroom. Any variables you declare in the test cannot be seen from the module, likewise anything declared in a module is not visible outside of it. Well almost.

It is possible to declare things in a special way, such as was done with Export-ModuleMember which allows an item such as a function or variable to be visible outside the module. It’s sort of like taking your alarm clock and putting it in the door of your bedroom, then it could be seen from both the living room and the bedroom.

InModuleScope allows moving (to extend the analogy) from the living room, where you are executing the test, into the bedroom and running the test in there for a while. While you get to use the things in the bedroom, such as the Get-PodcastImage function, you lose the ability to see things like the $here variable declared on line 1. If, on line 7, you had Write-Host $here, it would return nothing as that variable is not visible inside the module.

Line 8 has the Describe block. Then starting in line 11, there is an odd variable assignment, $mockRssData. The variable declaration begins in line 11, and goes all the way to line 213! (Note most of it has been clipped out for brevity, the download has the full code). So, what in the world is this for?

One of the tenants of unit testing is isolation. You only want to call the function you are testing. But that function is dependent on the output of the Get-PodcastData function. For this test, rather than calling it, you will instead use a set of known data. By using known data, you have already validated the input data is error free. In addition, you should be able to predict the output based on the input, allowing us better validation of the test results.

The code comments in the download have complete step by step instructions on creating this, but in short, Get-PodcastData is called the results are stored in a variable. The results are then saved to a file using the Export-CliXml cmdlet. Next, the file is opened, and the contents are pasted into the $mockRssData variable.

You may recall how you used the Tokenizer method of the System.Management.Automation.PSParser class to validate the PowerShell code. Under the hood, the Import-CliXml uses another method from System.Management.Automation, specifically the DeserialzeAsList method of the PSSerializer class. This takes a blob of XML that was prepared using Export-CliXml and converts it back into an object.

So why not just use Import-CliXml and import the file? When testing, you want to ensure you limit external influences as much as possible. The external file might get overwritten, or deleted. In that case the set of known data would vanish, and you would have to carefully debug the test. With the data embedded in the test, you guarantee that the known data set will be preserved and reliable.

In line 216 the static, known data is converted back into a variable which has the same contents as if loaded from Get-PodcastData.

When testing you need to test all possible conditions. The Get-PodcastImage function can be called in one of two ways, passing in data via the pipeline, or as a parameter so you need to execute it using both methods in the test. Because the code will be very similar for both tests, you can save a lot of repetitive code by using a loop. In line 226, an array is loaded with the values parameter and pipeline. (Any two values could have been used, these just made it easier to understand the required logic in the loop.)

Once in the for loop a context block is generated, on line 229. Then on line 232 you see the test setting up a $testDriveFolder variable using a variable called $TestDrive followed by the name of the current loop, parameter or pipeline.

I’m not trying to trick you, I haven’t secretly created the $TestDrive variable, nor have I carelessly forgotten it. It’s something that Pester thoughtfully provides for us!

Remember, not only are Unit tests supposed to be done in isolation, they aren’t supposed to have any side effects such as leaving files on the hard drive after a test. But downloading files is exactly what Get-PodcastImage is supposed to do!

You could of course create a temporary folder name, first making sure it doesn’t exist, create it, then delete everything in it after the test is done, but whew that is a lot of work. Instead, the folks who authored Pester thoughtfully provided a way to achieve this with no effort on your part.

When the Pester module’s Describe block is invoked, it creates a temporary directory on your drive called the test drive. It’s irrelevant where, and part of the reason for that is you shouldn’t try to access it directly. Instead, Pester places the location of your test drive in a variable named $TestDrive.

The rules around the test drive are interesting. As mentioned, part of the execution of the Describe function is to setup a $TestDrive folder. When the Describe ends, it deletes the test drive folder along with its contents. When a Context block is executed, things change a little. At the beginning of the Context, it will make a copy of the contents of the test drive folder. Your code can then interact with the contents, adding, changing and removing files. When the Context block ends, everything in the test drive folder is removed, and the contents are then restored from the copy that was made at the start of the context block.

In line 229 when the Context is executed, it makes a copy of the test drive. As nothing has been done with it yet, the test drive is empty. Then line 233 creates new subdirectories, one for each pass in the loop (parameter and pipeline). You need to do this as the $TestDrive contents will exist for the duration of the Context block, but you need to download the images twice, once when called using parameters and once when called using the pipeline. The simple solution then was to just create two folders to hold the downloaded images.

Within the Get-PodcastImage, it uses Test-Path to first see if the image already exists before it tries to download it. Remembering the rule not to call anything else but the function you’re actually testing, you want to replace Test-Path with your own version. In my previous article I covered the use of Mocks in some detail so I won’t repeat here. Since $TestDrive folder was empty at the start of the Context, you know the images won’t be there, so you can just have Test-Path return a false every time.

Lines 239 to 242 finally call the Get-PodcastImage function. It’s called in two ways, the first calling it passing in $rssData as a parameter, the second using $rssData as input to Get-PodcastImage via the pipeline.

Now that the function has executed, you can test the output. It loops over each podcast in the set of known data (held in $rssData) and first sees if the file exists, done in lines 246 to 250.

The Get-PodcastData returns a list of every file it downloaded, so in lines 252-254, each image is validated in the known data was found in the output list. The Context block ends in line 257, and at that point Pester deletes everything that was put in the test drive folder during the execution of the Context block. It then restores what was there at the beginning of the Context block, which in this case is nothing as the test drive was empty.

Thus far the tests are for downloading files when the directory is empty, and testing for both the podcast dataset passed in as a parameter and via the pipeline. The Get-PodcastImage function has an important component: it tests to see if the file already exists and, if so, it skips it. The file won’t be downloaded, nor will it be in the list of files the function returns as being downloaded. As part of the testing, then, you also need to ensure that functionality is working correctly.

That’s what the next test, beginning in line 266 does. Lines 266 to 273 are a repeat from the last test. For this test, you want to designate some files as already existing. They don’t have to actually exist, you just have to make the Get-PodcastImage function think they do, which is accomplished in the mocked version of Test-Path. In preparation, you’ll copy over seven file names from the $rssData variable into another array called $existingFiles, as shown in lines 278 to 280.

Starting in line 290, a mocked version of Test-Path is created. It extracts the file name from the parameter being passed in, then checks to see if it is already in the $existingFiles array. If so it returns true, otherwise false.

With lines 301 to 304, the Get-PodcastImage function is called, passing in $rssData as a parameter or via the pipeline depending on which iteration of the loop it’s in. The list of files downloaded are returned and placed in the $downloadedImages variable. Remember, this list should exclude the file names stored in the $existingImages variable because the mocked Test-Path indicated these already existed, so the function would have skipped downloading them as well as not included them in the output list.

The tests generated beginning in line 306 are ones that would seem obvious. You take each file that the function said it downloaded, then check to see if it exists in the test drive.

Line 316 moves into two tests that may not seem quite so obvious to do; you want to test for a negative condition. The first test, in line 319, checks to see if one of the existing files appears in the list of downloaded files. If so, you know it was reported as downloaded in error.

The second test, beginning in line 325, checks to make sure none of the files in the existing files array are in the test folder. If they were, again the test would fail as they shouldn’t be there.

Whew! That was a lot of work! Over 300 lines of code to test a function that was just shy of 50 lines long. But you’ll often find that the tests wind up being much longer than what is being tested. There is setup, creating sets of known data, mocking up functions, calling the function as both a parameter and via a pipeline (if applicable), checking for various conditions such as downloading with and without files already present. And all this is just for the unit test; you still have to handle the Acceptance test! Time to do that next.

Get-PodcastImage – The Acceptance Tests

You will rest a bit easier knowing that Acceptance tests are a lot easier to construct. As you saw with Unit tests, there is a lot of setup creating known datasets, test drive folders, and mocking up functions that are not being tested. Much of that goes away with the Acceptance tests, as you will be using components directly, without having to mock them or worry about temporary storage. Take a look at the Acceptance tests for Get-PodcastImage.

01: Describe 'Get-PodcastImage Acceptance Tests' -Tags 'Acceptance' {
02: 
03:   InModuleScope Podcast-NoAgenda { 
04:   
05:     $rssData = Get-PodcastData
06: 
07:     $root = 'C:\PowerShell\Pester-Module\'
08:     $tests = @{ 'default parameter' = "$($root)Podcast-Data\"
09:                 'default pipeline'  = "$($root)Podcast-Data\"
10:                 'nondefault parameter' = "$($root)Podcast-Test\"
11:                 'nondefault pipeline'  = "$($root)Podcast-Test\"
12:               }
13: 
14:     foreach($test in $tests.GetEnumerator())
15:     {
16:       $folder = $test.Value
17: 
18:       # Get a list of images already present
19:       $existingImages = Get-ChildItem "$($folder)*" -Include *.jpg, *.png | Select-Object Name
20: 
21:       # Call Get-PodcastImage based on which test we are running for
22:       switch ($test.Name)
23:       {
24:         'default parameter'    
25:            { $downloadedImages = Get-PodcastImage -rssData $rssData -Verbose }
26:         'default pipeline'     
27:            { $downloadedImages = $rssData | Get-PodcastImage -Verbose }
28:         'nondefault parameter' 
29:            { $downloadedImages = Get-PodcastImage -rssData $rssData -OutputPathFolder $folder -Verbose }
30:         'nondefault pipeline'  
31:            { $downloadedImages = $rssData | Get-PodcastImage -OutputPathFolder $folder -Verbose }
32:       } # switch ($test.Name)
33: 
34:       # Use split to reduce the test name to either default or nondefault and parameter or pipeline.
35:       # We'll use them in the context and test names to reduce their length
36:       $dlFolder = $test.Name.Split(' ')[0]
37:       $pipeParam = $test.Name.Split(' ')[1]
38: 
39:       Context "Acceptance Test Get-PodcastImage $pipeParam test to $dlFolder folder" {
40:         # Make sure all the files exist
41:         foreach($podcast in $rssData)
42:         {
43:           $imgFileName = $podcast.ImageURL.Split('/')[-1]
44:           $outFileName = "$($folder)$($imgFileName)"
45:           It "image $imgFileName should exist in the $dlFolder folder" {      
46:             $outFileName | Should -Exist
47:           }
48:         }
49:       
50:         # Make sure downloaded images weren't in the list of existing ones
51:         foreach ($img in $downloadedImages)
52:         {
53:           It "should have downloaded $img" {
54:             [bool]($img -in $existingImages) | Should -BeFalse
55:           }
56:         }
57:       
58:       } # Context 'Acceptance Test Get-PodcastImage 
59: 
60:       # Optional: Remove the images that were just downloaded so we can reset for next test
61:       # foreach ($img in $downloadedImages)
62:       # {
63:       #   Remove-Item "$($folder)$($img)" -ErrorAction SilentlyContinue
64:       # }
65:           
66:     } # foreach($test in $tests.GetEnumerator())
67: 
68:   } # InModuleScope Podcast-NoAgenda
69: 
70: } # Describe 'Get-PodcastImage Acceptance Tests'

Line 1 kicks off with the Describe block, then on line 3, you have the InModuleScope declaration.

Before you go zooming past line 3, an important point needs to be made. If you scroll up to the Unit test, you’ll see that the Describe block was declared inside the InModuleScope. In this test, you’re doing exactly the opposite. In fact, InModuleScope is extremely flexible in that you can declare it at any level you need. You could place it inside Context blocks or place a Context block in it. You could even get microscopic, and only put the call to Get-PodcastImage inside the InModuleScope and leave everything else in the scope of the test.

It’s important to understand this flexibility with InModuleScope when creating your tests. For the tests in the examples the scoping hasn’t had a significant impact, but you may have situations where you need to repeatedly shift scope back and forth between your module and your tests. Just remember InModuleScope is extremely useful, and think about how scope will apply to your tests when authoring them.

Line 5 executes the call to Get-PodcastData and returns the data to the $rssData variable. The unit tests worked with a set of known values. For these tests, you are working with the current, live values. This is both a risk and a benefit. If the folks at No Agenda changed something in their RSS feed you didn’t anticipate, it could cause the test to fail. You would need to evaluate the reason for the failure and update the tests (and possibly the module’s code) accordingly.

For the exact same reason though, it’s a benefit. No Agenda releases new episodes every Thursday and Sunday. Before they attempt to download the episode each week, the employees at the fictitious company PodcastSight could run the test suite. If they all pass, you know you are clear to download the latest episode. If they fail, you can investigate and correct any issues before you attempt to download the episodes into the production environment.

As you are downloading things for real, and not to a test drive environment, you need to set up a series of folders to hold the downloads. The root folder is assigned in line 7. Then in lines 8 to 12 a hash table is created. The left side of the hash is the test you are doing; the right is the download path. Note, to keep this code simple the test assumes these folders already exist! If you have downloaded the samples, be sure to check to see if the files are in place. When coding your tests, you may wish to include a few extra lines to see if the folders exist and if not create them.

Additionally, there was no technical reason you couldn’t have used the $TestDrive folder. With Acceptance tests though, you typically want to leave the results of your tests, in this case the downloaded podcast images, available for the testers to see and review after the tests are executed. It is for this reason then you chose to save the images to a folder where they could be persisted after the tests have completed running.

Beginning in line 14, a foreach loop enumerates over the hash table. You can easily iterate over an array by just using the name of the array after the in clause of the foreach. Hash tables though work a bit differently. With a hash table you have to use its GetEnumerator() method to “pop” the next item off the hash table and copy it into the placeholder variable (in this case $test).

Once inside the foreach, you can access the key/value pair of the hash table through the placeholder. The Value property refers to the data in the right side of the hash table. The Name property corresponds to the key, the data on the left side of the hash table assignments.

Because $test.Value isn’t overly clear, in line 16, it’s copied into a variable named $folder. Not only is it more concise, it clearly conveys what the contents of the variable are.

For these tests, you need to call the Get-PodcastImage with four different combinations, first calling it passing in $rssData via the pipeline, then as a parameter. For each one of these, you want to test using the default download folder as well as passing in a value for the download folder. That’s what switch statement that begins in line 22 does. As the code loops over the hash table, it determines which test is the current one from the hash tables key ($test.Name).

One of the design principals of Pester is readability. As such you really want the test output to be readable as well. Line 36 uses the split method of a string to break the hash table’s key into two parts. The first part (default or nondefault) will be copied into the $dlFolder variable. In line 37, it’s repeated, only this time it copies over the pipeline or parameter word into the $pipeParam variable.

The Context block beginning on line 39 is used to make a clearly readable name for the testers to see. They are reused throughout the Context block to improve readability in the test output (line 45), as well as in constructing path names (line 44).

Speaking of the Context block, the first set of tests in it begins on line 41. Each image in the $rssData should exist in the target folder. It doesn’t really matter if the image was downloaded during the test or already existed, either way the image should exist which is the important part.

Next though, you will test to ensure that the list of downloaded images wasn’t in the list of images that already existed. In other words, did it correctly skip the images that already existed and only download the ones it needed? That’s the job of the test in lines 51 to 56.

This wraps up the testing. There is an optional block beginning at line 60. It’s commented out, but you may have some circumstances where you want to delete what was just downloaded so you can rerun the test. In that case this code could be uncommented to remove any images that were just downloaded. Note this only deletes the images that were just downloaded during this test, anything other files already in the folder are left alone.

Execute-Tests.ps1

Before you wrap things up, your attention should be called to a file in the downloads called Execute-Tests.ps1. This script has been written with individual statements to execute each Pester test in the collection. Using it you can execute the tests individually as you learn. There is also a spot where you can execute all of the tests at once.

Summary

Thanks for sticking with it through this rather long article. If you’ve looked over the demo code, you’ll see far more than what is covered here. There are many examples that may aid you in the future, so be sure to look over the code base. It is well documented throughout.

As you can see, Pester is an incredibly powerful tool. Used well, it can aid you in producing high quality, bug free code. It doesn’t have to stop with code either, you can use Pester to verify a wide variety of things. You could write Pester tests to validate your SQL Server databases are current. Is you server configured correctly? Write a Pester test that you can run regularly to validate it. The possibilities are endless!

The post Testing PowerShell Modules with Pester appeared first on Simple Talk.



from Simple Talk https://ift.tt/2ylPqLJ
via

No comments:

Post a Comment