Testing PowerShell with Pester for real

PowerShell Script Wallpaper

Following my previous post about testing PowerShell with Pester, in this post I show you my way of testing PowerShell with Pester in the real world.

Scenario

Consider the following scenario. You want to smoke-test your application using a bunch of files designed for that. Also, you have different environments, such as the development DEV or UAT environments. Every environment has a name for the environment, like dev for the development environment, and the number of the environment (for example dev1, uat2…). For each environment, I have a folder with test files to remove.

In the following script, I search in the environment folders and subfolders and look at the name of the file that contains the words tmp and test to delete. The CleanupFilesFiter.ps1 is checking if the parameters are valid and verifying if the environment has the expected format; read the folders and subfolders to detect what files it has to delete.

Because the script has to delete for real the files in a production environment, I want to be sure it works as expected. For this reason, I want to test it and the easy way is to use Pestel to mock the files or create the structure I need to test the script.

CleanupFilesFilter.ps1

So, I break down the script to better understand what I’m doing here. First, the parameters require to run the script.

The parameters

Now, the parameters the script is expected are:

  • the targetPath when it has to search for the files to delete
  • the targetEnv (target environment) to determine what environment folder it has to check
  • the file creation date RemoveAfterDate is used to filter the files to remove after this date
param(
  [Parameter(Mandatory=$true)] [string]$targetPath,
  [Parameter(Mandatory=$true)] [string]$targetEnv,
  [Parameter(Mandatory=$true)] 
  [ValidateScript({[DateTime]::ParseExact($_, "yyyy-MM-dd HH:mm", $null)})]
  [string]$RemoveAfterDate
)

The folder list

In the environment folder, I can have lot of folders. So, I want to limit the search in specific folders listed in this variable.

[string[]]$folderList = "Folder1","Folder2","Folder3","Folder4","Folder5"

Validate the environment

As I said before, the files are in an environment folder. This folder has the conventional short name plus the number of the environment. For example, a valid environment is dev1 or UAT3. To check if the environment string is valid, I use the Regex for it.

function ValidateEnv([string]$theEnv)
{
    if ($theEnv -eq "")
    {
        Write-Error "Environment is an empty string"
        return $false
    }

    $matchText = "^(dev\d?$|prd\d?$)"
    if ($theEnv -notmatch $matchText)
    {
        Write-Error "$($theEnv) is not a valid environment, please use dev optionally followed by a digit"
        return $false
    }

    return $true
}

Check files to delete

Now, here is where the magic happens. This function checks in a specific folder if there is any file that matches the name or the date. Notice that I define and return a fileList variable that is a [System.Collections.Generic.List[System.IO.FileInfo]]. This will be useful when I test the function. If there are some files, the script adds the list of files to the fileList.

The list of files from Get-ChildItem is converted into an array of System.IO.FileInfo.

function CheckFilesToDelete([string]$EnvPath, [string[]]$fList, [datetime]$date) {
    $fileList = [System.Collections.Generic.List[System.IO.FileInfo]]::new()

    foreach ($folderName in $fList) {
        $path = "$EnvPath\$folderName"

        $list = @()
        if($date -eq $null) {
            $list = [System.IO.FileInfo[]]@(Get-ChildItem -Path $path -File -Recurse | 
                    Where-Object { $_.Name -match '.*Tmp ?Test.*' })
        }
        else {
            $list = [System.IO.FileInfo[]]@(Get-ChildItem "$path" -File -Recurse | 
                Where-Object { $_.CreationTime -gt $date })
        }

        if ($list.Count -gt 0) { $fileList.AddRange($list) }
    }

    return $fileList
}

File deletion

Finally, the last function of the script is for deleting the files using the function CheckFilesToDelete described above. This function checks for every folder in the environment folder the files to delete and add them in the FilesToDelete variable. In the following script, the delete command in commented and you will see the list of files to delete. If you want to delete for real, just uncomment that line.

