Skip to content

Commit 8c33371

Browse files
🩹 [Patch]: Framework-generated boilerplate code now tested and covered (#113)
Module tests now validate framework-injected boilerplate — type accelerator registration and OnRemove cleanup — so code coverage reflects actual test gaps instead of penalizing module authors for untested framework code. ## New: Type accelerator registration tests When `Build-PSModule` injects a class exporter region, the module tests now verify that every public class and enum listed in `$ExportableClasses` / `$ExportableEnums` is registered as a type accelerator after import. ``` Context Framework - Type accelerator registration [+] Should register public class [Book] as a type accelerator [+] Should register public class [BookList] as a type accelerator ``` Modules without a class exporter region skip these tests automatically. ## New: OnRemove cleanup tests A new test verifies that when the module is removed, its type accelerators are cleaned up via the `OnRemove` hook — preventing type collisions when re-importing or importing a different version. ``` Context Framework - Module OnRemove cleanup [+] Should clean up type accelerators when the module is removed ``` ## Changed: IsWindows compatibility shim removed The `$IsWindows = $true` PS 5.1 Desktop edition shim has been removed from `Build-PSModule` (see [PSModule/Build-PSModule#132](PSModule/Build-PSModule#132)). The corresponding test context and test fixture output have been removed here as well. PSModule targets PowerShell LTS (7.6+) where `$IsWindows` is a built-in automatic variable. ## Technical Details - `PSModule.Tests.ps1`: Discovery-phase variables (`$hasClassExporter`, `$expectedClassNames`, `$expectedEnumNames`) are computed at script scope for Pester's `-Skip`/`-ForEach` evaluation, then recomputed in a top-level `BeforeAll` for the Run phase. This dual-computation pattern is required because Pester v5 Discovery and Run are separate executions that don't share script-scope state. - `PSModuleTest.psm1` (test fixture): Updated to remove the `$IsWindows` shim and its suppression attributes, matching the new Build-PSModule output format. - PSScriptAnalyzer `PSUseDeclaredVarsMoreThanAssignments` suppressed at file level because the analyzer cannot trace variable flow across Pester's `BeforeAll` → `It` block boundaries.
1 parent 3cf6c58 commit 8c33371

File tree

2 files changed

+137
-8
lines changed

2 files changed

+137
-8
lines changed

‎src/tests/Module/PSModule/PSModule.Tests.ps1‎

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,73 @@
66
'PSAvoidUsingWriteHost', '',
77
Justification = 'Log outputs to GitHub Actions logs.'
88
)]
9+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
10+
'PSUseDeclaredVarsMoreThanAssignments', '',
11+
Justification = 'Variables are set in BeforeAll and consumed in Describe/It blocks.'
12+
)]
913
[CmdLetBinding()]
1014
param(
1115
[Parameter(Mandatory)]
1216
[string] $Path
1317
)
1418

