PowerShell Module Development
Table of Contents
- Overview
- Prerequisites
- Module Structure
- Development Workflow
- Module Manifest
- Function Development
- Testing with Pester
- Code Analysis
- Build and Packaging
- Publishing Modules
- CI/CD Pipeline
- Best Practices
- Troubleshooting
- Resources
Overview
PowerShell modules are reusable packages of PowerShell code that provide specific functionality. This comprehensive guide covers the complete development lifecycle from initial project setup to publishing and maintenance.
What You'll Learn
- Module Architecture: How to structure professional PowerShell modules
- Development Practices: Industry-standard development workflows
- Testing Strategy: Comprehensive testing with Pester framework
- Quality Assurance: Code analysis and linting standards
- Publishing Process: Deployment to public and private repositories
- CI/CD Integration: Automated build and deployment pipelines
Module Types
- Script Modules (.psm1): Most common, contains PowerShell functions and variables
- Binary Modules (.dll): Compiled .NET assemblies containing PowerShell cmdlets
- Manifest Modules (.psd1): Contains metadata and can load other modules
- Dynamic Modules: Created in memory at runtime
Prerequisites
PowerShell Environment
This guide uses PowerShell 7.4+ for cross-platform compatibility and modern features:
# Check your PowerShell version
$PSVersionTable.PSVersion
# Expected output: 7.4.0 or higher
Development Tools
Required Software
- PowerShell 7.4+: Latest stable version
- Visual Studio Code: Primary development environment
- Git: Version control system
Visual Studio Code Extensions
Install these essential extensions for PowerShell development:
# PowerShell extension
code --install-extension ms-vscode.PowerShell
# GitLens for enhanced Git integration
code --install-extension eamodio.gitlens
# Bracket Pair Colorizer for better code readability
code --install-extension CoenraadS.bracket-pair-colorizer-2
Required PowerShell Modules
Install these modules for development, testing, and analysis:
# Install required modules
$ModulesToInstall = @(
'Pester', # Testing framework (5.5.0+)
'PSScriptAnalyzer', # Code analysis (1.22.0+)
'platyPS', # Documentation generation
'ModuleBuilder', # Module building utilities
'PowerShellGet' # Module publishing (2.2.5+)
)
foreach ($Module in $ModulesToInstall)
{
Install-Module -Name $Module -Scope CurrentUser -Force -AllowClobber
}
# Verify installations
Get-Module -Name $ModulesToInstall -ListAvailable |
Select-Object Name, Version |
Format-Table -AutoSize
Development Environment Configuration
PowerShell Profile Setup
Configure your PowerShell profile for module development:
# Create or edit your PowerShell profile
if (-not (Test-Path $PROFILE))
{
New-Item -Type File -Path $PROFILE -Force
}
# Add helpful aliases and functions
Add-Content -Path $PROFILE -Value @"
# Module development shortcuts
function Import-ModuleForce { param([string]`$Name) Import-Module `$Name -Force }
Set-Alias -Name imf -Value Import-ModuleForce
# Quick Pester test runner
function Invoke-PesterQuick { param([string]`$Path = '.') Invoke-Pester `$Path -Output Detailed }
Set-Alias -Name test -Value Invoke-PesterQuick
# Script Analyzer shortcut
function Invoke-ScriptAnalyzerQuick { param([string]`$Path = '.') Invoke-ScriptAnalyzer `$Path -Recurse }
Set-Alias -Name analyze -Value Invoke-ScriptAnalyzerQuick
"@
Module Structure
Standard Directory Layout
Follow this proven directory structure for professional PowerShell modules:
MyModule/
├── MyModule/ # Module root directory
│ ├── Public/ # Public functions (exported)
│ │ ├── Get-Something.ps1
│ │ ├── Set-Something.ps1
│ │ └── New-Something.ps1
│ ├── Private/ # Private functions (internal use)
│ │ ├── Helper-Function.ps1
│ │ └── Utility-Function.ps1
│ ├── Classes/ # PowerShell classes (optional)
│ │ └── CustomClass.ps1
│ ├── Data/ # Module data files
│ │ ├── config.json
│ │ └── templates.xml
│ ├── Localization/ # Localized strings (optional)
│ │ ├── en-US/
│ │ └── es-ES/
│ ├── MyModule.psd1 # Module manifest
│ ├── MyModule.psm1 # Module script file
│ └── README.md # Module documentation
├── Tests/ # Pester tests
│ ├── Unit/
│ │ ├── Public/
│ │ └── Private/
│ ├── Integration/
│ ├── MyModule.Tests.ps1
│ └── TestHelper.psm1
├── Scripts/ # Build and utility scripts
│ ├── Build.ps1
│ ├── Deploy.ps1
│ └── Clean.ps1
├── Docs/ # Documentation
│ ├── en-US/
│ ├── CHANGELOG.md
│ └── CONTRIBUTING.md
├── Examples/ # Usage examples
├── .gitignore # Git ignore file
├── .vscode/ # VS Code settings
│ ├── settings.json
│ ├── tasks.json
│ └── launch.json
├── azure-pipelines.yml # CI/CD pipeline
├── LICENSE # License file
└── README.md # Project readme
### Creating the Initial Structure
Use this script to create the standard module structure:
```powershell
function New-ModuleStructure
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ModuleName,
[Parameter()]
[string]$Path = (Get-Location).Path,
[Parameter()]
[string]$Author = $env:USERNAME,
[Parameter()]
[string]$Description = "A PowerShell module"
)
$ModuleRoot = Join-Path $Path $ModuleName
# Create directory structure
$Directories = @(
"$ModuleName",
"$ModuleName/Public",
"$ModuleName/Private",
"$ModuleName/Classes",
"$ModuleName/Data",
"$ModuleName/Localization/en-US",
"Tests/Unit/Public",
"Tests/Unit/Private",
"Tests/Integration",
"Scripts",
"Docs/en-US",
"Examples",
".vscode"
)
foreach ($Directory in $Directories)
{
$FullPath = Join-Path $ModuleRoot $Directory
New-Item -Path $FullPath -ItemType Directory -Force | Out-Null
Write-Verbose "Created directory: $FullPath"
}
Write-Output "Module structure created successfully at: $ModuleRoot"
}
# Usage example
New-ModuleStructure -ModuleName "MyAwesomeModule" -Author "Your Name" -Verbose
Development Workflow
1. Initialize Module Project
# Navigate to your development directory
Set-Location "C:\Dev\PowerShell"
# Create module structure
New-ModuleStructure -ModuleName "UserManagement" -Author "Your Name"
# Initialize Git repository
Set-Location "UserManagement"
git init
git add .
git commit -m "Initial module structure"
2. Create Module Manifest
Generate a proper module manifest with all required metadata:
# Create module manifest
$ManifestParams = @{
Path = ".\UserManagement\UserManagement.psd1"
RootModule = "UserManagement.psm1"
ModuleVersion = "1.0.0"
GUID = [System.Guid]::NewGuid().ToString()
Author = "Your Name"
CompanyName = "Your Company"
Copyright = "(c) $(Get-Date -Format yyyy) Your Name. All rights reserved."
Description = "PowerShell module for user management operations"
PowerShellVersion = "7.0"
FunctionsToExport = @() # Will be populated by build script
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
RequiredModules = @('ActiveDirectory')
Tags = @('UserManagement', 'ActiveDirectory', 'Security')
LicenseUri = "https://github.com/yourusername/UserManagement/blob/main/LICENSE"
ProjectUri = "https://github.com/yourusername/UserManagement"
HelpInfoURI = "https://github.com/yourusername/UserManagement/blob/main/README.md"
}
New-ModuleManifest @ManifestParams
3. Implement Module Functions
Create your first public function following best practices:
# UserManagement/Public/Get-UserInformation.ps1
<#
.SYNOPSIS
Retrieves comprehensive user information from Active Directory.
.DESCRIPTION
The Get-UserInformation function retrieves detailed user information from Active Directory,
including account status, group memberships, and last logon information.
.PARAMETER Identity
Specifies the user identity. Can be SamAccountName, UserPrincipalName, or DistinguishedName.
.PARAMETER IncludeGroups
Include group membership information in the output.
.PARAMETER IncludeLastLogon
Include last logon information from all domain controllers.
.EXAMPLE
Get-UserInformation -Identity "john.doe"
Gets basic user information for john.doe.
.EXAMPLE
Get-UserInformation -Identity "john.doe@contoso.com" -IncludeGroups -IncludeLastLogon
Gets comprehensive user information including groups and last logon details.
.NOTES
Requires ActiveDirectory module and appropriate permissions.
.LINK
https://github.com/yourusername/UserManagement
#>
function Get-UserInformation
{
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[ValidateNotNullOrEmpty()]
[string[]]$Identity,
[Parameter()]
[switch]$IncludeGroups,
[Parameter()]
[switch]$IncludeLastLogon
)
begin
{
Write-Verbose "Starting Get-UserInformation"
# Verify required module is available
if (-not (Get-Module -Name ActiveDirectory -ListAvailable))
{
throw "ActiveDirectory module is required but not available"
}
Import-Module ActiveDirectory -Force
}
process
{
foreach ($User in $Identity)
{
try
{
Write-Verbose "Processing user: $User"
# Get basic user information
$ADUser = Get-ADUser -Identity $User -Properties * -ErrorAction Stop
# Create base user object
$UserInfo = [PSCustomObject]@{
SamAccountName = $ADUser.SamAccountName
UserPrincipalName = $ADUser.UserPrincipalName
DisplayName = $ADUser.DisplayName
EmailAddress = $ADUser.EmailAddress
Enabled = $ADUser.Enabled
LockedOut = $ADUser.LockedOut
PasswordExpired = $ADUser.PasswordExpired
LastPasswordSet = $ADUser.PasswordLastSet
WhenCreated = $ADUser.WhenCreated
WhenChanged = $ADUser.WhenChanged
DistinguishedName = $ADUser.DistinguishedName
}
# Add group information if requested
if ($IncludeGroups)
{
Write-Verbose "Including group membership for $User"
$Groups = Get-ADUser -Identity $User -Properties MemberOf |
Select-Object -ExpandProperty MemberOf |
ForEach-Object { (Get-ADGroup $_).Name }
$UserInfo | Add-Member -MemberType NoteProperty -Name "GroupMembership" -Value $Groups
}
# Add last logon information if requested
if ($IncludeLastLogon)
{
Write-Verbose "Including last logon information for $User"
$LastLogon = Get-LastLogonInfo -Identity $User
$UserInfo | Add-Member -MemberType NoteProperty -Name "LastLogonInfo" -Value $LastLogon
}
Write-Output $UserInfo
}
catch
{
Write-Error "Failed to retrieve information for user '$User': $($_.Exception.Message)"
continue
}
}
}
end
{
Write-Verbose "Completed Get-UserInformation"
}
}
4. Create Supporting Private Functions
# UserManagement/Private/Get-LastLogonInfo.ps1
function Get-LastLogonInfo
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Identity
)
try
{
# Get all domain controllers
$DomainControllers = Get-ADDomainController -Filter *
$LastLogonInfo = @()
foreach ($DC in $DomainControllers)
{
try
{
$User = Get-ADUser -Identity $Identity -Server $DC.HostName -Properties LastLogon, LastLogonDate
$LastLogonInfo += [PSCustomObject]@{
DomainController = $DC.HostName
LastLogon = if ($User.LastLogon) { [DateTime]::FromFileTime($User.LastLogon) } else { $null }
LastLogonDate = $User.LastLogonDate
}
}
catch
{
Write-Warning "Could not query $($DC.HostName) for user $Identity"
}
}
# Return the most recent logon
return ($LastLogonInfo | Sort-Object LastLogon -Descending | Select-Object -First 1)
}
catch
{
Write-Error "Failed to get last logon information: $($_.Exception.Message)"
return $null
}
}
Module Manifest
Complete Manifest Example
A comprehensive module manifest with all important fields:
@{
# Module Information
RootModule = 'UserManagement.psm1'
ModuleVersion = '1.0.0'
GUID = '12345678-1234-1234-1234-123456789012'
Author = 'Your Name'
CompanyName = 'Your Company'
Copyright = '(c) 2024 Your Name. All rights reserved.'
Description = 'PowerShell module for comprehensive user management operations in Active Directory environments.'
# PowerShell Requirements
PowerShellVersion = '7.0'
PowerShellHostName = ''
PowerShellHostVersion = ''
DotNetFrameworkVersion = ''
CLRVersion = ''
ProcessorArchitecture = ''
# Module Dependencies
RequiredModules = @(
@{
ModuleName = 'ActiveDirectory'
ModuleVersion = '1.0.0.0'
}
)
RequiredAssemblies = @()
ScriptsToProcess = @()
TypesToProcess = @()
FormatsToProcess = @()
# Exports (populated by build script)
FunctionsToExport = @(
'Get-UserInformation',
'Set-UserPassword',
'New-UserAccount',
'Remove-UserAccount',
'Enable-UserAccount',
'Disable-UserAccount'
)
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
# Module List and File List (for security)
ModuleList = @()
FileList = @()
# Private Data
PrivateData = @{
PSData = @{
# Tags for PowerShell Gallery
Tags = @('UserManagement', 'ActiveDirectory', 'Security', 'Administration')
# License and Project URLs
LicenseUri = 'https://github.com/yourusername/UserManagement/blob/main/LICENSE'
ProjectUri = 'https://github.com/yourusername/UserManagement'
IconUri = 'https://github.com/yourusername/UserManagement/raw/main/icon.png'
# Release Notes
ReleaseNotes = @'
## 1.0.0
- Initial release
- Core user management functions
- Comprehensive error handling
- Full test coverage
'@
# Prerelease identifier
Prerelease = ''
# External dependencies
ExternalModuleDependencies = @('ActiveDirectory')
}
}
# Help and Documentation
HelpInfoURI = 'https://github.com/yourusername/UserManagement/blob/main/README.md'
DefaultCommandPrefix = ''
}
Function Development
Function Template
Use this template for all module functions:
<#
.SYNOPSIS
Brief description of what the function does.
.DESCRIPTION
Detailed description of the function's purpose and behavior.
.PARAMETER ParameterName
Description of the parameter.
.EXAMPLE
Example-Function -ParameterName "Value"
Description of what this example does.
.INPUTS
What objects can be piped to this function.
.OUTPUTS
What this function returns.
.NOTES
Additional information about the function.
.LINK
Related links or documentation.
#>
function Verb-Noun
{
[CmdletBinding(SupportsShouldProcess)]
[OutputType([System.Object])]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[ValidateNotNullOrEmpty()]
[string[]]$Identity,
[Parameter()]
[ValidateSet('Option1', 'Option2', 'Option3')]
[string]$Option = 'Option1',
[Parameter()]
[switch]$Force
)
begin
{
Write-Verbose "Starting $($MyInvocation.MyCommand.Name)"
# Initialize variables
$Results = @()
# Validate prerequisites
if (-not (Test-Prerequisites))
{
throw "Prerequisites not met"
}
}
process
{
foreach ($Item in $Identity)
{
if ($PSCmdlet.ShouldProcess($Item, "Perform Action"))
{
try
{
Write-Verbose "Processing: $Item"
# Main function logic here
$Result = Invoke-SomeOperation -Target $Item -Option $Option
$Results += $Result
}
catch
{
Write-Error "Failed to process '$Item': $($_.Exception.Message)"
continue
}
}
}
}
end
{
Write-Verbose "Completed $($MyInvocation.MyCommand.Name)"
return $Results
}
}
Parameter Validation
Implement comprehensive parameter validation:
param(
# Required string with validation
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$UserName,
# Validate against specific values
[Parameter()]
[ValidateSet('Create', 'Modify', 'Delete', 'Query')]
[string]$Action = 'Query',
# Validate string length
[Parameter()]
[ValidateLength(1, 50)]
[string]$Description,
# Validate number range
[Parameter()]
[ValidateRange(1, 100)]
[int]$MaxResults = 10,
# Validate script block result
[Parameter()]
[ValidateScript({
if (Test-Path $_)
{
return $true
}
else
{
throw "Path '$_' does not exist"
}
})]
[string]$ConfigPath,
# Validate with regular expression
[Parameter()]
[ValidatePattern('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')]
[string]$EmailAddress,
# Custom validation attribute
[Parameter()]
[ValidateNotNull()]
[System.Management.Automation.PSCredential]$Credential
)
Testing with Pester
Test Structure
Create comprehensive tests for your module:
# Tests/Unit/Public/Get-UserInformation.Tests.ps1
BeforeAll {
# Import the module
$ModuleRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent
$ModulePath = Join-Path -Path $ModuleRoot -ChildPath "UserManagement"
Import-Module $ModulePath -Force
# Mock external dependencies
Mock -CommandName Get-ADUser -MockWith {
[PSCustomObject]@{
SamAccountName = 'testuser'
UserPrincipalName = 'testuser@contoso.com'
DisplayName = 'Test User'
EmailAddress = 'testuser@contoso.com'
Enabled = $true
LockedOut = $false
PasswordExpired = $false
PasswordLastSet = (Get-Date).AddDays(-30)
WhenCreated = (Get-Date).AddDays(-100)
WhenChanged = (Get-Date).AddDays(-10)
DistinguishedName = 'CN=Test User,OU=Users,DC=contoso,DC=com'
}
}
}
Describe 'Get-UserInformation' {
Context 'When getting basic user information' {
It 'Should return user object with required properties' {
$Result = Get-UserInformation -Identity 'testuser'
$Result | Should -Not -BeNullOrEmpty
$Result.SamAccountName | Should -Be 'testuser'
$Result.UserPrincipalName | Should -Be 'testuser@contoso.com'
$Result.Enabled | Should -Be $true
}
It 'Should handle pipeline input' {
$Users = @('user1', 'user2', 'user3')
$Result = $Users | Get-UserInformation
$Result | Should -HaveCount 3
}
It 'Should handle multiple users via parameter' {
$Result = Get-UserInformation -Identity @('user1', 'user2')
$Result | Should -HaveCount 2
}
}
Context 'When including group information' {
BeforeEach {
Mock -CommandName Get-ADGroup -MockWith {
[PSCustomObject]@{ Name = 'TestGroup' }
}
}
It 'Should include group membership when switch is specified' {
$Result = Get-UserInformation -Identity 'testuser' -IncludeGroups
$Result.GroupMembership | Should -Not -BeNullOrEmpty
}
}
Context 'When handling errors' {
It 'Should handle user not found gracefully' {
Mock -CommandName Get-ADUser -MockWith {
throw [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]::new()
}
{ Get-UserInformation -Identity 'nonexistentuser' -ErrorAction Stop } | Should -Throw
}
It 'Should continue processing other users when one fails' {
Mock -CommandName Get-ADUser -MockWith {
if ($Identity -eq 'baduser')
{
throw "User not found"
}
return [PSCustomObject]@{ SamAccountName = $Identity }
}
$Result = Get-UserInformation -Identity @('gooduser', 'baduser', 'anothergooduser') -ErrorAction SilentlyContinue
$Result | Should -HaveCount 2
}
}
}
AfterAll {
Remove-Module UserManagement -Force -ErrorAction SilentlyContinue
}
Integration Tests
# Tests/Integration/UserManagement.Integration.Tests.ps1
BeforeAll {
$ModuleRoot = Split-Path -Path $PSScriptRoot -Parent
$ModulePath = Join-Path -Path $ModuleRoot -ChildPath "UserManagement"
Import-Module $ModulePath -Force
# Skip tests if not in AD environment
$SkipTests = -not (Get-Module -Name ActiveDirectory -ListAvailable)
}
Describe 'UserManagement Integration Tests' -Skip:$SkipTests {
Context 'Real Active Directory Integration' {
BeforeAll {
# Create test user for integration tests
$TestUserName = "TestUser_$(Get-Random)"
$TestUserParams = @{
Name = $TestUserName
SamAccountName = $TestUserName
UserPrincipalName = "$TestUserName@$env:USERDNSDOMAIN"
AccountPassword = (ConvertTo-SecureString "TempPassword123!" -AsPlainText -Force)
Enabled = $true
Path = "OU=Test Users,DC=$($env:USERDOMAIN),DC=com"
}
try
{
New-ADUser @TestUserParams -ErrorAction Stop
$TestUserCreated = $true
}
catch
{
$TestUserCreated = $false
Write-Warning "Could not create test user: $($_.Exception.Message)"
}
}
It 'Should retrieve real user information' -Skip:(-not $TestUserCreated) {
$Result = Get-UserInformation -Identity $TestUserName
$Result | Should -Not -BeNullOrEmpty
$Result.SamAccountName | Should -Be $TestUserName
$Result.Enabled | Should -Be $true
}
AfterAll {
if ($TestUserCreated)
{
Remove-ADUser -Identity $TestUserName -Confirm:$false -ErrorAction SilentlyContinue
}
}
}
}
Running Tests
# Run all tests
Invoke-Pester -Path .\Tests\ -Output Detailed
# Run specific test file
Invoke-Pester -Path .\Tests\Unit\Public\Get-UserInformation.Tests.ps1 -Output Detailed
# Run with code coverage
$Coverage = Invoke-Pester -Path .\Tests\ -CodeCoverage .\UserManagement\*.ps1 -PassThru
$Coverage.CodeCoverage
Code Analysis
PSScriptAnalyzer Configuration
Create a configuration file for consistent code analysis:
# PSScriptAnalyzerSettings.psd1
@{
# Include default rules
IncludeDefaultRules = $true
# Exclude specific rules if needed
ExcludeRules = @(
# 'PSUseShouldProcessForStateChangingFunctions' # Uncomment to exclude
)
# Custom rules
CustomRulePath = @()
# Rule configuration
Rules = @{
PSPlaceOpenBrace = @{
Enable = $true
OnSameLine = $false # Allman style braces
}
PSPlaceCloseBrace = @{
Enable = $true
NewLineAfter = $true
}
PSUseConsistentIndentation = @{
Enable = $true
Kind = 'tab' # Use tabs for indentation
}
PSUseConsistentWhitespace = @{
Enable = $true
CheckInnerBrace = $true
CheckOpenBrace = $true
CheckOpenParen = $true
CheckOperator = $true
CheckPipe = $true
CheckSeparator = $true
}
PSAlignAssignmentStatement = @{
Enable = $true
CheckHashtable = $true
}
PSUseCorrectCasing = @{
Enable = $true
}
}
}
Analysis Script
Create a script to run comprehensive code analysis:
# Scripts/Analyze.ps1
[CmdletBinding()]
param(
[Parameter()]
[string]$Path = (Get-Location).Path,
[Parameter()]
[switch]$Fix
)
Write-Host "Running PSScriptAnalyzer on: $Path" -ForegroundColor Green
# Run analysis
$Results = Invoke-ScriptAnalyzer -Path $Path -Recurse -Settings PSScriptAnalyzerSettings.psd1
if ($Results)
{
Write-Host "Found $($Results.Count) issues:" -ForegroundColor Yellow
# Group results by severity
$ResultsBySeverity = $Results | Group-Object Severity
foreach ($Group in $ResultsBySeverity)
{
Write-Host "`n$($Group.Name) Issues: $($Group.Count)" -ForegroundColor Red
foreach ($Issue in $Group.Group)
{
Write-Host " $($Issue.ScriptName):$($Issue.Line) - $($Issue.RuleName): $($Issue.Message)" -ForegroundColor Gray
}
}
# Attempt to fix issues if requested
if ($Fix)
{
Write-Host "`nAttempting to fix issues..." -ForegroundColor Yellow
$FixableIssues = $Results | Where-Object { $_.RuleName -in @(
'PSUseConsistentIndentation',
'PSUseConsistentWhitespace',
'PSAlignAssignmentStatement',
'PSPlaceOpenBrace',
'PSPlaceCloseBrace'
)}
if ($FixableIssues)
{
Invoke-Formatter -ScriptDefinition (Get-Content -Raw -Path $_.ScriptName) -Settings PSScriptAnalyzerSettings.psd1 |
Set-Content -Path $_.ScriptName -Encoding UTF8
Write-Host "Fixed formatting issues" -ForegroundColor Green
}
}
exit 1
}
else
{
Write-Host "No issues found! ✅" -ForegroundColor Green
exit 0
}
Build and Packaging
Build Script
Create a comprehensive build script:
# Scripts/Build.ps1
[CmdletBinding()]
param(
[Parameter()]
[ValidateSet('Dev', 'Test', 'Prod')]
[string]$Configuration = 'Dev',
[Parameter()]
[string]$OutputPath = '.\Output',
[Parameter()]
[switch]$UpdateVersion,
[Parameter()]
[switch]$Clean
)
# Build configuration
$BuildConfig = @{
ModuleName = 'UserManagement'
SourcePath = '.\UserManagement'
OutputPath = $OutputPath
TestPath = '.\Tests'
DocsPath = '.\Docs'
}
Write-Host "Starting build process..." -ForegroundColor Green
Write-Host "Configuration: $Configuration" -ForegroundColor Cyan
# Clean previous build
if ($Clean -or (Test-Path $BuildConfig.OutputPath))
{
Write-Host "Cleaning previous build..." -ForegroundColor Yellow
Remove-Item -Path $BuildConfig.OutputPath -Recurse -Force -ErrorAction SilentlyContinue
}
# Create output directory
New-Item -Path $BuildConfig.OutputPath -ItemType Directory -Force | Out-Null
$ModuleOutputPath = Join-Path $BuildConfig.OutputPath $BuildConfig.ModuleName
New-Item -Path $ModuleOutputPath -ItemType Directory -Force | Out-Null
# Update version if requested
if ($UpdateVersion)
{
Write-Host "Updating module version..." -ForegroundColor Yellow
$ManifestPath = Join-Path $BuildConfig.SourcePath "$($BuildConfig.ModuleName).psd1"
$Manifest = Import-PowerShellDataFile -Path $ManifestPath
$Version = [Version]$Manifest.ModuleVersion
$NewVersion = switch ($Configuration)
{
'Dev' { [Version]::new($Version.Major, $Version.Minor, $Version.Build + 1, 0) }
'Test' { [Version]::new($Version.Major, $Version.Minor + 1, 0, 0) }
'Prod' { [Version]::new($Version.Major + 1, 0, 0, 0) }
}
Write-Host "Version: $($Version.ToString()) → $($NewVersion.ToString())" -ForegroundColor Cyan
# Update manifest
Update-ModuleManifest -Path $ManifestPath -ModuleVersion $NewVersion.ToString()
}
# Run tests
Write-Host "Running tests..." -ForegroundColor Yellow
$TestResults = Invoke-Pester -Path $BuildConfig.TestPath -PassThru -Output Normal
if ($TestResults.FailedCount -gt 0)
{
Write-Host "Tests failed! Build cannot continue." -ForegroundColor Red
exit 1
}
# Run code analysis
Write-Host "Running code analysis..." -ForegroundColor Yellow
& (Join-Path $PSScriptRoot "Analyze.ps1") -Path $BuildConfig.SourcePath
if ($LASTEXITCODE -ne 0)
{
Write-Host "Code analysis issues found! Build cannot continue." -ForegroundColor Red
exit 1
}
# Build module
Write-Host "Building module..." -ForegroundColor Yellow
# Copy module files
Copy-Item -Path "$($BuildConfig.SourcePath)\*" -Destination $ModuleOutputPath -Recurse -Force
# Generate module file by combining all functions
$ModuleContent = @()
# Add module header
$ModuleContent += @"
#
# Module: $($BuildConfig.ModuleName)
# Generated: $(Get-Date)
# Configuration: $Configuration
#
# Module variables
`$Script:ModuleRoot = `$PSScriptRoot
"@
# Add private functions
$PrivateFunctions = Get-ChildItem -Path (Join-Path $BuildConfig.SourcePath "Private") -Filter "*.ps1" -ErrorAction SilentlyContinue
foreach ($Function in $PrivateFunctions)
{
$ModuleContent += "`n# Private function: $($Function.BaseName)"
$ModuleContent += Get-Content -Raw -Path $Function.FullName
}
# Add public functions
$PublicFunctions = Get-ChildItem -Path (Join-Path $BuildConfig.SourcePath "Public") -Filter "*.ps1" -ErrorAction SilentlyContinue
$ExportedFunctions = @()
foreach ($Function in $PublicFunctions)
{
$ModuleContent += "`n# Public function: $($Function.BaseName)"
$ModuleContent += Get-Content -Raw -Path $Function.FullName
$ExportedFunctions += $Function.BaseName
}
# Add module footer
$ModuleContent += @"
# Export public functions
Export-ModuleMember -Function @(
$($ExportedFunctions | ForEach-Object { " '$_'" } | Join-String -Separator ",`n")
)
"@
# Write combined module file
$ModuleFilePath = Join-Path $ModuleOutputPath "$($BuildConfig.ModuleName).psm1"
$ModuleContent | Out-File -FilePath $ModuleFilePath -Encoding UTF8
# Update manifest with exported functions
$ManifestPath = Join-Path $ModuleOutputPath "$($BuildConfig.ModuleName).psd1"
Update-ModuleManifest -Path $ManifestPath -FunctionsToExport $ExportedFunctions
# Generate documentation
if (Get-Module -Name platyPS -ListAvailable)
{
Write-Host "Generating documentation..." -ForegroundColor Yellow
Import-Module $ModuleOutputPath -Force
New-MarkdownHelp -Module $BuildConfig.ModuleName -OutputFolder (Join-Path $BuildConfig.DocsPath "en-US") -Force
Remove-Module $BuildConfig.ModuleName -Force
}
Write-Host "Build completed successfully! ✅" -ForegroundColor Green
Write-Host "Module output: $ModuleOutputPath" -ForegroundColor Cyan
Publishing Modules
Publishing to PowerShell Gallery
# Scripts/Publish.ps1
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ApiKey,
[Parameter()]
[string]$Repository = 'PSGallery',
[Parameter()]
[string]$ModulePath = '.\Output\UserManagement',
[Parameter()]
[switch]$WhatIf
)
Write-Host "Publishing module to $Repository..." -ForegroundColor Green
# Validate module
Write-Host "Validating module..." -ForegroundColor Yellow
$Manifest = Test-ModuleManifest -Path (Join-Path $ModulePath "UserManagement.psd1")
if (-not $Manifest)
{
Write-Host "Module manifest validation failed!" -ForegroundColor Red
exit 1
}
Write-Host "Module: $($Manifest.Name) v$($Manifest.Version)" -ForegroundColor Cyan
# Test import
try
{
Import-Module $ModulePath -Force -ErrorAction Stop
Write-Host "Module import test passed ✅" -ForegroundColor Green
Remove-Module $Manifest.Name -Force
}
catch
{
Write-Host "Module import test failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
# Publish module
$PublishParams = @{
Path = $ModulePath
Repository = $Repository
NuGetApiKey = $ApiKey
Force = $true
Verbose = $true
}
if ($WhatIf)
{
Write-Host "WhatIf: Would publish module with parameters:" -ForegroundColor Yellow
$PublishParams | Format-Table -AutoSize
}
else
{
try
{
Publish-Module @PublishParams
Write-Host "Module published successfully! 🚀" -ForegroundColor Green
}
catch
{
Write-Host "Publishing failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}
}
Publishing to Private Repository
# For private repositories like BaGet
$PrivateRepoParams = @{
Path = $ModulePath
Repository = 'MyPrivateRepo' # Previously registered
NuGetApiKey = $PrivateApiKey
Force = $true
}
Publish-Module @PrivateRepoParams
CI/CD Pipeline
Azure DevOps Pipeline
# azure-pipelines.yml
trigger:
branches:
include:
- main
- develop
paths:
include:
- UserManagement/*
- Tests/*
- Scripts/*
pool:
vmImage: 'windows-latest'
variables:
ModuleName: 'UserManagement'
BuildConfiguration: 'Release'
stages:
- stage: Build
displayName: 'Build and Test'
jobs:
- job: BuildTest
displayName: 'Build and Test Module'
steps:
- task: PowerShell@2
displayName: 'Install Required Modules'
inputs:
targetType: 'inline'
script: |
Install-Module -Name Pester, PSScriptAnalyzer, platyPS -Force -Scope CurrentUser
Get-Module -Name Pester, PSScriptAnalyzer, platyPS -ListAvailable
- task: PowerShell@2
displayName: 'Run Code Analysis'
inputs:
targetType: 'filePath'
filePath: 'Scripts/Analyze.ps1'
arguments: '-Path $(Build.SourcesDirectory)'
- task: PowerShell@2
displayName: 'Run Pester Tests'
inputs:
targetType: 'inline'
script: |
$TestResults = Invoke-Pester -Path './Tests' -OutputFile 'TestResults.xml' -OutputFormat NUnitXml -PassThru
if ($TestResults.FailedCount -gt 0) {
Write-Host "##vso[task.logissue type=error]$($TestResults.FailedCount) test(s) failed"
exit 1
}
- task: PublishTestResults@2
displayName: 'Publish Test Results'
inputs:
testResultsFormat: 'NUnit'
testResultsFiles: 'TestResults.xml'
failTaskOnFailedTests: true
- task: PowerShell@2
displayName: 'Build Module'
inputs:
targetType: 'filePath'
filePath: 'Scripts/Build.ps1'
arguments: '-Configuration $(BuildConfiguration) -UpdateVersion'
- task: PublishPipelineArtifact@1
displayName: 'Publish Build Artifacts'
inputs:
targetPath: 'Output'
artifactName: 'ModuleBuild'
- stage: Publish
displayName: 'Publish Module'
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
dependsOn: Build
jobs:
- deployment: PublishPSGallery
displayName: 'Publish to PowerShell Gallery'
environment: 'Production'
strategy:
runOnce:
deploy:
steps:
- download: current
artifact: ModuleBuild
- task: PowerShell@2
displayName: 'Publish to PowerShell Gallery'
inputs:
targetType: 'filePath'
filePath: 'Scripts/Publish.ps1'
arguments: '-ApiKey $(PSGalleryApiKey) -ModulePath $(Pipeline.Workspace)/ModuleBuild/$(ModuleName)'
env:
PSGalleryApiKey: $(PSGalleryApiKey)
GitHub Actions Workflow
# .github/workflows/build-and-publish.yml
name: Build and Publish Module
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v3
- name: Install PowerShell modules
shell: pwsh
run: |
Install-Module -Name Pester, PSScriptAnalyzer, platyPS -Force -Scope CurrentUser
- name: Run PSScriptAnalyzer
shell: pwsh
run: |
./Scripts/Analyze.ps1 -Path .
- name: Run Pester Tests
shell: pwsh
run: |
$Results = Invoke-Pester -Path ./Tests -PassThru -CI
if ($Results.FailedCount -gt 0) {
throw "$($Results.FailedCount) test(s) failed"
}
build:
needs: test
runs-on: windows-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Build Module
shell: pwsh
run: |
Install-Module -Name Pester, PSScriptAnalyzer, platyPS -Force -Scope CurrentUser
./Scripts/Build.ps1 -Configuration Release -UpdateVersion
- name: Upload Build Artifact
uses: actions/upload-artifact@v3
with:
name: module-build
path: Output/
publish:
needs: build
runs-on: windows-latest
if: github.ref == 'refs/heads/main'
environment: Production
steps:
- uses: actions/checkout@v3
- name: Download Build Artifact
uses: actions/download-artifact@v3
with:
name: module-build
path: Output/
- name: Publish to PowerShell Gallery
shell: pwsh
run: |
./Scripts/Publish.ps1 -ApiKey $env:PSGALLERY_API_KEY -ModulePath ./Output/UserManagement
env:
PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }}
Best Practices
Module Design Principles
- Single Responsibility: Each function should do one thing well
- Consistent Naming: Follow PowerShell verb-noun conventions
- Parameter Design: Use standard parameter names and types
- Error Handling: Implement comprehensive error handling
- Documentation: Provide complete help documentation
- Testing: Achieve high test coverage
- Performance: Optimize for common scenarios
Code Organization
# Good: Clear, specific function names
function Get-UserAccountInformation { }
function Set-UserAccountPassword { }
function New-UserAccountRequest { }
# Bad: Vague, inconsistent naming
function GetUser { }
function ChangePassword { }
function CreateAccount { }
Error Handling Strategy
function Robust-Function
{
[CmdletBinding()]
param([string]$Identity)
try
{
# Use -ErrorAction Stop to make cmdlets throw terminating errors
$Result = Get-SomeResource -Identity $Identity -ErrorAction Stop
# Validate results
if (-not $Result)
{
throw "No results found for identity: $Identity"
}
return $Result
}
catch [SpecificException]
{
# Handle specific known exceptions
Write-Error "Specific error occurred: $($_.Exception.Message)"
throw
}
catch
{
# Handle unexpected exceptions
Write-Error "Unexpected error in $($MyInvocation.MyCommand.Name): $($_.Exception.Message)"
throw
}
}
Performance Considerations
# Efficient: Use ArrayList for dynamic arrays
$Results = [System.Collections.ArrayList]@()
foreach ($Item in $LargeCollection)
{
$null = $Results.Add($ProcessedItem)
}
# Inefficient: Using += operator
$Results = @()
foreach ($Item in $LargeCollection)
{
$Results += $ProcessedItem # Creates new array each time
}
# Efficient: Filter early in pipeline
Get-Process | Where-Object CPU -GT 100 | Select-Object Name, CPU
# Inefficient: Filter after selection
Get-Process | Select-Object Name, CPU | Where-Object CPU -GT 100
Troubleshooting
Common Issues and Solutions
Module Import Issues
# Problem: Module not loading
# Solution: Check module path and manifest
Get-Module -Name YourModule -ListAvailable
Test-ModuleManifest -Path .\YourModule.psd1
# Problem: Functions not exported
# Solution: Verify FunctionsToExport in manifest
$Manifest = Import-PowerShellDataFile .\YourModule.psd1
$Manifest.FunctionsToExport
Test Failures
# Problem: Mocks not working
# Solution: Ensure mocks are in correct scope
BeforeEach {
Mock -CommandName Get-ADUser -MockWith { return $MockUser }
}
# Problem: Integration tests failing
# Solution: Skip tests when dependencies unavailable
BeforeAll {
$SkipTests = -not (Get-Module -Name RequiredModule -ListAvailable)
}
Describe 'Tests' -Skip:$SkipTests {
# Test content
}
Build Issues
# Problem: Build script failing
# Solution: Check prerequisites and paths
if (-not (Get-Module -Name Pester -ListAvailable)) {
throw "Pester module is required for build"
}
if (-not (Test-Path $SourcePath)) {
throw "Source path not found: $SourcePath"
}
Debug Techniques
# Enable verbose output
$VerbosePreference = 'Continue'
Import-Module .\YourModule -Verbose
# Check module internals
Get-Module YourModule | Select-Object -ExpandProperty ExportedCommands
(Get-Module YourModule).PrivateData
# Trace command execution
Trace-Command -Name CommandDiscovery -Expression { Get-YourFunction } -PSHost
Resources
Official Documentation
- PowerShell Module Documentation
- PowerShell Gallery Publishing Guide
- Pester Testing Framework
- PSScriptAnalyzer Rules
Community Resources
Tools and Utilities
- Plaster Templates - Project scaffolding
- BuildHelpers - Build automation
- PSDepend - Dependency management
This document provides comprehensive guidance for PowerShell module development following industry best practices and Microsoft recommendations. For additional resources, see the PowerShell documentation index.