function GetData() {
    foreach($folderName in $folderList)
    {
	    $path = "$directoryEnvPath\$folderName"

        $checkPath = Test-Path $path
	    if ($checkPath -eq $false)
	    {
		    continue;
	    }

        if($filterByDate -eq $true)
        {
	        ($NewerFilesToDelete += @(CheckFilesToDelete -EnvPath $directoryEnvPath -fList $folderName)) > $null
        }
        else {
            ($FilesToDelete += @(CheckFilesToDelete -EnvPath $directoryEnvPath -fList $folderName)) > $null
        }
    }

    if($filterByDate -eq $true)
    {
	    ($NewerFilesToDelete += @(CheckFilesToDelete -EnvPath $directoryEnvPath -fList $folderName -date $RemoveAfterDate)) > $null
    }

    if($FilesToDelete.Count)
    {
	    $FilesToDelete
        #$FilesToDelete | Remove-Item -Verbose -Recurse
    }
    else
    {
        Write-Host "`n No smoke test files found to delete in $($directoryEnvPath) `n" -ForegroundColor Yellow
    }
	
    if($NewerFilesToDelete.Count)
    {
	    $NewerFilesToDelete
    	#$NewerFilesToDelete | Remove-Item -Verbose -Recurse
    }
    else
    {
        Write-Host "`n No newer files then ${RemoveAfterDate} found to delete in ${directoryEnvPath} `n" -ForegroundColor Yellow
    }
}

The full script

Now, for your convenient, I post here the full script called CleanupFilesFilter.ps1.

param(
  [Parameter(Mandatory=$true)] [string]$targetPath,
  [Parameter(Mandatory=$true)] [string]$targetEnv,
  [Parameter(Mandatory=$true)] 
  [ValidateScript({[DateTime]::ParseExact($_, "yyyy-MM-dd HH:mm", $null)})]
  [string]$RemoveAfterDate
)

$ErrorActionPreference = "Stop"
$filterByDate = $false

[string[]]$folderList = "Folder1","Folder2","Folder3","Folder4","Folder5"

function ValidateEnv([string]$theEnv)
{
    if ($theEnv -eq "")
    {
        Write-Error "Environment is an empty string"
        return $false
    }

    $matchText = "^(dev\d?$|prd\d?$)"
    if ($theEnv -notmatch $matchText)
    {
        Write-Error "$($theEnv) is not a valid environment, please use dev optionally followed by a digit"
        return $false
    }

    return $true
}

function CheckFilesToDelete([string]$EnvPath, [string[]]$fList, [datetime]$date) {
    $fileList = [System.Collections.Generic.List[System.IO.FileInfo]]::new()

    foreach ($folderName in $fList) {
        $path = "$EnvPath\$folderName"

        $list = @()
        if($date -eq $null) {
            $list = [System.IO.FileInfo[]]@(Get-ChildItem -Path $path -File -Recurse | 
                    Where-Object { $_.Name -match '.*Tmp ?Test.*' })
        }
        else {
            $list = [System.IO.FileInfo[]]@(Get-ChildItem "$path" -File -Recurse | 
                Where-Object { $_.CreationTime -gt $date })
        }

        if ($list.Count -gt 0) { $fileList.AddRange($list) }
    }

    return $fileList
}