19+
# Discovery-phase variables — must be set at script scope (outside BeforeAll) so that
20+
# Pester's -Skip and -ForEach parameters can reference them during test discovery.
21+
$moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf
22+
$moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1"
23+
$moduleRootPath = Join-Path -Path $Path -ChildPath "$moduleName.psm1"
24+
25+
# Discover public classes and enums from the compiled module source.
26+
# The class exporter region is injected by Build-PSModule when classes/public contains types.
27+
$moduleContent = if (Test-Path -Path $moduleRootPath) { Get-Content -Path $moduleRootPath -Raw } else { '' }
28+
$hasClassExporter = $moduleContent -match '#region\s+Class exporter'
29+
30+
# Extract expected class and enum names from the class exporter block.
31+
$expectedClassNames = @()
32+
$expectedEnumNames = @()
33+
if ($hasClassExporter) {
34+
# Match $ExportableClasses = @( ... ) block
35+
if ($moduleContent -match '\$ExportableClasses\s*=\s*@\(([\s\S]*?)\)') {
36+
$expectedClassNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value })
37+
}
38+
# Match $ExportableEnums = @( ... ) block
39+
if ($moduleContent -match '\$ExportableEnums\s*=\s*@\(([\s\S]*?)\)') {
40+
$expectedEnumNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value })
41+
}
42+
}
43+
44+
45+
# Run-phase setup — recompute from $Path so that It/Context blocks can use these variables.
46+
# Pester v5 Discovery and Run are separate executions. The script-scope variables above drive
47+
# -Skip and -ForEach during Discovery. This BeforeAll recomputes the same values for the Run
48+
# phase, where It blocks actually execute. The duplication is intentional and required.
1549
BeforeAll {
1650
$moduleName = Split-Path -Path (Split-Path -Path $Path -Parent) -Leaf
1751
$moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1"
18-
Write-Verbose "Module Manifest Path: [$moduleManifestPath]"
52+
$moduleRootPath = Join-Path -Path $Path -ChildPath "$moduleName.psm1"
53+
54+
$moduleContent = if (Test-Path -Path $moduleRootPath) { Get-Content -Path $moduleRootPath -Raw } else { '' }
55+
$hasClassExporter = $moduleContent -match '#region\s+Class exporter'
56+
57+
$expectedClassNames = @()
58+
$expectedEnumNames = @()
59+
if ($hasClassExporter) {
60+
if ($moduleContent -match '\$ExportableClasses\s*=\s*@\(([\s\S]*?)\)') {
61+
$expectedClassNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value })
62+
}
63+
if ($moduleContent -match '\$ExportableEnums\s*=\s*@\(([\s\S]*?)\)') {
64+
$expectedEnumNames = @([regex]::Matches($Matches[1], '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value })
65+
}
66+
}
67+
Write-Host "Has class exporter: $hasClassExporter"
68+
Write-Host "Expected classes: $($expectedClassNames -join ', ')"
69+
Write-Host "Expected enums: $($expectedEnumNames -join ', ')"
1970
}
2071

2172
Describe 'PSModule - Module tests' {
2273
Context 'Module' {
2374
It 'The module should be importable' {
24-
{ Import-Module -Name $moduleName } | Should -Not -Throw
75+
{ Import-Module -Name $moduleManifestPath -Force } | Should -Not -Throw
2576
}
2677
}
2778

@@ -36,9 +87,45 @@ Describe 'PSModule - Module tests' {
3687
$result | Should -Not -Be $null
3788
Write-Host "$($result | Format-List | Out-String)"
3889
}
39-
# It 'has a valid license URL' {}
40-
# It 'has a valid project URL' {}
41-
# It 'has a valid icon URL' {}
42-
# It 'has a valid help URL' {}
90+
}
91+
92+
Context 'Framework - Type accelerator registration' -Skip:(-not $hasClassExporter) {
93+
BeforeAll {
94+
Import-Module -Name $moduleManifestPath -Force
95+
}
96+
It 'Should register public enum [<_>] as a type accelerator' -ForEach $expectedEnumNames {
97+
$registered = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get
98+
$registered.Keys | Should -Contain $_ -Because 'the framework registers public enums as type accelerators'
99+
}
100+
101+
It 'Should register public class [<_>] as a type accelerator' -ForEach $expectedClassNames {
102+
$registered = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get
103+
$registered.Keys | Should -Contain $_ -Because 'the framework registers public classes as type accelerators'
104+
}
105+
}
106+
107+
Context 'Framework - Module OnRemove cleanup' -Skip:(-not $hasClassExporter) {
108+
BeforeAll {
109+
Import-Module -Name $moduleManifestPath -Force
110+
}
111+
It 'Should clean up type accelerators when the module is removed' {
112+
# Capture type names before removal
113+
$typeNames = @(@($expectedEnumNames) + @($expectedClassNames) | Where-Object { $_ })
114+
$typeNames | Should -Not -BeNullOrEmpty -Because 'there should be types to verify cleanup for'
115+
116+
try {
117+
# Remove the module to trigger the OnRemove hook
118+
Remove-Module -Name $moduleName -Force
119+
120+
# Verify type accelerators are cleaned up
121+
$typeAccelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators')::Get
122+
foreach ($typeName in $typeNames) {
123+
$typeAccelerators.Keys | Should -Not -Contain $typeName -Because "the OnRemove hook should remove type accelerator [$typeName]"
124+
}
125+
} finally {
126+
# Re-import the module for any subsequent tests
127+
Import-Module -Name $moduleManifestPath -Force
128+
}
129+
}
43130
}
44131
}

