More Pester Testing .NET with Copilot
Last time, I started exploring how to build a set of Pester tests for code written around the .NET Framework. I am using the Get-OSDetail
function that uses raw .NET classes for CIM objects. In the previous article, I shared the process I used to build the Pester test with the assistance of GitHub CoPilot in VS Code. If you read the last article, you know the AI-generated code is far from perfect. You still have to understand how to write a Pester test. However, Copilot accelerated the process and provided a good starting point in many cases.
Organizing the Pester Tests
When we left off, I had a test that would validate the function's design and input. When Copilot created a test for the CimSession
parameter, it kindly organized the test in a Context
block. I often like to organize my tests in this way. It makes it easier to understand the test's purpose and selectively run tests.
Here's the current version of the Pester test script:
# Import the script containing the Get-OSDetail function
BeforeAll {
. $PSCommandPath.Replace('.tests.ps1', '.ps1')
}
Describe 'Get-OSDetail' {
Context 'Command Design' {
# 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' {
(Get-Command Get-OSDetail).Verb | Should -BeIn (Get-Verb).Verb
}
It 'Does not contain any command aliases' {
$functionDefinition = Get-Command Get-OSDetail | Select-Object -ExpandProperty Definition
$ast = [System.Management.Automation.Language.Parser]::ParseInput($functionDefinition, [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
}
Context Parameters {
Context 'CimSession parameter' {
BeforeAll {
$parameter = (Get-Command Get-OSDetail).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
}
The 'CimSession parameter defaults to the local computer name' {
$functionDefinition = Get-Command Get-OSDetail | Select-Object -ExpandProperty Definition
$ast = [System.Management.Automation.Language.Parser]::ParseInput($functionDefinition, [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' {
# Test all code paths
# This will depend on the specific logic of your function
# Here's an example of testing if the function returns the correct OS details
It 'Should return the correct OS details' {
$session = New-CimSession
$result = Get-OSDetail -CimSession $session
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$result.Name | Should -Be $os.Caption
$result.Version | Should -Be $os.Version
$result.OSArchitecture | Should -Be $os.OSArchitecture
$result.RegisteredUser | Should -Be $os.RegisteredUser
$result.RegisteredOrganization | Should -Be $os.Organization
$result.InstallDate | Should -Be $os.InstallDate
$result.ComputerName | Should -Be $os.CSName
}
# 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
}
} # Context Output
}
The test works as expected.

I added an outer Context
block for each parameter even though I only have one. If I add more parameters, I can define additional Context
blocks for each. This also serves as a template for future tests.
Modeling Output
One item that I want to revise is the output testing. When you write a Pester test, you model the function's output. In the current state of the test, there is no mocking. The function is running against the local host. I eventually need to address this. But for now, I want to revise the output test to ensure the output object is correctly defined. This allows me to confirm I haven't broken anything should I modify the function.
I am writing a custom object. After reviewing the properties I wanted to validate, I realized I should revise my code to ensure that the properties on the custom object are of the correct type.
PS C:\> $r = 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
PS C:\> $r.PSObject.properties | Select-Object Name,TypeNameOfValue,Value
Name TypeNameOfValue Value
---- --------------- -----
Name System.String Microsoft Windows 11 Pro
Version System.String 10.0.22635
Build System.String 22635
OSArchitecture System.String 64-bit
RegisteredUser System.String Jeff
RegisteredOrganization System.Object
InstallDate System.DateTime 5/17/2022 2:54:52 PM
ComputerName System.String THINKX1-JH
Many of the properties now are strings. This could pose a problem should I try to sort a collection of these objects. I can easily adjust this in my function.
[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
}
I can write specific tests for Version
and Build
.
It 'The output should have Version property of type [Version]' {
$result.Version | Should -BeOfType [Version]
}
It 'The output should have Build property of type [Int32]' {
$result.Build | Should -BeOfType [Int32]
}
It 'The output should have an InstallDate property of type [DateTime]' {
$result.InstallDate | Should -BeOfType [DateTime]
}
After I wrote the assertion and pressed Enter after the opening brace, Copilot suggested the appropriate code. As soon as I started typing the next assertion, Copilot "auto-completed" the rest of the assertion and the test. This is admittedly spooky when I feel Copilot is reading my mind.
The remaining properties should be strings. But instead of hard-coding a test for each, I can use Pester's test cases feature.
It 'The output should have a <_> property of type [String]' -TestCases @("Name", "OSArchitecture", "RegisteredUser", "RegisteredOrganization", "ComputerName") {
$result.$($_) | Should -BeOfType [String]
}
However, this test assumes all properties have a value. This is where mocking would be helpful. For right now, I can easily modify my computer to set a value for RegisteredOrganization
.

Refactoring Code
Now, I need to address refactoring code. I asked Copilot with what is probably too broad a prompt.
What sections of code might need to be re-factored to make the code more testable?
The response, though, is quite helpful and surprisingly insightful.
The Get-OSDetail function is fairly well-structured for testing, but there are a few areas that could be refactored to improve testability:
Dependency on external resources: The function depends on the CimSession object, which is an external resource. This makes it hard to test the function in isolation. You could refactor the function to accept an interface or a mock object, which can be controlled in your tests.
Error handling: The function uses the TestConnection method to check the connection, but it doesn't handle the case when the connection fails. You could refactor the function to throw an exception or return a specific error object when the connection fails. This would allow you to write tests for the error handling code.
Looping over CimSession objects: The function loops over CimSession objects and processes each one. This could be refactored into a separate function that processes a single CimSession object. This would make it easier to write tests for the processing code.
Direct querying of instances: The function directly queries instances using the QueryInstances method. This could be refactored into a separate function or service that can be mocked in your tests.
It suggested this code:
function Get-OSDetail {
[CmdletBinding()]
param (
[Parameter(Mandatory=$true)]
[Microsoft.Management.Infrastructure.CimSession[]]$CimSession
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
#define the WQL query
$Query = 'Select CSName,Caption,Version,BuildNumber,InstallDate,OSArchitecture,RegisteredUser,Organization from Win32_OperatingSystem'
} #begin
Process {
foreach ($cs in $CimSession) {
Get-OSDetailForSession -CimSession $cs -Query $Query
}
} #process
} #end function Get-OSDetail
function Get-OSDetailForSession {
param (
[Microsoft.Management.Infrastructure.CimSession]$CimSession,
[string]$Query
)
#capture connection failures to a variable
if ($CimSession.TestConnection([ref]$ci, [ref]$ce)) {
Write-Verbose "Querying $($CimSession.ComputerName.ToUpper())"
$data = $CimSession.QueryInstances('Root/Cimv2', 'WQL', $Query)
[PSCustomObject]@{
PSTypeName = 'OSDetail'
Name = $data.Caption
Version = $data.Version
Build = $data.BuildNumber
OSArchitecture = $data.OSArchitecture
RegisteredUser = $data.RegisteredUser
RegisteredOrganization = $data.Organization
InstallDate = $data.InstallDate
ComputerName = $data.CSName
}
} else {
throw "Failed to connect to $($CimSession.ComputerName)"
}
}
In this refactored code, the Get-OSDetail function now calls a separate Get-OSDetailForSession function for each CimSession object. This makes writing tests for the code that processes a single CimSession object easier. Also, the Get-OSDetailForSession function now throws an exception when the connection fails, which allows you to write tests for the error-handling code.
This is quite helpful. Even though I have an If/Else
statement based on testing the connection, because I am invoking a .NET method, I can't mock the action. I don't think I'll use the suggested code as is, but it gives me a good starting point.
One thing I want to be cautious of is that I don't want to make things worse when refactoring. Running the existing function against the local host took 261ms. Hopefully, re-factoring won't have a detrimental effect on performance.
I'll define a helper function in the Begin
script block.
function TestCimConnection {
param(
[Microsoft.Management.Infrastructure.CimSession]$CimSession
)
$cs.TestConnection([ref]$ci, [ref]$ce)
} #TestCimConnection
I don't need cmdletbinding
, and I'm trusting PowerShell's scoping process to find the referenced variables. The function is a simple wrapper around the .NET method. I then modify the function to call it.
If (TestCimConnection -CimSession $cs) {
I'll define a similar helper function to wrap around the QueryInstances
method.
function InvokeQuery {
param(
[Microsoft.Management.Infrastructure.CimSession]$CimSession,
[string]$Query
)
$CimSession.QueryInstances('Root/Cimv2', 'WQL', $Query)
}
Then, modify the function to call it.
$data = InvokeQuery -CimSession $cs -Query $Query
Running the command verifies that the function still works as expected. And I haven't degraded performance with the helper functions.
Mocking
Now for the fun part. I can now mock the TestCimConnection
and InvokeQuery
functions. This will allow me to test for successes and failures without invoking the .NET methods. The TestCimConnection
mock is pretty easy since I only have to return True or False. However, the InvokeQuery
mock is a bit more complex. I need to return a CIM object, which means I can use the New-MockObject
cmdlet. However, stop and think about this. I have a mock for the InvokeQuery
action. That is going to return data I use to build my custom object. I don't need a CimInstance; I only need an object with the correct properties and values.
BeforeAll {
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
}
The values don't have to be real, but I like making them as realistic as possible.
Likewise, I can add a mock for the testing function.
Mock TestCimConnection { $true } -Verifiable -ParameterFilter { $CimSession.ComputerName -eq 'SRV1' }
These mocks are in the Context 'Command Output'
block. However, based on experience, I already know that these mocks will fail. We're going to run into a scoping problem with the private functions. Diving into this will take us deep into the weeds, and I've probably already made your head spin a little bit. We'll pick up here next time.
Summary
If nothing else, I hope you are recognizing the value and limitations of depending on AI when writing PowerShell code. GitHub Copilot has been very helpful in writing much of the Pester test, although I've had to rely on my experience to fix shortcomings.
Mocking and testing .NET methods means wrapping the code into a function—a function you can mock. But there are new challenges, which we'll cover next time. Comments and questions are always welcome.