Pester Testing .NET with Copilot Part 3
I've been sharing my experiences writing Pester tests for a PowerShell function that uses the .NET Framework instead of invoking native cmdlets. The process has also involved using GitHub Copilot in VS Code. As you've seen this has been hit and miss. While there were times when Copilot accelerated my scripting, there were times, I needed to step in.
When we left off, I had refactored the function to use private functions that wrapped around the .NET method invocations. You can only Pester test or mock a PowerShell command like a function or cmdlet.
This is where the function stands now after the refactoring.
Function Get-OSDetail {
<#
.Synopsis
Get operating system details via CIM
.Description
Use this command to query one or more remote computers using CIM to get operating
system details.
.Example
PS C:\> Get-OSDetail
Name : Microsoft Windows 11 Pro
Version : 10.0.22635
Build : 22635
OSArchitecture : 64-bit
RegisteredUser : Jeff
RegisteredOrganization :
InstallDate : 5/17/2022 2:54:52 PM
ComputerName : THINKX1-JH
.Link
Get-CimInstance
#>
[CmdletBinding()]
[OutputType('OSDetail')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline
)]
[Alias('CN', 'Server')]
[ValidateNotNullOrEmpty()]
[Microsoft.Management.Infrastructure.CimSession[]]$CimSession = $Env:ComputerName
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
#define the WQL query
$Query = 'Select CSName,Caption,Version,BuildNumber,InstallDate,OSArchitecture,RegisteredUser,Organization from Win32_OperatingSystem'
#initialize reference variables
New-Variable -Name ci
New-Variable -Name ce
#define private helper functions that I can mock in my pester tests
function TestCimConnection {
param(
[Microsoft.Management.Infrastructure.CimSession]$CimSession
)
$cs.TestConnection([ref]$ci, [ref]$ce)
} #TestCimConnection
function InvokeQuery {
param(
[Microsoft.Management.Infrastructure.CimSession]$CimSession,
[string]$Query
)
$CimSession.QueryInstances('Root/Cimv2', 'WQL', $Query)
}
} #begin
Process {
foreach ($cs in $CimSession) {
#capture connection failures to a variable
Write-Verbose "Testing connection to $($cs.ComputerName.ToUpper())"
If (TestCimConnection -CimSession $cs) {
Write-Verbose "Querying $($cs.ComputerName.ToUpper())"
$data = InvokeQuery -CimSession $cs -Query $Query
[PSCustomObject]@{
PSTypeName = 'OSDetail'
Name = $data.Caption
Version = $data.Version -as [Version]
Build = $data.BuildNumber -as [Int32]
OSArchitecture = $data.OSArchitecture
RegisteredUser = $data.RegisteredUser
RegisteredOrganization = $data.Organization
InstallDate = $data.InstallDate
ComputerName = $data.CSName
}
}
else {
Write-Warning "Unable to connect to $($cs.ComputerName.ToUpper()). $($ce.Message)"
}
} #foreach
} #process
End {
Write-Verbose "Ending $($MyInvocation.MyCommand)"
} #end
} #end function Get-OSDetail
I want to mock the private functions because the Pester test isn't supposed to run the actual function. It only needs to test the function's logic and my requirements.
However, as I alluded to at the end of the previous article, there is going to be a problem. I can write a mock of the private function like this:
Mock TestCimConnection { $true } -Verifiable -ParameterFilter { $CimSession.ComputerName -eq 'SRV1' }
But when I run the Pester test, I get an error: CommandNotFoundException: Could not find Command TestCimConnection
. This is because Pester is looking for a public function called TestCimConnection
. It doesn't know about the private function. How can I work around this problem?
Expose the Functions
Pester can only test, or mock what it can see. This is a matter of scope. If my function was in a module, there is a way to expose the private functions to Pester. I'll save that for a future article. For stand-alone functions, you need to be a little more creative.
One option is to move the private functions from the Begin
block and add them to the script file.
function TestCimConnection {
param(
[Microsoft.Management.Infrastructure.CimSession]$CimSession
)
$cs.TestConnection([ref]$ci, [ref]$ce)
} #TestCimConnection
function InvokeQuery {
param(
[Microsoft.Management.Infrastructure.CimSession]$CimSession,
[string]$Query
)
$CimSession.QueryInstances('Root/Cimv2', 'WQL', $Query)
}
Function Get-OSDetail {
When I do this, my Pester test and mocks work as expected. The downside is that when I dot source the script file in production, I also expose two functions that don't have standard names and that aren't intended for a user to run. I'm probably worrying too much about this, but it just feels messy.
Define the Private Functions in the Pester Test
Remember, Pester needs to be able to see the functions to mock them. Nothing is preventing me from redefining the private functions in the Pester test. This is a little more work, but this approach lets me keep the functions private in the script file.
Here's the fun part. I don't have to copy the entire private function to the Pester test. I only have to define the function by name so that I can mock it.
Context 'Command Output' {
BeforeAll {
function TestCimConnection { }
function InvokeQuery { }
...
The functions in the BeforeAll
block are empty. But I can mock them. The mock goes in the same BeforeAll
block.
Mock TestCimConnection { $true } -Verifiable -ParameterFilter { $CimSession.ComputerName -eq 'SRV1' }
Mock TestCimConnection { $false } -Verifiable -ParameterFilter { $CimSession.ComputerName -eq 'OFFLINE' }
$result = Get-OSDetail -CimSession SRV1
I can use these mocks in my assertions.
It 'Should write a warning for a connection failure' {
Get-OSDetail -CimSession OFFLINE -WarningVariable wv -WarningAction SilentlyContinue
$wv | Should -Not -BeNullOrEmpty
}
Mocking Output
The other thing I haven't shown is mocking output. I have a little bit with the TestCimConnection
mock. I am returning True or False depending on the computer name. But I can also mock the output of the InvokeQuery
function. The Query
method returns a full CimInstance object that I use to construct the custom object. But I don't have to simulate that object. I only need to return an object with the properties I need.
Mock InvokeQuery {
return @{
Caption = 'Windows Server 2022'
Version = '10.0.20348'
BuildNumber = '20348'
InstallDate = [DateTime]'1/17/2022 2:54:52 PM'
OSArchitecture = '64-bit'
RegisteredUser = 'Admin'
Organization = 'Company'
CSName = 'SRV1'
}
} -ParameterFilter { $CimSession.ComputerName -eq 'SRV1' }
Because I know the values, I can test for them. I can also test for the data type of the properties.
It 'The output should have Version property of type [Version]' {
$result.Version | Should -BeOfType [Version]
$result.Version | Should -Be '10.0.20348'
}
In a previous article, I showed how to use Pester test cases to loop through a series of property names. I'll rewrite this assertion to also validate I'm getting the value I expect.
It 'The output should have a property of type [String] and value ' -TestCases @(
@{Name = 'Name'; Value = 'Windows Server 2022' },
@{Name = 'OSArchitecture'; Value = '64-bit' },
@{Name = 'RegisteredUser'; Value = 'Admin' },
@{Name = 'RegisteredOrganization'; Value = 'Company' },
@{Name = 'ComputerName'; Value = 'SRV1' }
) {
$script:result.$Name | Should -BeOfType [String]
$script:result.$Name | Should -Be $Value
}
The items
and
are placeholders for the test case data and correspond to the hashtable keys.
Putting it All Together
The process of writing the Pester test involved revising the function and I think improving it. Writing a Pester test for a stand-alone function requires a little more effort, especially in this situation where I needed private functions around the .NET methods.
Here's the function.
Function Get-OSDetail {
<#
.Synopsis
Get operating system details via CIM
.Description
Use this command to query one or more remote computers using CIM to get operating
system details.
.Example
PS C:\> Get-OSDetail
Name : Microsoft Windows 11 Pro
Version : 10.0.22635
Build : 22635
OSArchitecture : 64-bit
RegisteredUser : Jeff
RegisteredOrganization :
InstallDate : 5/17/2022 2:54:52 PM
ComputerName : THINKX1-JH
.Link
Get-CimInstance
#>
[CmdletBinding()]
[OutputType('OSDetail')]
Param(
[Parameter(
Position = 0,
ValueFromPipeline
)]
[Alias('CN', 'Server')]
[ValidateNotNullOrEmpty()]
[Microsoft.Management.Infrastructure.CimSession[]]$CimSession = $Env:ComputerName
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
#define the WQL query
$Query = 'Select CSName,Caption,Version,BuildNumber,InstallDate,OSArchitecture,RegisteredUser,Organization from Win32_OperatingSystem'
#initialize reference variables
New-Variable -Name ci
New-Variable -Name ce
#define private helper functions that I can mock in my pester tests
function TestCimConnection {
param(
[Microsoft.Management.Infrastructure.CimSession]$CimSession
)
$cs.TestConnection([ref]$ci, [ref]$ce)
} #TestCimConnection
function InvokeQuery {
param(
[Microsoft.Management.Infrastructure.CimSession]$CimSession,
[string]$Query
)
$CimSession.QueryInstances('Root/Cimv2', 'WQL', $Query)
}
} #begin
Process {
foreach ($cs in $CimSession) {
#capture connection failures to a variable
Write-Verbose "Testing connection to $($cs.ComputerName.ToUpper())"
If (TestCimConnection -CimSession $cs) {
Write-Verbose "Querying $($cs.ComputerName.ToUpper())"
$data = InvokeQuery -CimSession $cs -Query $Query
[PSCustomObject]@{
PSTypeName = 'OSDetail'
Name = $data.Caption
Version = $data.Version -as [Version]
Build = $data.BuildNumber -as [Int32]
OSArchitecture = $data.OSArchitecture
RegisteredUser = $data.RegisteredUser
RegisteredOrganization = $data.Organization
InstallDate = $data.InstallDate
ComputerName = $data.CSName
}
}
else {
Write-Warning "Unable to connect to $($cs.ComputerName.ToUpper()). $($ce.Message)"
}
} #foreach
} #process
End {
Write-Verbose "Ending $($MyInvocation.MyCommand)"
} #end
} #end function Get-OSDetail
And here's the complete Pester test.
#Get-OSDetail.tests.ps1
# Import the script containing the Get-OSDetail function
BeforeAll {
. $PSCommandPath.Replace('.tests.ps1', '.ps1')
}
Describe 'Get-OSDetail' {
BeforeAll {
#only need to get the command once
$cmd = Get-Command Get-OSDetail
}
Context 'Command Design' {
It 'Should support cmdletbinding' {
$cmd.CmdletBinding
}
# Test if the function has comment-based help
It 'Should have comment-based help' {
(Get-Help Get-OSDetail).Description | Should -Not -BeNullOrEmpty
}
# Test if the function follows PowerShell scripting best practices
# This is a very broad requirement and might need more specific tests
# Here's an example of testing if the function uses approved verbs
It 'Should use approved verbs' {
$cmd.Verb | Should -BeIn (Get-Verb).Verb
}
It 'Does not contain any command aliases' {
$ast = [System.Management.Automation.Language.Parser]::ParseInput($cmd.Definition, [ref]$null, [ref]$null)
$commandAst = $ast.FindAll({
param($ast)
$ast -is [System.Management.Automation.Language.CommandAst] -AND $ast.CommandElements.StringConstantType -eq 'BareWord' -AND (Get-Alias -Name $ast.CommandElements.Value -ErrorAction SilentlyContinue)
}, $true)
$commandAst.Count | Should -Be 0
}
It 'Should not use Write-Host' {
$cmd.Definition | Should -Not -Match 'Write-Host'
}
It 'Should have Verbose output' {
$cmd.Definition | Should -Match 'Write-Verbose'
}
Context Parameters {
Context 'CimSession parameter' {
BeforeAll {
$parameter = $cmd.Parameters['CimSession']
}
It 'Is at position 0' {
$parameter.Attributes.Where({ $_.typeID.name -match 'ParameterAttribute' }).Position | Should -Be 0
}
It 'Accepts pipeline input by value' {
$parameter.Attributes.Where({ $_.typeID.name -match 'ParameterAttribute' }).ValueFromPipeline | Should -BeTrue
}
It 'Is of type Microsoft.Management.Infrastructure.CimSession[]' {
$parameter.ParameterType.FullName | Should -Be 'Microsoft.Management.Infrastructure.CimSession[]'
}
It 'Accepts multiple values' {
$parameter.ParameterType.IsArray | Should -BeTrue
}
It 'CimSession parameter defaults to the local computer name' {
$ast = [System.Management.Automation.Language.Parser]::ParseInput($cmd.Definition, [ref]$null, [ref]$null)
$paramAst = $ast.Find({
param($ast)
$ast -is [System.Management.Automation.Language.ParameterAst] -and
$ast.Name.VariablePath.UserPath -eq 'CimSession'
}, $true)
# This will only work if the default value is a static value, not a runtime value or environment variable
$defaultValue = $paramAst.DefaultValue.Extent.text
$defaultValue | Should -Be '$Env:ComputerName'
}
It 'Has parameter aliases of CN and Server' {
$parameter.Aliases | Should -Contain 'CN'
$parameter.Aliases | Should -Contain 'Server'
}
} # CimSession parameter
}
} # Context Design
Context 'Command Output' {
BeforeAll {
function TestCimConnection { }
function InvokeQuery { }
Mock TestCimConnection { $true } -Verifiable -ParameterFilter { $CimSession.ComputerName -eq 'SRV1' }
Mock TestCimConnection { $false } -ParameterFilter { $CimSession.ComputerName -eq 'OFFLINE' }
Mock InvokeQuery {
return @{
Caption = 'Windows Server 2022'
Version = '10.0.20348'
BuildNumber = '20348'
InstallDate = [DateTime]'1/17/2022 2:54:52 PM'
OSArchitecture = '64-bit'
RegisteredUser = 'Admin'
Organization = 'Company'
CSName = 'SRV1'
}
} -ParameterFilter { $CimSession.ComputerName -eq 'SRV1' } -Verifiable
[regex]$rx = 'Select.*from\s(?\w+_\w+)'
} # BeforeAll
It 'Should test the connection to the CimSession' {
[void](Get-OSDetail -CimSession OFFLINE -WarningAction SilentlyContinue)
Should -Invoke TestCimConnection -Exactly -Times 1
}
It 'Should make a single query' {
#define this as a script scope variable so it can be seen in the other It blocks
$script:result = Get-OSDetail -CimSession SRV1
Should -Invoke InvokeQuery -Exactly -Times 1
}
It 'Should query the Win32_OperatingSystem class' {
$rx.Match($cmd.Definition).Groups["class"].Value | Should -Be 'Win32_OperatingSystem'
}
It 'Should return an OSDetail object' {
#I can't test for the type because it is a custom type name
#$script:result | Should -BeOfType [OSDetail]
#I have to test for the type name
$script:result.PSTypeNames | Should -Contain 'OSDetail'
}
It 'The output should have Version property of type [Version]' {
$script:result.Version | Should -BeOfType [Version]
$script:result.Version | Should -Be '10.0.20348'
}
It 'The output should have Build property of type [Int32]' {
$script:result.Build | Should -BeOfType [Int32]
$script:result.Build | Should -Be 20348
}
It 'The output should have an InstallDate property of type [DateTime]' {
$script:result.InstallDate | Should -BeOfType [DateTime]
$script:result.InstallDate | Should -Be ([DateTime]'1/17/2022 2:54:52 PM')
}
It 'The output should have a property of type [String] and value ' -TestCases @(
@{Name = 'Name'; Value = 'Windows Server 2022' },
@{Name = 'OSArchitecture'; Value = '64-bit' },
@{Name = 'RegisteredUser'; Value = 'Admin' },
@{Name = 'RegisteredOrganization'; Value = 'Company' },
@{Name = 'ComputerName'; Value = 'SRV1' }
) {
$script:result.$Name | Should -BeOfType [String]
$script:result.$Name | Should -Be $Value
}
# Test for failures
# This will depend on the specific logic of your function
# Here's an example of testing if the function throws an error when given a null CimSession
It 'Should throw an error when given a null CimSession' {
{ Get-OSDetail -CimSession $null } | Should -Throw
}
It 'Should write a warning for a connection failure' {
Get-OSDetail -CimSession OFFLINE -WarningVariable wv -WarningAction SilentlyContinue
$wv | Should -Not -BeNullOrEmpty
}
} # Context Output
}
I've added a few other assertions and re-organized the test script. I trust my comments will help you follow along. The test file and script file are in the same folder.
I know I should use the Pester configuration object, but this works for now. I can run the test like this:
Invoke-Pester -Show All -WarningAction SilentlyContinue

You can see the test runs quickly.
Summary
Unit testing with Pester allows anyone to run the test. The test verifies the code is designed and runs as expected. If I modify the function, I can re-run the test to make sure I didn't break anything. Of course, I should modify the test to reflect the changes in the function.
I hope you'll take some time to go through the Pester test and understand what I'm doing. Writing a Pester test is as much an art as a science. Otherwise, I'll have Copilot do all the work. The AI is a handy assistant but it isn't a replacement for the artist.
If you need some help with Pester, take a look at its documentation.