‎tests/outputTestRepo/outputs/module/PSModuleTest/PSModuleTest.psm1‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ Write-Debug "[$scriptName] - [/private] - Processing folder"
190190
#region - From /private/Get-InternalPSModule.ps1
191191
Write-Debug "[$scriptName] - [/private/Get-InternalPSModule.ps1] - Importing"
192192

193-
Function Get-InternalPSModule {
193+
function Get-InternalPSModule {
194194
<#
195195
.SYNOPSIS
196196
Performs tests on a module.
@@ -214,7 +214,7 @@ Write-Debug "[$scriptName] - [/private/Get-InternalPSModule.ps1] - Done"
214214
#region - From /private/Set-InternalPSModule.ps1
215215
Write-Debug "[$scriptName] - [/private/Set-InternalPSModule.ps1] - Importing"
216216

217-
Function Set-InternalPSModule {
217+
function Set-InternalPSModule {
218218
<#
219219
.SYNOPSIS
220220
Performs tests on a module.
@@ -377,6 +377,48 @@ Write-Verbose '------------------------------'
377377
Write-Debug "[$scriptName] - [/finally.ps1] - Done"
378378
#endregion - From /finally.ps1
379379

380+
#region Class exporter
381+
# Get the internal TypeAccelerators class to use its static methods.
382+
$TypeAcceleratorsClass = [psobject].Assembly.GetType(
383+
'System.Management.Automation.TypeAccelerators'
384+
)
385+
# Ensure none of the types would clobber an existing type accelerator.
386+
# If a type accelerator with the same name already exists, skip adding it.
387+
$ExistingTypeAccelerators = $TypeAcceleratorsClass::Get
388+
# Define the types to export with type accelerators.
389+
$ExportableEnums = @(
390+
)
391+
$ExportableEnums | ForEach-Object { Write-Verbose "Exporting enum '$($_.FullName)'." }
392+
foreach ($Type in $ExportableEnums) {
393+
if ($Type.FullName -in $ExistingTypeAccelerators.Keys) {
394+
Write-Verbose "Enum already exists [$($Type.FullName)]. Skipping."
395+
} else {
396+
Write-Verbose "Importing enum '$Type'."
397+
$TypeAcceleratorsClass::Add($Type.FullName, $Type)
398+
}
399+
}
400+
$ExportableClasses = @(
401+
[Book]
402+
[BookList]
403+
)
404+
$ExportableClasses | ForEach-Object { Write-Verbose "Exporting class '$($_.FullName)'." }
405+
foreach ($Type in $ExportableClasses) {
406+
if ($Type.FullName -in $ExistingTypeAccelerators.Keys) {
407+
Write-Verbose "Class already exists [$($Type.FullName)]. Skipping."
408+
} else {
409+
Write-Verbose "Importing class '$Type'."
410+
$TypeAcceleratorsClass::Add($Type.FullName, $Type)
411+
}
412+
}
413+
414+
# Remove type accelerators when the module is removed.
415+
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
416+
foreach ($Type in ($ExportableEnums + $ExportableClasses)) {
417+
$null = $TypeAcceleratorsClass::Remove($Type.FullName)
418+
}
419+
}.GetNewClosure()
420+
#endregion Class exporter
421+
380422
$exports = @{
381423
Cmdlet = ''
382424
Alias = '*'

0 commit comments

Comments
 (0)