function GetData() {
    foreach($folderName in $folderList)
    {
	    $path = "$directoryEnvPath\$folderName"

        $checkPath = Test-Path $path
	    if ($checkPath -eq $false)
	    {
		    continue;
	    }

        if($filterByDate -eq $true)
        {
	        ($NewerFilesToDelete += @(CheckFilesToDelete -EnvPath $directoryEnvPath -fList $folderName)) > $null
        }
        else {
            ($FilesToDelete += @(CheckFilesToDelete -EnvPath $directoryEnvPath -fList $folderName)) > $null
        }
    }

    if($filterByDate -eq $true)
    {
	    ($NewerFilesToDelete += @(CheckFilesToDelete -EnvPath $directoryEnvPath -fList $folderName -date $RemoveAfterDate)) > $null
    }

    if($FilesToDelete.Count)
    {
	    $FilesToDelete
        #$FilesToDelete | Remove-Item -Verbose -Recurse
    }
    else
    {
        Write-Host "`n No smoke test files found to delete in $($directoryEnvPath) `n" -ForegroundColor Yellow
    }
	
    if($NewerFilesToDelete.Count)
    {
	    $NewerFilesToDelete
    	#$NewerFilesToDelete | Remove-Item -Verbose -Recurse
    }
    else
    {
        Write-Host "`n No newer files then ${RemoveAfterDate} found to delete in ${directoryEnvPath} `n" -ForegroundColor Yellow
    }
}

#
# Start
Write-Host "This script will delete files and folders created by testing"

ValidateEnv $targetEnv > $null

$targetPathEnv = "$targetPath\$targetEnv"
$checkPath = Test-Path $targetPathEnv
if ($checkPath -eq $false)
{
	Write-Error "$($targetPathEnv) doesn't exist"
}

if($RemoveAfterDate -ne "")
{
    $filterByDate = $true
}

$directoryEnvPath = "$targetPathEnv\TestFolder" 
[System.IO.DirectoryInfo[]]$FilesToDelete 

GetData

CleanupFilesFilter.Tests.ps1

Now, I explained in the previous post, it is common for a PowerShell script test to have a file with the same name of the file to test plus .Test before the extension.

BeforeAll

So, this section is called from Pester before anything else. Therefore, here is the right place to add the code to create folders and files for testing the script.

BeforeAll {
    $path = Join-Path -Path $targetPath -ChildPath $targetEnv
    Write-Host "This is the path $path"
    Write-Host ""
    Write-Host "Creating folders and files..."

    $path = Join-Path -Path $path -ChildPath $mainFolder
    $folderList |
    ForEach-Object {
        $tmpPath = Join-Path $path $_
        New-Item $tmpPath -ItemType Directory -force

        ("TmpTest","Tmp Test","tmp-test","Tmp 1Test","test") | foreach { New-Item -Path $tmpPath -Name "$_.txt" -Force }
    }
    Write-Host "Creation completed"

    . $PSCommandPath.Replace('.Tests.ps1', '.ps1') -targetPath $targetPath -targetEnv $targetEnv -RemoveAfterDate $RemoveAfterDate
}

As you can see, with New-Item I force the creation of the directory and then with the same command force the creation of same files in the folder. When the creation process is completed, I run the script I want to test.

Validate the environment

First, I want to test the function ValidateEnv to check if it returns what I expect with different input. Remember, the environment has to have the environment code plus a number.

