Pester Testing with InModuleScope
In a recent article, we looked at writing a Pester test that required mocking private functions. These are functions intended to be used within your code, not exposed to the user. Often, we use private helper functions as wrappers to commands that we would otherwise be unable to test with Pester.
The challenge is that in a stand-alone function, exposing the private functions to Pester adds a little complexity to the test. However, if we are writing a module, we have another alternative. Pester tests can be challenging to write because of scope. Fortunately, Pester includes a feature that makes it easier to test or mock private functions in a module. I want to show you how to wrap your tests in an InModuleScope
block.
You can limit the scoping to the tests that need to access the private functions.
Describe MyCode {
InModuleScope MyFunction {
It 'should return a string' {
$result = MyFunction
$result | Should -Be 'This is a demo script file.'
}
It 'should fail on a bad computername' {
$result = MyFunction -ComputerName 'BadComputer'
$result | Should -Throw
}
} #InModuleScope
#this is NOT tested in the module scope
It 'Should accept a PSCredential' {
#TBD
} -pending
} #describe
Personally, if I'm writing a Pester test for a module, I wrap all of my tests in an InModuleScope
block. This way, I can be sure that I'm testing the module as it will be used in the real world.
InModuleScope MyFunction {
Describe CommandFoo {
}
Describe CommandBar {
}
}
Let's look at an example using my Get-OSDetail
function that I'll put into a module.
The Module
In my previous article, the private helper functions were in the Begin
block of the main function. In the module, I have moved them outside of the function. The module will be called OSDetail
and the functions are in the OSDetail.psm1
file.
#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)
}
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
} #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 have a module manifest file that exports the function. I've trimmed the manifest of settings I am not using.
#
# Module manifest for OSDetail
#
@{
RootModule = 'OSDetail.psm1'
ModuleVersion = '0.1.0'
CompatiblePSEditions = @('Core', 'Desktop')
GUID = '1ffb1ec8-90bd-4e6e-b075-e559b2a5e359'
Author = 'Jeff Hicks'
CompanyName = 'JDH Information Technology Solutions, Inc.'
Copyright = '(c) 2024 JDH Information Technology Solutions, Inc.'
Description = 'A module with tools for getting operating system details via CIM.'
PowerShellVersion = '5.1'
TypesToProcess = @()
FormatsToProcess = @()
FunctionsToExport = 'Get-OSDetail'
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @()
LicenseUri = ''
ProjectUri = ''
ExternalModuleDependencies = @()
} # End of PSData hashtable
} # End of PrivateData hashtable
}
Loading the Module to Test
My Pester test file is called OSDetail.tests.ps1
and it is in a tests
subfolder from the module root. When I run the test, I need to ensure that the most current version of the module is loaded. You can use whatever code you want, even hardcoding the module name and path. I'm taking a more programmatic route so that I can reuse the code in other tests.
I'll put the module loading code in a BeforeDiscover
block. I want the module to be loaded before anything else is done.
BeforeDiscovery {
# Import the OSDetail module manifest
$module = $PSCommandPath.Replace('.tests.ps1', '.psd1')
#set a global variable so I can use this in my tests
$global:modulePath = Join-Path -Path .. -ChildPath (Split-Path $module -Leaf)
$ModuleName = (Get-Item $global:modulePath).BaseName
If (Test-Path $global:modulePath) {
Import-Module $global:modulePath -Force
}
else {
Write-Warning "Can't find module at $global:modulePath"
}
}
Because of the way Pester scopes variables, I'm defining a global variable to hold the module manifest path. I'll show you later how I use this in my tests.
Testing Functions
Because I am going to use InModuleScope
, I no longer need to redefine the private functions in my test. All I need to do is mock them. Otherwise, my test is very similar to what I showed you in the previous article.
InModuleScope $ModuleName {
#I only need to test module functions in the module scope
Describe 'Get-OSDetail' -Tag Function {
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
}
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 {
#Mocking private functions
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
#this is not the only way to write this test
$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
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
} #Describe Get-OSDetail
} #InModuleScope
I explained in the previous article how and why I set up the `Context blocks.
I've added a tag to the Describe
block so that I can run this test separately from other tests in the file. The comments and assertions should be self-explanatory.
Invoke-Pester -output Detailed -TagFilter function

If I add functions to the module, I can add more Describe
blocks to the InModuleScope
block.
Testing Module Design
What I've shown thus far was my goal for this article. But how about some bonus material? When I write a Pester test for a module, I often include code to validate the module design and organization. I want to make sure the module has all the components I expect before I publish it to the PowerShell Gallery,
Describe "Module $ModuleName" -Tag Module {
BeforeAll {
#only need to get the module once
$thisModule = Test-ModuleManifest -Path $global:modulePath
}
AfterAll {
Remove-Variable -Name modulePath -Scope Global
}
Context Manifest {
It 'Should have a module manifest' {
$modulePath | Should -Exist
$thisModule.ModuleType | Should -Be 'Manifest'
}
It 'Should have a defined RootModule' {
$thisModule.RootModule | Should -Be "$($thisModule.Name).psm1"
}
It 'Should have a module version number that follows semantic versioning' {
$thisModule.Version | Should -BeOfType [Version]
}
It 'Should have a defined description' {
$thisModule.Description | Should -Not -BeNullOrEmpty
}
It 'Should have a GUID' {
$thisModule.Guid | Should -BeOfType [Guid]
}
It 'Should have an Author' {
$thisModule.Author | Should -Not -BeNullOrEmpty
}
It 'Should have a CompanyName' {
$thisModule.CompanyName | Should -Not -BeNullOrEmpty
}
It 'Should have a Copyright' {
$thisModule.Copyright | Should -Not -BeNullOrEmpty
}
It 'Should have a minimal PowerShellVersion' {
$thisModule.PowerShellVersion | Should -BeOfType [Version]
}
It 'Should have defined CompatiblePSEditions' {
$thisModule.CompatiblePSEditions.count | Should -BeGreaterThan 0
}
It 'Should have a LicenseUri' {
$thisModule.PrivateData.PSData.licenseUri | Should -Not -BeNullOrEmpty
} -pending
It 'Should have a defined ProjectURI' {
$thisModule.PrivateData.PSData.ProjectUri | Should -Not -BeNullOrEmpty
} -pending
It 'Should have defined tags' {
$thisModule.PrivateData.PSData.Tags.count | Should -BeGreaterThan 0
} -pending
}
Context 'Module Content' {
It 'Should have a changelog file' {
"..\changelog.md" | Should -Exist
}
It 'Should have a license file' {
"..\license.md" | Should -Exist
} -Pending
It 'Should have a README file' {
"..\README.md" | Should -Exist
} -pending
It 'Should have a docs folder' {
"..\docs" | Should -Exist
}
It 'Should have a markdown file for every exported function' {
$functions = $thisModule.ExportedFunctions
$functions.GetEnumerator() | ForEach-Object {
$mdFile = "..\docs\$($_.Key).md"
$mdFile | Should -Exist
}
} -pending
It 'Should have external help' {
"..\en-us" | Should -Exist
"..\en-us\OSDetail-help.xml" | Should -Exist
} -pending
It 'Should have a Pester test' {
#this is probably silly since I'm using a Pester test.
".\*tests.ps1" | Should -Exist
}
}
}
Because I am not testing module functions, I don't need to use InModuleScope
. I've marked some tests as pending because I don't have all the files or content yet. This is my way of doing "test-driven development". The pending tests serve as a reminder of what I still need to do.
You can also see that I am organizing the tests with Context
blocks.
Invoke-Pester -output Detailed -TagFilter module

There is no mocking in these tests. One way to look at this is that I am using Pester to test the "infrastructure" of the module. Because this is not a unit test, an argument could be made that it should belong in a separate test file. If I were working in a team environment and sharing the workload in writing Pester tests, having a separate file would also make sense. For me, I find it just as easy to put everything in one file. As long as I use tags, I can run tests on a more granular level.
Summary
There is a learning curve in writing Pester tests so don't feel you have to write anything as complex as what I have been demonstrating. Pester is better suited for testing PowerShell modules than stand-alone scripts. Just remember that if your module relies on private functions, You can't mock what Pester can't see. You will need to use InModuleScope
.
I recommend taking a few minutes to review the Pester documentation on the subject.