diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml
index 9ca3fda6..cc37fe00 100644
--- a/.github/workflows/Action-Test.yml
+++ b/.github/workflows/Action-Test.yml
@@ -11,12 +11,40 @@ jobs:
strategy:
fail-fast: false
matrix:
- shell: [pwsh]
- os: [ubuntu-latest, macos-latest, windows-latest]
include:
- - shell: powershell
- os: windows-latest
- name: Action-Test - [${{ matrix.os }}@${{ matrix.shell }}]
+ - os: ubuntu-latest
+ shell: pwsh
+ path: tests/src
+ testtype: SourceCode
+ - os: ubuntu-latest
+ shell: pwsh
+ path: tests/outputs/modules
+ testtype: Module
+ - os: macos-latest
+ shell: pwsh
+ path: tests/src
+ testtype: sourcecode
+ - os: macos-latest
+ shell: pwsh
+ path: tests/outputs/modules
+ testtype: module
+ - os: windows-latest
+ shell: pwsh
+ path: tests/src
+ testtype: sourcecode
+ - os: windows-latest
+ shell: pwsh
+ path: tests/outputs/modules
+ testtype: module
+ - os: windows-latest
+ shell: powershell
+ path: tests/src
+ testtype: sourcecode
+ - os: windows-latest
+ shell: powershell
+ path: tests/outputs/modules
+ testtype: module
+ name: Action-Test - [${{ matrix.os }}@${{ matrix.shell }}] - [${{ matrix.path }}]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repo
@@ -33,5 +61,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
with:
Name: PSModuleTest
- Path: tests/outputs/modules
+ Path: ${{ matrix.path }}
Shell: ${{ matrix.shell }}
+ TestType: ${{ matrix.TestType }}
diff --git a/README.md b/README.md
index 416a069b..1b8912e1 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ This GitHub Action is a part of the [PSModule framework](https://github.com/PSMo
## Specifications and practices
-Test-PSModule follows:
+Test-PSModule enables:
- [Test-Driven Development](https://testdriven.io/test-driven-development/) using [Pester](https://pester.dev) and [PSScriptAnalyzer](https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/overview?view=ps-modules)
@@ -15,15 +15,53 @@ Test-PSModule follows:
The action runs the following the Pester test framework:
- [PSScriptAnalyzer tests](https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/rules/readme?view=ps-modules)
- [PSModule framework tests](#psmodule-tests)
-- If `RunModuleTests` is set to `true`:
- - Custom module tests from the `tests` directory in the module repository.
- - Module manifest tests using [Test-ModuleManifest](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/test-modulemanifest)
+- If `TestType` is set to `Module`:
+ - The module manifest is:
+ - tested using [Test-ModuleManifest](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/test-modulemanifest).
+ - temporarily altered to version `999.0.0` to avoid version conflicts when running for dependencies of the framework.
+ - The module is imported.
+ - Custom module tests from the `tests` directory in the module repository are run.
+ - CodeCoverage is calculated.
+ - The following reports are calculated and uploaded as artifacts:
+ - Test suite results.
+ - Code coverage results.
+- If `TestType` is set to `SourceCode`:
+ - The source code is tested with:
+ - `PSScriptAnalyzer` for best practices, using custom settings.
+ - `PSModule.SourceCode` for other PSModule standards.
The action fails if any of the tests fail or it fails to run the tests.
## How to use it
To use the action, create a new file in the `.github/workflows` directory of the module repository and add the following content.
+
+Workflow suggestion - before module is built
+
+```yaml
+name: Test-PSModule
+
+on: [push]
+
+jobs:
+ Test-PSModule:
+ name: Test-PSModule
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v4
+
+ - name: Initialize environment
+ uses: PSModule/Initialize-PSModule@main
+
+ - name: Test-PSModule
+ uses: PSModule/Test-PSModule@main
+ with:
+ Path: src
+ TestType: SourceCode
+
+```
+
Workflow suggestion - after module is built
@@ -47,8 +85,8 @@ jobs:
- name: Test-PSModule
uses: PSModule/Test-PSModule@main
with:
- Name: PSModule
Path: outputs/modules
+ TestType: Module
```
@@ -59,9 +97,9 @@ jobs:
| Name | Description | Required | Default |
| ---- | ----------- | -------- | ------- |
-| `Name` | The name of the module to test. The name of the repository is used if not specified. | `false` | |
| `Path` | The path to the module to test. | `true` | |
-| `RunModuleTests` | Run the module tests. | `false` | `true` |
+| `TestType` | The type of tests to run. Can be either `Module` or `SourceCode`. | `true` | |
+| `Name` | The name of the module to test. The name of the repository is used if not specified. | `false` | |
| `Shell` | The shell to use for running the tests. | `false` | `pwsh` |
## PSModule tests
diff --git a/action.yml b/action.yml
index ee8a6a90..fc4258a6 100644
--- a/action.yml
+++ b/action.yml
@@ -12,10 +12,9 @@ inputs:
Path:
description: The path to the module to test.
required: true
- RunModuleTests:
- description: Run the module tests.
- required: false
- default: 'true'
+ TestType:
+ description: The type of tests to run. Can be either 'Module' or 'SourceCode'.
+ required: true
Shell:
description: The shell to use for running the tests.
required: false
@@ -29,7 +28,21 @@ runs:
env:
GITHUB_ACTION_INPUT_Name: ${{ inputs.Name }}
GITHUB_ACTION_INPUT_Path: ${{ inputs.Path }}
- GITHUB_ACTION_INPUT_RunModuleTests: ${{ inputs.RunModuleTests }}
+ GITHUB_ACTION_INPUT_TestType: ${{ inputs.TestType }}
run: |
# Test-PSModule
. "$env:GITHUB_ACTION_PATH\scripts\main.ps1" -Verbose
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: ${{ inputs.TestType == 'Module' }}
+ with:
+ name: ${{ runner.os }}-${{ inputs.Shell }}-Test-Report
+ path: ${{ github.workspace }}/outputs/Test-Report.xml
+
+ - name: Upload code coverage report
+ uses: actions/upload-artifact@v4
+ if: ${{ inputs.TestType == 'Module' }}
+ with:
+ name: ${{ runner.os }}-${{ inputs.Shell }}-CodeCoverage-Report
+ path: ${{ github.workspace }}/outputs/CodeCoverage-Report.xml
diff --git a/scripts/helpers/Test-PSModule.ps1 b/scripts/helpers/Test-PSModule.ps1
index 2ec23249..15b485d8 100644
--- a/scripts/helpers/Test-PSModule.ps1
+++ b/scripts/helpers/Test-PSModule.ps1
@@ -14,32 +14,19 @@ function Test-PSModule {
# Run module tests.
[Parameter()]
- [switch] $RunModuleTests
+ [ValidateSet('SourceCode', 'Module')]
+ [string] $TestType = 'SourceCode'
)
$moduleName = Split-Path -Path $Path -Leaf
-
- #region Test Module Manifest
- Start-LogGroup 'Test Module Manifest'
- $moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1"
- if (Test-Path -Path $moduleManifestPath) {
- try {
- $status = Test-ModuleManifest -Path $moduleManifestPath
- } catch {
- Write-Warning "⚠️ Test-ModuleManifest failed: $moduleManifestPath"
- throw $_.Exception.Message
- }
- Write-Verbose ($status | Format-List | Out-String) -Verbose
- } else {
- Write-Warning "⚠️ Module manifest not found: $moduleManifestPath"
- }
- Stop-LogGroup
- #endregion
+ $testSourceCode = $TestType -eq 'SourceCode'
+ $testModule = $TestType -eq 'Module'
+ $moduleTestsPath = Join-Path $env:GITHUB_WORKSPACE 'tests'
#region Get test kit versions
Start-LogGroup 'Get test kit versions'
- $PSSAModule = Get-PSResource -Name PSScriptAnalyzer | Sort-Object Version -Descending | Select-Object -First 1
- $pesterModule = Get-PSResource -Name Pester | Sort-Object Version -Descending | Select-Object -First 1
+ $PSSAModule = Get-PSResource -Name PSScriptAnalyzer -Verbose:$false | Sort-Object Version -Descending | Select-Object -First 1
+ $pesterModule = Get-PSResource -Name Pester -Verbose:$false | Sort-Object Version -Descending | Select-Object -First 1
Write-Verbose 'Testing with:'
Write-Verbose " PowerShell $($PSVersionTable.PSVersion.ToString())"
@@ -57,6 +44,7 @@ function Test-PSModule {
Data = @{
Path = $Path
SettingsFilePath = Join-Path $PSSATestsPath 'PSScriptAnalyzer.Tests.psd1'
+ Verbose = $true
}
}
Write-Verbose 'ContainerParams:'
@@ -67,11 +55,11 @@ function Test-PSModule {
#region Add test - Common - PSModule
Start-LogGroup 'Add test - Common - PSModule'
- $PSModuleTestsPath = Join-Path -Path $env:GITHUB_ACTION_PATH -ChildPath 'scripts\tests\PSModule'
$containerParams = @{
- Path = $PSModuleTestsPath
+ Path = Join-Path -Path $env:GITHUB_ACTION_PATH -ChildPath 'scripts\tests\PSModule\Common.Tests.ps1'
Data = @{
- Path = $Path
+ Path = $Path
+ Verbose = $true
}
}
Write-Verbose 'ContainerParams:'
@@ -80,15 +68,49 @@ function Test-PSModule {
Stop-LogGroup
#endregion
- #region Add test - Specific - $moduleName
- if ($RunModuleTests) {
- $moduleTestsPath = Join-Path $env:GITHUB_WORKSPACE 'tests'
+ #region Add test - Module - PSModule
+ if ($testModule) {
+ Start-LogGroup 'Add test - Module - PSModule'
+ $containerParams = @{
+ Path = Join-Path -Path $env:GITHUB_ACTION_PATH -ChildPath 'scripts\tests\PSModule\Module.Tests.ps1'
+ Data = @{
+ Path = $Path
+ Verbose = $true
+ }
+ }
+ Write-Verbose 'ContainerParams:'
+ Write-Verbose "$($containerParams | ConvertTo-Json)"
+ $containers += New-PesterContainer @containerParams
+ Stop-LogGroup
+ }
+ #endregion
+
+ #region Add test - SourceCode - PSModule
+ if ($testSourceCode) {
+ Start-LogGroup 'Add test - SourceCode - PSModule'
+ $containerParams = @{
+ Path = Join-Path -Path $env:GITHUB_ACTION_PATH -ChildPath 'scripts\tests\PSModule\SourceCode.Tests.ps1'
+ Data = @{
+ Path = $Path
+ Verbose = $true
+ }
+ }
+ Write-Verbose 'ContainerParams:'
+ Write-Verbose "$($containerParams | ConvertTo-Json)"
+ $containers += New-PesterContainer @containerParams
+ Stop-LogGroup
+ }
+ #endregion
+
+ #region Add test - Module - $moduleName
+ if ($testModule) {
if (Test-Path -Path $moduleTestsPath) {
- Start-LogGroup "Add test - Specific - $moduleName"
+ Start-LogGroup "Add test - Module - $moduleName"
$containerParams = @{
Path = $moduleTestsPath
Data = @{
- Path = $Path
+ Path = $Path
+ Verbose = $true
}
}
Write-Verbose 'ContainerParams:'
@@ -98,17 +120,17 @@ function Test-PSModule {
} else {
Write-Warning "⚠️ No tests found - [$moduleTestsPath]"
}
- } else {
- Write-Warning "⚠️ Module tests are disabled - [$moduleName]"
}
#endregion
#region Import module
- if ((Test-Path -Path $moduleTestsPath) -and $RunModuleTests) {
+ if ((Test-Path -Path $moduleTestsPath) -and $testModule) {
Start-LogGroup "Importing module: $moduleName"
+ $moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1"
+ Set-ModuleManifest -Path $moduleManifestPath -ModuleVersion '999.0.0'
Add-PSModulePath -Path (Split-Path $Path -Parent)
Get-Module -Name $moduleName -ListAvailable | Remove-Module -Force
- Import-Module -Name $moduleName -Force -RequiredVersion 999.0.0 -Global
+ Import-Module -Name $moduleName -Force -RequiredVersion '999.0.0' -Global
Stop-LogGroup
}
#endregion
@@ -123,14 +145,14 @@ function Test-PSModule {
PassThru = $true
}
TestResult = @{
- Enabled = $true
+ Enabled = $testModule
OutputFormat = 'NUnitXml'
- OutputPath = '.\outputs\PSModuleTest.Results.xml'
- TestSuiteName = 'PSModule Tests'
+ OutputPath = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath 'outputs\Test-Report.xml'
+ TestSuiteName = 'Unit tests'
}
CodeCoverage = @{
- Enabled = $true
- OutputPath = '.\outputs\CodeCoverage.xml'
+ Enabled = $testModule
+ OutputPath = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath 'outputs\CodeCoverage-Report.xml'
OutputFormat = 'JaCoCo'
OutputEncoding = 'UTF8'
CoveragePercentTarget = 75
diff --git a/scripts/main.ps1 b/scripts/main.ps1
index 8a6fe8bd..efa1b0a1 100644
--- a/scripts/main.ps1
+++ b/scripts/main.ps1
@@ -17,14 +17,13 @@ Write-Verbose "Code to test: [$codeToTest]"
if (-not (Test-Path -Path $codeToTest)) {
throw "Path [$codeToTest] does not exist."
}
-$runModuleTests = $env:GITHUB_ACTION_INPUT_RunModuleTests -eq 'true'
-Write-Verbose "Run module tests: [$runModuleTests]"
+Write-Verbose "Test type to run: [$env:GITHUB_ACTION_INPUT_TestType]"
Stop-LogGroup
$params = @{
- Path = $codeToTest
- RunModuleTests = $runModuleTests
+ Path = $codeToTest
+ TestType = $env:GITHUB_ACTION_INPUT_TestType
}
$results = Test-PSModule @params
diff --git a/scripts/tests/PSModule/CodingStyle.Tests.ps1 b/scripts/tests/PSModule/CodingStyle.Tests.ps1
deleted file mode 100644
index bb935ceb..00000000
--- a/scripts/tests/PSModule/CodingStyle.Tests.ps1
+++ /dev/null
@@ -1,5 +0,0 @@
-Describe 'CodingStyle' {
- It 'Is following the coding style for PSModule Framework' {
- $true | Should -Be $true
- }
-}
diff --git a/scripts/tests/PSModule/PSModule.Tests.ps1 b/scripts/tests/PSModule/Common.Tests.ps1
similarity index 90%
rename from scripts/tests/PSModule/PSModule.Tests.ps1
rename to scripts/tests/PSModule/Common.Tests.ps1
index 49093944..9dd283b5 100644
--- a/scripts/tests/PSModule/PSModule.Tests.ps1
+++ b/scripts/tests/PSModule/Common.Tests.ps1
@@ -9,10 +9,9 @@ Param(
)
# These tests are for the whole module and its parts. The scope of these tests are on the src folder and the specific module folder within it.
-Context 'Module design tests' {
- Describe 'Script files' {
+Describe 'Script files' {
+ Context 'Module design tests' {
It 'Script filename and function/filter name should match' {
-
$scriptFiles = @()
Get-ChildItem -Path $Path -Filter '*.ps1' -Recurse -File | ForEach-Object {
@@ -38,9 +37,7 @@ Context 'Module design tests' {
}
# It 'Script file should only contain one function or filter' {}
-
# It 'All script files have tests' {} # Look for the folder name in tests called the same as section/folder name of functions
-
}
Describe 'Function/filter design' {
@@ -68,15 +65,3 @@ Context 'Module design tests' {
# It 'parameters are separated by a blank line' {}
}
}
-
-Context 'Manifest file' {
- It 'has a manifest file' {}
- It 'has a valid license URL' {}
- It 'has a valid project URL' {}
- It 'has a valid icon URL' {}
- It 'has a valid help URL' {}
-}
-
-Context 'Root module file' {
- It 'has a root module file' {}
-}
diff --git a/scripts/tests/PSModule/Module.Tests.ps1 b/scripts/tests/PSModule/Module.Tests.ps1
new file mode 100644
index 00000000..a30684ed
--- /dev/null
+++ b/scripts/tests/PSModule/Module.Tests.ps1
@@ -0,0 +1,43 @@
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSReviewUnusedParameter', 'Path',
+ Justification = 'Path is used to specify the path to the module to test.'
+)]
+[CmdLetBinding()]
+Param(
+ [Parameter(Mandatory)]
+ [string] $Path
+)
+
+BeforeAll {
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseDeclaredVarsMoreThanAssignments', 'moduleName',
+ Justification = 'moduleName is used in the test.'
+ )]
+ $moduleName = Split-Path -Path $Path -Leaf
+}
+
+Describe 'PSModule - Module tests' {
+ Context "Module Manifest" {
+ BeforeAll {
+ $moduleManifestPath = Join-Path -Path $Path -ChildPath "$moduleName.psd1"
+ Write-Verbose "Module Manifest Path: [$moduleManifestPath]" -Verbose
+ }
+ It 'Module Manifest exists' {
+ $result = Test-Path -Path $moduleManifestPath
+ $result | Should -Be $true
+ Write-Verbose $result -Verbose
+ }
+ It 'Module Manifest is valid' {
+ $result = Test-ModuleManifest -Path $moduleManifestPath
+ $result | Should -Not -Be $null
+ Write-Verbose $result -Verbose
+ }
+ # It 'has a valid license URL' {}
+ # It 'has a valid project URL' {}
+ # It 'has a valid icon URL' {}
+ # It 'has a valid help URL' {}
+ }
+ # Context "Root module file" {
+ # It 'has a root module file' {}
+ # }
+}
diff --git a/scripts/tests/PSModule/SourceCode.Tests.ps1 b/scripts/tests/PSModule/SourceCode.Tests.ps1
new file mode 100644
index 00000000..b7ab8b20
--- /dev/null
+++ b/scripts/tests/PSModule/SourceCode.Tests.ps1
@@ -0,0 +1,70 @@
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSReviewUnusedParameter', 'Path',
+ Justification = 'Path is used to specify the path to the module to test.'
+)]
+[CmdLetBinding()]
+Param(
+ [Parameter(Mandatory)]
+ [string] $Path
+)
+
+Describe 'PSModule - SourceCode tests' {
+
+ Context 'function/filter' {
+ It 'Script filename and function/filter name should match' {
+
+ $scriptFiles = @()
+
+ Get-ChildItem -Path $Path -Filter '*.ps1' -Recurse -File | ForEach-Object {
+ $fileContent = Get-Content -Path $_.FullName -Raw
+ if ($fileContent -match '^(?:function|filter)\s+([a-zA-Z][a-zA-Z0-9-]*)') {
+ $functionName = $matches[1]
+ $fileName = $_.BaseName
+ $relativePath = $_.FullName.Replace($Path, '').Trim('\').Trim('/')
+ $scriptFiles += @{
+ fileName = $fileName
+ filePath = $relativePath
+ functionName = $functionName
+ }
+ }
+ }
+
+ $issues = @('')
+ $issues += $scriptFiles | Where-Object { $_.filename -ne $_.functionName } | ForEach-Object {
+ " - $($_.filePath): Function/filter name [$($_.functionName)]. Change file name or function/filter name so they match."
+ }
+ $issues -join [Environment]::NewLine |
+ Should -BeNullOrEmpty -Because 'the script files should be called the same as the function they contain'
+ }
+
+ # It 'Script file should only contain one function or filter' {}
+
+ # It 'All script files have tests' {} # Look for the folder name in tests called the same as section/folder name of functions
+
+ }
+
+ Context 'Function/filter design' {
+ # It 'comment based doc block start is indented with 4 spaces' {}
+ # It 'comment based doc is indented with 8 spaces' {}
+ # It 'has synopsis for all functions' {}
+ # It 'has description for all functions' {}
+ # It 'has examples for all functions' {}
+ # It 'has output documentation for all functions' {}
+ # It 'has [CmdletBinding()] attribute' {}
+ # It 'boolean parameters in CmdletBinding() attribute are written without assignments' {}
+ # I.e. [CmdletBinding(ShouldProcess)] instead of [CmdletBinding(ShouldProcess = $true)]
+ # It 'has [OutputType()] attribute' {}
+ # It 'has verb 'New','Set','Disable','Enable' etc. and uses "ShoudProcess" in the [CmdletBinding()] attribute' {}
+ }
+
+ Context 'Parameter design' {
+ # It 'has parameter description for all functions' {}
+ # It 'has parameter validation for all functions' {}
+ # It 'parameters have [Parameters()] attribute' {}
+ # It 'boolean parameters to the [Parameter()] attribute are written without assignments' {}
+ # I.e. [Parameter(Mandatory)] instead of [Parameter(Mandatory = $true)]
+ # It 'datatype for parameters are written on the same line as the parameter name' {}
+ # It 'datatype for parameters and parameter name are separated by a single space' {}
+ # It 'parameters are separated by a blank line' {}
+ }
+}
diff --git a/tests/outputs/modules/PSModuleTest/PSModuleTest.psd1 b/tests/outputs/modules/PSModuleTest/PSModuleTest.psd1
index ebe988d3..b47c2dd3 100644
--- a/tests/outputs/modules/PSModuleTest/PSModuleTest.psd1
+++ b/tests/outputs/modules/PSModuleTest/PSModuleTest.psd1
@@ -1,6 +1,6 @@
@{
RootModule = 'PSModuleTest.psm1'
- ModuleVersion = '999.0.0'
+ ModuleVersion = '0.0.1'
CompatiblePSEditions = @(
'Core'
'Desktop'
diff --git a/tests/src/PSModuleTest/assemblies/LsonLib.dll b/tests/src/PSModuleTest/assemblies/LsonLib.dll
new file mode 100644
index 00000000..36618070
Binary files /dev/null and b/tests/src/PSModuleTest/assemblies/LsonLib.dll differ
diff --git a/tests/src/PSModuleTest/classes/Book.ps1 b/tests/src/PSModuleTest/classes/Book.ps1
new file mode 100644
index 00000000..3e22c270
--- /dev/null
+++ b/tests/src/PSModuleTest/classes/Book.ps1
@@ -0,0 +1,132 @@
+class Book {
+ # Class properties
+ [string] $Title
+ [string] $Author
+ [string] $Synopsis
+ [string] $Publisher
+ [datetime] $PublishDate
+ [int] $PageCount
+ [string[]] $Tags
+ # Default constructor
+ Book() { $this.Init(@{}) }
+ # Convenience constructor from hashtable
+ Book([hashtable]$Properties) { $this.Init($Properties) }
+ # Common constructor for title and author
+ Book([string]$Title, [string]$Author) {
+ $this.Init(@{Title = $Title; Author = $Author })
+ }
+ # Shared initializer method
+ [void] Init([hashtable]$Properties) {
+ foreach ($Property in $Properties.Keys) {
+ $this.$Property = $Properties.$Property
+ }
+ }
+ # Method to calculate reading time as 2 minutes per page
+ [timespan] GetReadingTime() {
+ if ($this.PageCount -le 0) {
+ throw 'Unable to determine reading time from page count.'
+ }
+ $Minutes = $this.PageCount * 2
+ return [timespan]::new(0, $Minutes, 0)
+ }
+ # Method to calculate how long ago a book was published
+ [timespan] GetPublishedAge() {
+ if (
+ $null -eq $this.PublishDate -or
+ $this.PublishDate -eq [datetime]::MinValue
+ ) { throw 'PublishDate not defined' }
+
+ return (Get-Date) - $this.PublishDate
+ }
+ # Method to return a string representation of the book
+ [string] ToString() {
+ return "$($this.Title) by $($this.Author) ($($this.PublishDate.Year))"
+ }
+}
+
+class BookList {
+ # Static property to hold the list of books
+ static [System.Collections.Generic.List[Book]] $Books
+ # Static method to initialize the list of books. Called in the other
+ # static methods to avoid needing to explicit initialize the value.
+ static [void] Initialize() { [BookList]::Initialize($false) }
+ static [bool] Initialize([bool]$force) {
+ if ([BookList]::Books.Count -gt 0 -and -not $force) {
+ return $false
+ }
+
+ [BookList]::Books = [System.Collections.Generic.List[Book]]::new()
+
+ return $true
+ }
+ # Ensure a book is valid for the list.
+ static [void] Validate([book]$Book) {
+ $Prefix = @(
+ 'Book validation failed: Book must be defined with the Title,'
+ 'Author, and PublishDate properties, but'
+ ) -join ' '
+ if ($null -eq $Book) { throw "$Prefix was null" }
+ if ([string]::IsNullOrEmpty($Book.Title)) {
+ throw "$Prefix Title wasn't defined"
+ }
+ if ([string]::IsNullOrEmpty($Book.Author)) {
+ throw "$Prefix Author wasn't defined"
+ }
+ if ([datetime]::MinValue -eq $Book.PublishDate) {
+ throw "$Prefix PublishDate wasn't defined"
+ }
+ }
+ # Static methods to manage the list of books.
+ # Add a book if it's not already in the list.
+ static [void] Add([Book]$Book) {
+ [BookList]::Initialize()
+ [BookList]::Validate($Book)
+ if ([BookList]::Books.Contains($Book)) {
+ throw "Book '$Book' already in list"
+ }
+
+ $FindPredicate = {
+ param([Book]$b)
+
+ $b.Title -eq $Book.Title -and
+ $b.Author -eq $Book.Author -and
+ $b.PublishDate -eq $Book.PublishDate
+ }.GetNewClosure()
+ if ([BookList]::Books.Find($FindPredicate)) {
+ throw "Book '$Book' already in list"
+ }
+
+ [BookList]::Books.Add($Book)
+ }
+ # Clear the list of books.
+ static [void] Clear() {
+ [BookList]::Initialize()
+ [BookList]::Books.Clear()
+ }
+ # Find a specific book using a filtering scriptblock.
+ static [Book] Find([scriptblock]$Predicate) {
+ [BookList]::Initialize()
+ return [BookList]::Books.Find($Predicate)
+ }
+ # Find every book matching the filtering scriptblock.
+ static [Book[]] FindAll([scriptblock]$Predicate) {
+ [BookList]::Initialize()
+ return [BookList]::Books.FindAll($Predicate)
+ }
+ # Remove a specific book.
+ static [void] Remove([Book]$Book) {
+ [BookList]::Initialize()
+ [BookList]::Books.Remove($Book)
+ }
+ # Remove a book by property value.
+ static [void] RemoveBy([string]$Property, [string]$Value) {
+ [BookList]::Initialize()
+ $Index = [BookList]::Books.FindIndex({
+ param($b)
+ $b.$Property -eq $Value
+ }.GetNewClosure())
+ if ($Index -ge 0) {
+ [BookList]::Books.RemoveAt($Index)
+ }
+ }
+}
diff --git a/tests/src/PSModuleTest/data/Config.psd1 b/tests/src/PSModuleTest/data/Config.psd1
new file mode 100644
index 00000000..fea44669
--- /dev/null
+++ b/tests/src/PSModuleTest/data/Config.psd1
@@ -0,0 +1,3 @@
+@{
+ RandomKey = 'RandomValue'
+}
diff --git a/tests/src/PSModuleTest/data/Settings.psd1 b/tests/src/PSModuleTest/data/Settings.psd1
new file mode 100644
index 00000000..bcfa7b47
--- /dev/null
+++ b/tests/src/PSModuleTest/data/Settings.psd1
@@ -0,0 +1,3 @@
+@{
+ RandomSetting = 'RandomSettingValue'
+}
diff --git a/tests/src/PSModuleTest/finally.ps1 b/tests/src/PSModuleTest/finally.ps1
new file mode 100644
index 00000000..e51c2260
--- /dev/null
+++ b/tests/src/PSModuleTest/finally.ps1
@@ -0,0 +1,3 @@
+Write-Verbose '------------------------------' -Verbose
+Write-Verbose '--- THIS IS A LAST LOADER ---' -Verbose
+Write-Verbose '------------------------------' -Verbose
diff --git a/tests/src/PSModuleTest/formats/CultureInfo.Format.ps1xml b/tests/src/PSModuleTest/formats/CultureInfo.Format.ps1xml
new file mode 100644
index 00000000..a715e08a
--- /dev/null
+++ b/tests/src/PSModuleTest/formats/CultureInfo.Format.ps1xml
@@ -0,0 +1,37 @@
+
+
+
+
+ System.Globalization.CultureInfo
+
+ System.Globalization.CultureInfo
+
+
+
+
+ 16
+
+
+ 16
+
+
+
+
+
+
+
+ LCID
+
+
+ Name
+
+
+ DisplayName
+
+
+
+
+
+
+
+
diff --git a/tests/src/PSModuleTest/formats/Mygciview.Format.ps1xml b/tests/src/PSModuleTest/formats/Mygciview.Format.ps1xml
new file mode 100644
index 00000000..4c972c2c
--- /dev/null
+++ b/tests/src/PSModuleTest/formats/Mygciview.Format.ps1xml
@@ -0,0 +1,65 @@
+
+
+
+
+ mygciview
+
+ System.IO.DirectoryInfo
+ System.IO.FileInfo
+
+
+ PSParentPath
+
+
+
+
+
+ 7
+ Left
+
+
+
+ 26
+ Right
+
+
+
+ 26
+ Right
+
+
+
+ 14
+ Right
+
+
+
+ Left
+
+
+
+
+
+
+
+ ModeWithoutHardLink
+
+
+ LastWriteTime
+
+
+ CreationTime
+
+
+ Length
+
+
+ Name
+
+
+
+
+
+
+
+
diff --git a/tests/src/PSModuleTest/header.ps1 b/tests/src/PSModuleTest/header.ps1
new file mode 100644
index 00000000..cc1fde9a
--- /dev/null
+++ b/tests/src/PSModuleTest/header.ps1
@@ -0,0 +1,3 @@
+[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Contains long links.')]
+[CmdletBinding()]
+param()
diff --git a/tests/src/PSModuleTest/init/initializer.ps1 b/tests/src/PSModuleTest/init/initializer.ps1
new file mode 100644
index 00000000..f4121d25
--- /dev/null
+++ b/tests/src/PSModuleTest/init/initializer.ps1
@@ -0,0 +1,3 @@
+Write-Verbose '-------------------------------' -Verbose
+Write-Verbose '--- THIS IS AN INITIALIZER ---' -Verbose
+Write-Verbose '-------------------------------' -Verbose
diff --git a/tests/src/PSModuleTest/modules/OtherPSModule.psm1 b/tests/src/PSModuleTest/modules/OtherPSModule.psm1
new file mode 100644
index 00000000..9e4353ba
--- /dev/null
+++ b/tests/src/PSModuleTest/modules/OtherPSModule.psm1
@@ -0,0 +1,19 @@
+Function Get-OtherPSModule {
+ <#
+ .SYNOPSIS
+ Performs tests on a module.
+
+ .DESCRIPTION
+ A longer description of the function.
+
+ .EXAMPLE
+ Get-OtherPSModule -Name 'World'
+ #>
+ [CmdletBinding()]
+ param(
+ # Name of the person to greet.
+ [Parameter(Mandatory)]
+ [string] $Name
+ )
+ Write-Output "Hello, $Name!"
+}
diff --git a/tests/src/PSModuleTest/private/Get-InternalPSModule.ps1 b/tests/src/PSModuleTest/private/Get-InternalPSModule.ps1
new file mode 100644
index 00000000..3366e44b
--- /dev/null
+++ b/tests/src/PSModuleTest/private/Get-InternalPSModule.ps1
@@ -0,0 +1,18 @@
+Function Get-InternalPSModule {
+ <#
+ .SYNOPSIS
+ Performs tests on a module.
+
+ .EXAMPLE
+ Test-PSModule -Name 'World'
+
+ "Hello, World!"
+ #>
+ [CmdletBinding()]
+ param (
+ # Name of the person to greet.
+ [Parameter(Mandatory)]
+ [string] $Name
+ )
+ Write-Output "Hello, $Name!"
+}
diff --git a/tests/src/PSModuleTest/private/Set-InternalPSModule.ps1 b/tests/src/PSModuleTest/private/Set-InternalPSModule.ps1
new file mode 100644
index 00000000..11c2fa15
--- /dev/null
+++ b/tests/src/PSModuleTest/private/Set-InternalPSModule.ps1
@@ -0,0 +1,22 @@
+Function Set-InternalPSModule {
+ <#
+ .SYNOPSIS
+ Performs tests on a module.
+
+ .EXAMPLE
+ Test-PSModule -Name 'World'
+
+ "Hello, World!"
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function',
+ Justification = 'Reason for suppressing'
+ )]
+ [CmdletBinding()]
+ param (
+ # Name of the person to greet.
+ [Parameter(Mandatory)]
+ [string] $Name
+ )
+ Write-Output "Hello, $Name!"
+}
diff --git a/tests/src/PSModuleTest/public/Get-PSModuleTest.ps1 b/tests/src/PSModuleTest/public/Get-PSModuleTest.ps1
new file mode 100644
index 00000000..0e9aacfe
--- /dev/null
+++ b/tests/src/PSModuleTest/public/Get-PSModuleTest.ps1
@@ -0,0 +1,20 @@
+#Requires -Modules Utilities
+
+function Get-PSModuleTest {
+ <#
+ .SYNOPSIS
+ Performs tests on a module.
+
+ .EXAMPLE
+ Test-PSModule -Name 'World'
+
+ "Hello, World!"
+ #>
+ [CmdletBinding()]
+ param (
+ # Name of the person to greet.
+ [Parameter(Mandatory)]
+ [string] $Name
+ )
+ Write-Output "Hello, $Name!"
+}
diff --git a/tests/src/PSModuleTest/public/New-PSModuleTest.ps1 b/tests/src/PSModuleTest/public/New-PSModuleTest.ps1
new file mode 100644
index 00000000..7f26215f
--- /dev/null
+++ b/tests/src/PSModuleTest/public/New-PSModuleTest.ps1
@@ -0,0 +1,24 @@
+#Requires -Modules @{ModuleName='PSSemVer'; ModuleVersion='1.0'}
+
+function New-PSModuleTest {
+ <#
+ .SYNOPSIS
+ Performs tests on a module.
+
+ .EXAMPLE
+ Test-PSModule -Name 'World'
+
+ "Hello, World!"
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function',
+ Justification = 'Reason for suppressing'
+ )]
+ [CmdletBinding()]
+ param (
+ # Name of the person to greet.
+ [Parameter(Mandatory)]
+ [string] $Name
+ )
+ Write-Output "Hello, $Name!"
+}
diff --git a/tests/src/PSModuleTest/public/Set-PSModuleTest.ps1 b/tests/src/PSModuleTest/public/Set-PSModuleTest.ps1
new file mode 100644
index 00000000..a87ac117
--- /dev/null
+++ b/tests/src/PSModuleTest/public/Set-PSModuleTest.ps1
@@ -0,0 +1,22 @@
+function Set-PSModuleTest {
+ <#
+ .SYNOPSIS
+ Performs tests on a module.
+
+ .EXAMPLE
+ Test-PSModule -Name 'World'
+
+ "Hello, World!"
+ #>
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
+ 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function',
+ Justification = 'Reason for suppressing'
+ )]
+ [CmdletBinding()]
+ param (
+ # Name of the person to greet.
+ [Parameter(Mandatory)]
+ [string] $Name
+ )
+ Write-Output "Hello, $Name!"
+}
diff --git a/tests/src/PSModuleTest/public/Test-PSModuleTest.ps1 b/tests/src/PSModuleTest/public/Test-PSModuleTest.ps1
new file mode 100644
index 00000000..26be2b9b
--- /dev/null
+++ b/tests/src/PSModuleTest/public/Test-PSModuleTest.ps1
@@ -0,0 +1,18 @@
+function Test-PSModuleTest {
+ <#
+ .SYNOPSIS
+ Performs tests on a module.
+
+ .EXAMPLE
+ Test-PSModule -Name 'World'
+
+ "Hello, World!"
+ #>
+ [CmdletBinding()]
+ param (
+ # Name of the person to greet.
+ [Parameter(Mandatory)]
+ [string] $Name
+ )
+ Write-Output "Hello, $Name!"
+}
diff --git a/tests/src/PSModuleTest/scripts/loader.ps1 b/tests/src/PSModuleTest/scripts/loader.ps1
new file mode 100644
index 00000000..29ad42f6
--- /dev/null
+++ b/tests/src/PSModuleTest/scripts/loader.ps1
@@ -0,0 +1,3 @@
+Write-Verbose '-------------------------' -Verbose
+Write-Verbose '--- THIS IS A LOADER ---' -Verbose
+Write-Verbose '-------------------------' -Verbose
diff --git a/tests/src/PSModuleTest/types/DirectoryInfo.Types.ps1xml b/tests/src/PSModuleTest/types/DirectoryInfo.Types.ps1xml
new file mode 100644
index 00000000..aef538b2
--- /dev/null
+++ b/tests/src/PSModuleTest/types/DirectoryInfo.Types.ps1xml
@@ -0,0 +1,21 @@
+
+
+
+ System.IO.FileInfo
+
+
+ Status
+ Success
+
+
+
+
+ System.IO.DirectoryInfo
+
+
+ Status
+ Success
+
+
+
+
diff --git a/tests/src/PSModuleTest/types/FileInfo.Types.ps1xml b/tests/src/PSModuleTest/types/FileInfo.Types.ps1xml
new file mode 100644
index 00000000..4cfaf6b8
--- /dev/null
+++ b/tests/src/PSModuleTest/types/FileInfo.Types.ps1xml
@@ -0,0 +1,14 @@
+
+
+
+ System.IO.FileInfo
+
+
+ Age
+
+ ((Get-Date) - ($this.CreationTime)).Days
+
+
+
+
+