Describe "Validate Environment" {
    Context "when environment is empty" {
        It "should return 'Environment is an empty string'" {
            $scriptBlock = { ValidateEnv -theEnv '' -ErrorAction Stop }
            $scriptBlock | Should -Throw "Environment is an empty string"
        }
    }

    Context "when environment has more than 1 digit" {
        It "should return 'Environment is an empty string'" {
            $tmpEnv = 'dev01'
            $scriptBlock = { ValidateEnv -theEnv $tmpEnv -ErrorAction Stop }
            $scriptBlock | Should -Throw "$($tmpEnv) is not a valid environment, please use dev optionally followed by a digit"
        }
    }

In case the ValidateEnv finds the name of the environment is wrong, it raise an error with Write-Error... return $false. This causes an issue also in the test script. For this reason, I define the variable scriptBlock and trap the error and compare the returned string.

Check files

Then, this is the most complicated part of the all scripts. Because I want to verify if the function picks up the right files, I have to create an object in PowerShell and Pester for a file with all the details.

Mock Get-ChildItem

Now, let me start with the simple one. Because I want to test different thing, I need different files. Pester provides a Mock for a lot of commands. In particular, I have to mock the result of Get-ChildItem that contains in this case file details.

Mock Get-ChildItem {
    $arr = @(
        [System.IO.FileInfo]::new ('Tmp 1Test.txt'),
        [System.IO.FileInfo]::new ('tmp-test.txt'),
        [System.IO.FileInfo]::new ('TmpTest.txt'),
        [System.IO.FileInfo]::new ('qq02000.doc'))

In this code, the Mock for Get-ChildItem has an array arr with 4 files only with their names. So, I apply the filter on this Mock with

$arr | Where-Object { $_.Name -match '.*Tmp ?Test.*' }

that extracts from the list only the files with Tmp Test in the file name. Then, I define a variable to list the files I expected to have from the function I want to test.

$expected = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
$expected.Add([System.IO.FileInfo]::new('TmpTest.txt'))

Now, I’m calling the function CheckFilesToDelete and it returns a list of files. Then, I sort the list of files and take only the name of the files not all the attributes and check the list with the expected list.

$list = CheckFilesToDelete -EnvPath $path -fList $folderList -ErrorAction Stop
$list.Name | Sort-Object | Should -Be ($expected.Name | Sort-Object Name)

If the list has the same files of expected, the test is passed. And it passed.

Complex Mock Get-ChildItem

So far so good. My initial idea was to test the creation date of files to decide if delete them or not. In PowerShell I couldn’t find a way to create a file and change the creation date and time. For this reason, I have to create same MockObject to add in the Mock Get-ChildItem.

Mock Get-ChildItem {
    $arr = @(
        (New-MockObject -Type 'System.IO.FileInfo' -Properties @{ Name = 'Tmp Test.txt'; CreationTime = [datetime]'2023-01-01 21:00:00' }),
        (New-MockObject - Type 'System.IO.FileInfo' - Properties @{ Name = 'tmp-test.txt'; CreationTime = [datetime]'2023-01-01 22:00:00' }),
        (New-MockObject - Type 'System.IO.FileInfo' - Properties @{ Name = 'TmpTest.txt'; CreationTime = [datetime]'2022-01-01 22:00:00' }),
        (New-MockObject - Type 'System.IO.FileInfo' - Properties @{ Name = 'qq02000.doc'; CreationTime = [datetime]'2020-01-01 22:15:00' })
    )

So, using here I use the Pester command New-MockObject to create a list of files and add the CreationTime I want. So, I can test also the part of the function when I want to test the date of the files.

The full test script

# Define variables
$targetPath = $PSScriptRoot
$targetEnv = 'dev1'
$mainFolder = "TestFolder"
$RemoveAfterDate = "2022-01-01 00:00"
[string[]]$folderList = "Folder1","Folder2","Folder3","Folder4","Folder5"

# Setup the tests environment
BeforeAll {
    $path = Join-Path -Path $targetPath -ChildPath $targetEnv
    Write-Host "This is the path $path"
    Write-Host ""
    Write-Host "Creating folders and files..."

    $path = Join-Path -Path $path -ChildPath $mainFolder
    $folderList |
    ForEach-Object {
        $tmpPath = Join-Path $path $_
        New-Item $tmpPath -ItemType Directory -force

        ("TmpTest","Tmp Test","tmp-test","Tmp 1Test","test") | foreach { New-Item -Path $tmpPath -Name "$_.txt" -Force }
    }
    Write-Host "Creation completed"

    . $PSCommandPath.Replace('.Tests.ps1', '.ps1') -targetPath $targetPath -targetEnv $targetEnv -RemoveAfterDate $RemoveAfterDate
}

# Validate environment function
Describe "Validate Environment" {
    Context "when environment is empty" {
        It "should return 'Environment is an empty string'" {
            $scriptBlock = { ValidateEnv -theEnv '' -ErrorAction Stop }
            $scriptBlock | Should -Throw "Environment is an empty string"
        }
    }

    Context "when environment has more than 1 digit" {
        It "should return 'Environment is an empty string'" {
            $tmpEnv = 'dev01'
            $scriptBlock = { ValidateEnv -theEnv $tmpEnv -ErrorAction Stop }
            $scriptBlock | Should -Throw "$($tmpEnv) is not a valid environment, please use dev optionally followed by a digit"
        }
    }

    Context "when environment is valid" {
        It "should return true" {
            ValidateEnv -theEnv $targetEnv | Should -Be $true
        }
    }
}

Describe "Validate files to delete" {
    Context "validate files with name" {
        It "should return a list of expected files (mock)" {
            [string[]]$folderList = "Tests"

            $expected = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
            $expected.Add([System.IO.FileInfo]::new('TmpTest.txt'))

            Mock Get-ChildItem {
                $arr = @(
                    [System.IO.FileInfo]::new('Tmp 1Test.txt'),
                    [System.IO.FileInfo]::new('tmp-test.txt'),
                    [System.IO.FileInfo]::new('TmpTest.txt'),
                    [System.IO.FileInfo]::new('qq02000.doc'))

                # Mocking the -Filter parameter
                $arr | Where-Object { $_.Name -match '.*Tmp ?Test.*' }
            }

            $list = CheckFilesToDelete -EnvPath $path -fList $folderList -ErrorAction Stop
            $list.Name | Sort-Object | Should -Be ($expected.Name | Sort-Object Name)
        }

        It "should return a list of expected files (file system)" {
            [System.Collections.Generic.List[System.Object]]$expected = @(
                [PSCustomObject]@{ Name = 'Tmp Test.txt' },
	            [PSCustomObject]@{ Name = 'tmp-test.txt' }
            )

            $list = CheckFilesToDelete -EnvPath $path -fList $folderList -ErrorAction Stop
            $list.Count | Should -Be 10
            $list[0].Name | Should -Be 'Tmp Test.txt'
        }
    }

    Context "validate files with date" {
        It "should return a list of expected files (mock)" {
            [string[]]$folderList = "Tests"

            $expected = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
            $expected.Add([System.IO.FileInfo]::new('TmpTest.txt'))
            $expected.Add([System.IO.FileInfo]::new('Tmp Test.txt'))

	        Mock Get-ChildItem {
                $arr = @(
                    (New-MockObject -Type 'System.IO.FileInfo' -Properties @{ Name = 'Tmp Test.txt'; CreationTime = [datetime]'2023-01-01 21:00:00' }),
                    (New-MockObject -Type 'System.IO.FileInfo' -Properties @{ Name = 'tmp-test.txt'; CreationTime = [datetime]'2023-01-01 22:00:00' }),
                    (New-MockObject -Type 'System.IO.FileInfo' -Properties @{ Name = 'TmpTest.txt'; CreationTime = [datetime]'2022-01-01 22:00:00' }),
                    (New-MockObject -Type 'System.IO.FileInfo' -Properties @{ Name = 'qq02000.doc'; CreationTime = [datetime]'2020-01-01 22:15:00' })
                )

                # Mocking the -Filter parameter if used
                if ($PesterBoundParameters.Filter) {
                    return $arr | Where-Object Name -Like $PesterBoundParameters.Filter
                }

                return $arr
	        }

            $dt = [DateTime]::ParseExact('2023-01-01 00:00', 'yyyy-MM-dd HH:mm', $null)

            $list = CheckFilesToDelete -EnvPath $path -fList $folderList -date $dt -ErrorAction Stop
            $list.Count | Should -Be 2
            $list[0].Name | Should -Be 'Tmp Test.txt'
        }
    }
}

The result in PowerShell

Finally, here the screenshot of Windows PowerShell SE and the output of the tests.

Testing PowerShell scripts with Pester
Testing PowerShell scripts with Pester

Wrap up

In conclusion, this is a real example of testing PowerShell with Pester for a real world. I’m using this script in a production environment and the test with Pester is very useful. Please let me know what you think about.

Happy coding!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.