Testing PowerShell with Pester — A Practical Approach to Making Operations Scripts Harder to Break
· Updated: · Go Komura · PowerShell, Pester, Windows, Testing, Automation, CI, Legacy Asset Reuse
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614655)
- First published
Cite this article(DOI: 10.5281/zenodo.21614654)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Testing PowerShell with Pester — A Practical Approach to Making Operations Scripts Harder to Break. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614654 https://comcomponent.com/en/blog/2026/06/08/000-pester-powershell-test-maintenance/
- DOI (latest version)
- 10.5281/zenodo.21614654
- DOI (this version)
- 10.5281/zenodo.22220513
1. What to Understand First
PowerShell scripts start out as automation for small tasks.
Collecting files. Searching logs. Producing a CSV. Moving old files. Checking the state of a service.
As long as each one is a few dozen lines, you can verify it by eye. But as a script keeps getting used in production, changes like these creep in:
- Add more target folders
- Add exclusion conditions
- Change the CSV columns
- Archive before deleting
- Run from Task Scheduler or CI
- Send notifications on errors
At this stage, “it worked once on my machine” is no longer enough.
The scary side of PowerShell is the flip side of its convenience. Read-only operations are easy to experiment with, but for operations like deleting, moving, overwriting, restarting services, or changing permissions, a small mistake in a condition can turn into a real incident.
This is where Pester, the testing framework for PowerShell, comes in. Rather than covering every Pester feature, this article lays out a way to build up tests that make your existing PowerShell scripts harder to break in practice.
PowerShell testing is not just about writing clean code. It is a tool for reducing anxiety before a change and for verifying with evidence after a change.
The code in this article is published on GitHub as a complete set of samples runnable with Invoke-Pester (the script under test, the Pester tests, and a CI execution script).
pester-powershell-test-maintenance - komurasoft-blog-samples (GitHub)
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (18 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
2. What Do You Protect with Pester?
Adding Pester does not automatically make everything safe. The first thing to decide is “what do the tests protect?”
For PowerShell operations scripts, prioritizing these four areas tends to pay off:
| What you want to protect | What the tests examine |
|---|---|
| Condition logic | Which files, lines, users, or services are targeted |
| Output shape | CSV column names, return value properties, item counts |
| The step before dangerous operations | Whether the targets of deletion, moves, or stops are as intended |
| External dependencies | How the file system, APIs, command execution, dates and times, and environment variables are handled |
What you want to test first is not the deletion itself, but the logic that selects what to delete.
For example, in a script that deletes old logs, do not start by testing Remove-Item. Test “which logs get selected as targets” first.
This separation makes things much easier to test:
Function that collects the targets
↓
Step that verifies and records the targets
↓
Mutating step: move, delete, notify, etc.
Building up PowerShell tests does not mean immediately overhauling your existing scripts. Start by extracting the decision logic that sits in front of dangerous operations into a function, and verify its return value with Pester.
3. Align Versions
This article assumes Pester v5.
On older Windows environments, Pester may already be installed — but as the v3 series. Rather than using whatever the existing environment provides, check the version first.
Get-Module Pester -ListAvailable |
Sort-Object Version -Descending |
Select-Object Name, Version, Path
To install fresh, install from the PowerShell Gallery.
Install-Module -Name Pester -Scope CurrentUser -Force -SkipPublisherCheck
Import-Module Pester
Get-Module Pester
-SkipPublisherCheck is there because the old Pester bundled with Windows is installed with a Microsoft signature. Without the switch, the installation stops on the grounds that the publisher differs.
On a corporate machine, this command often does not go through as written. Proxies, TLS 1.2, and the NuGet provider are the typical causes. The fixes are collected in section 17.
When working as a team, verify that the Pester version does not drift between development machines, the build server, and the task execution environment.
A common source of confusion in PowerShell testing is not the code but version differences in the test runner.
In particular, old articles and internal notes may still contain Pester v4-or-earlier syntax. If you are setting things up fresh, aligning with the v5 style will make it easier to read later.
4. Decide Where Files Live
In Pester, it is conventional to name test files *.Tests.ps1.
The minimal layout looks like this:
scripts/
Get-OldLogFile.ps1
Get-OldLogFile.Tests.ps1
For something a bit larger, separate src and tests:
src/
public/
Get-OldLogFile.ps1
Remove-OldLogFile.ps1
tests/
public/
Get-OldLogFile.Tests.ps1
Remove-OldLogFile.Tests.ps1
Either works. What matters is settling on a convention.
- One test file per function
- Test file names end in
.Tests.ps1 - Load the code under test the same way everywhere
- Don’t mix unit tests and integration tests too freely
At first, placing the target .ps1 next to its .Tests.ps1 is plenty.
5. Run a Minimal Test
First, prepare a simple function, Get-OldLogFile.ps1.
function Get-OldLogFile {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Path,
[int] $Days = 30,
[string] $Filter = '*.log',
[datetime] $Now = (Get-Date)
)
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
throw "Folder not found: $Path"
}
$limit = $Now.AddDays(-1 * $Days)
Get-ChildItem -LiteralPath $Path -Filter $Filter -File |
Where-Object { $_.LastWriteTime -lt $limit } |
Sort-Object -Property LastWriteTime |
Select-Object FullName, Name, Length, LastWriteTime
}
To make testing easier, $Now can be passed in as a parameter.
If the function calls Get-Date directly every time, results vary depending on the day the test runs. With the date as a parameter, you can pin the condition — “files older than 30 days as of June 1, 2026” — and test against it.
Next, write the tests in Get-OldLogFile.Tests.ps1.
BeforeAll {
. $PSScriptRoot\Get-OldLogFile.ps1
}
Describe 'Get-OldLogFile' {
BeforeEach {
$script:Root = Join-Path $TestDrive 'logs'
New-Item -ItemType Directory -Path $script:Root -Force | Out-Null
$oldLog = Join-Path $script:Root 'old.log'
$newLog = Join-Path $script:Root 'new.log'
$oldTxt = Join-Path $script:Root 'old.txt'
Set-Content -LiteralPath $oldLog -Value 'old log' -Encoding UTF8
Set-Content -LiteralPath $newLog -Value 'new log' -Encoding UTF8
Set-Content -LiteralPath $oldTxt -Value 'old text' -Encoding UTF8
(Get-Item -LiteralPath $oldLog).LastWriteTime = [datetime]'2026-05-01T00:00:00'
(Get-Item -LiteralPath $newLog).LastWriteTime = [datetime]'2026-05-31T00:00:00'
(Get-Item -LiteralPath $oldTxt).LastWriteTime = [datetime]'2026-05-01T00:00:00'
}
It 'returns only .log files older than the specified number of days' {
$result = Get-OldLogFile `
-Path $script:Root `
-Days 30 `
-Now ([datetime]'2026-06-01T00:00:00')
$result | Should -HaveCount 1
$result[0].Name | Should -Be 'old.log'
}
It 'fails for a folder that does not exist' {
{ Get-OldLogFile -Path (Join-Path $TestDrive 'missing') } |
Should -Throw
}
}
Run it.
Invoke-Pester -Output Detailed .\Get-OldLogFile.Tests.ps1
The $TestDrive used here is a temporary area Pester provides for tests. Instead of touching the real C:\Logs or a shared folder, you work only with files created inside the test. For PowerShell scripts that involve file operations, getting into the habit of using $TestDrive first is the safe approach.
6. Write Test Names as Specifications
The string you put in Pester’s It is not just a description — for whoever reads it later, it is a small specification document.
For example, a name like this is a bit weak:
It 'works' {
# ...
}
It tells you nothing about what is supposed to work.
In practice, names that include the condition and the expected result are easier to read:
It 'returns only .log files older than the specified number of days' {
# ...
}
It 'does not include files exactly on the cutoff date' {
# ...
}
It 'fails for a folder that does not exist' {
# ...
}
Good test names pay off when a test fails. When the CI log shows this, you immediately know what broke:
[-] Get-OldLogFile.does not include files exactly on the cutoff date
A test name is a note to your future self.
7. Add One Boundary Condition
The Get-OldLogFile above decides whether a file is old using this condition:
$_.LastWriteTime -lt $limit
Because it uses -lt, files whose timestamp exactly equals the cutoff are excluded.
This is a small decision, but it matters in practice. “Older than 30 days” versus “30 days ago or earlier, inclusive” changes the number of files selected.
Add the boundary condition to the tests.
It 'does not include files exactly on the cutoff date' {
$border = Join-Path $script:Root 'border.log'
Set-Content -LiteralPath $border -Value 'border log' -Encoding UTF8
(Get-Item -LiteralPath $border).LastWriteTime = [datetime]'2026-05-02T00:00:00'
$result = Get-OldLogFile `
-Path $script:Root `
-Days 30 `
-Now ([datetime]'2026-06-01T00:00:00')
$result.Name | Should -Not -Contain 'border.log'
}
More tests are not automatically better. But logic with boundaries — dates, numbers, counts, permissions, filename patterns — is where tests deliver the most value.
8. Pin Down the Shape of the Return Value
In PowerShell scripts, the shape of a return value can change without anyone noticing.
At first it returned FileInfo objects directly.
Then someone added Select-Object.
Then column names were changed for CSV output.
Changes like this affect downstream processing, so testing the return value’s properties lets you catch unexpected changes.
It 'returns the properties used by downstream processing' {
$result = Get-OldLogFile `
-Path $script:Root `
-Days 30 `
-Now ([datetime]'2026-06-01T00:00:00')
$propertyNames = $result[0].PSObject.Properties.Name
$propertyNames | Should -Contain 'FullName'
$propertyNames | Should -Contain 'Name'
$propertyNames | Should -Contain 'Length'
$propertyNames | Should -Contain 'LastWriteTime'
}
For functions that feed CSV output or report generation, the column names are part of the specification, not just the values.
Verify not just that “it ran,” but that “it returns the shape the next step expects.”
9. Separate Deletion from Target Selection
Now consider deletion. Start with a bad example.
Get-ChildItem C:\Logs -Filter *.log -File |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
Remove-Item -Force
Short and convenient, but hard to test. Because target selection and deletion are joined in one pipeline, it is unclear where you would verify anything.
In practice, split it like this:
function Remove-OldLogFile {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string] $Path,
[int] $Days = 30,
[datetime] $Now = (Get-Date)
)
$targets = Get-OldLogFile -Path $Path -Days $Days -Now $Now
foreach ($target in $targets) {
if ($PSCmdlet.ShouldProcess($target.FullName, 'Remove old log file')) {
Remove-Item -LiteralPath $target.FullName -Force
}
}
}
Adding SupportsShouldProcess lets the function honor -WhatIf:
Remove-OldLogFile -Path C:\Logs -Days 30 -WhatIf
For PowerShell functions that delete things, it is safer to make a dry run possible with -WhatIf wherever you can.
10. Replace Dangerous Operations with Mock
In Pester, you can use Mock to replace actual command execution.
There is no need to really run Remove-Item in a test of the deletion logic.
Was it called in the situations where it should be? Was it not called in the situations where it must not be?
Checking that is enough.
Here is an example Remove-OldLogFile.Tests.ps1:
BeforeAll {
. $PSScriptRoot\Get-OldLogFile.ps1
. $PSScriptRoot\Remove-OldLogFile.ps1
}
Describe 'Remove-OldLogFile' {
It 'calls Remove-Item for old log files' {
Mock Get-OldLogFile {
[pscustomobject]@{
FullName = 'C:\Logs\old.log'
Name = 'old.log'
Length = 10
LastWriteTime = [datetime]'2026-05-01'
}
}
Mock Remove-Item {}
Remove-OldLogFile `
-Path 'C:\Logs' `
-Days 30 `
-Now ([datetime]'2026-06-01')
Should -Invoke Remove-Item `
-Times 1 `
-Exactly `
-ParameterFilter { $LiteralPath -eq 'C:\Logs\old.log' }
}
It 'does not call Remove-Item with WhatIf' {
Mock Get-OldLogFile {
[pscustomobject]@{
FullName = 'C:\Logs\old.log'
Name = 'old.log'
Length = 10
LastWriteTime = [datetime]'2026-05-01'
}
}
Mock Remove-Item {}
Remove-OldLogFile `
-Path 'C:\Logs' `
-Days 30 `
-Now ([datetime]'2026-06-01') `
-WhatIf
Should -Invoke Remove-Item -Times 0
}
}
In these tests, both Get-OldLogFile and Remove-Item are mocked, so the real C:\Logs\old.log does not need to exist. What is under examination is the decision-making of Remove-OldLogFile:
- If there are targets, call
Remove-Item - With
-WhatIf, do not callRemove-Item - When calling, pass the intended path
The more dangerous an operation, the safer it is to test the conditions under which it is invoked, rather than the execution itself.
11. Don’t Overuse Mock
Mock is handy, but overusing it erodes the value of the tests. Mocking everything pulls you too far away from how PowerShell actually behaves.
Here are rough guidelines:
| Operation | Recommendation |
|---|---|
| Dates | Pin via a parameter |
| File creation | Use $TestDrive |
| Deletion and moves | Verify with Mock and -WhatIf |
| Web API calls | Mock Invoke-RestMethod and the like |
| Email sending and notifications | Mock the sending command |
| Reading and writing CSV | Create small real files in $TestDrive |
If you mock even file reads and writes, you can miss real problems with character encoding, line endings, and column names.
On the other hand, operations like deletion, notification, external APIs, and stopping services are genuinely better not executed.
Separate “where to use the real thing” from “where to mock.”
12. Refactor Existing Scripts to Be Testable
Bringing in Pester changes how existing scripts are written, a little. But you do not need a big redesign up front — fixes at this level are enough to start.
Before
$limit = (Get-Date).AddDays(-30)
Get-ChildItem C:\Logs -Filter *.log -File |
Where-Object { $_.LastWriteTime -lt $limit } |
Remove-Item -Force
After
function Get-OldLogFile {
param(
[string] $Path,
[int] $Days = 30,
[datetime] $Now = (Get-Date)
)
$limit = $Now.AddDays(-1 * $Days)
Get-ChildItem -LiteralPath $Path -Filter *.log -File |
Where-Object { $_.LastWriteTime -lt $limit }
}
function Remove-OldLogFile {
[CmdletBinding(SupportsShouldProcess)]
param(
[string] $Path,
[int] $Days = 30,
[datetime] $Now = (Get-Date)
)
Get-OldLogFile -Path $Path -Days $Days -Now $Now |
ForEach-Object {
if ($PSCmdlet.ShouldProcess($_.FullName, 'Remove old log file')) {
Remove-Item -LiteralPath $_.FullName -Force
}
}
}
The changes are not large.
- Made the date a parameter
- Extracted target selection into a function
- Moved deletion into a separate function
- Added
SupportsShouldProcess
That alone makes the code far easier to test.
In building up PowerShell tests, it is more effective to make “dates,” “paths,” “external commands,” and “mutating operations” replaceable from outside than to start from design theory.
13. Decide on Test Categories
Pester lets you tag Describe, Context, and It blocks.
For example, separate fast unit tests from integration tests that touch the real environment.
Describe 'Get-OldLogFile' -Tag 'Unit' {
It 'returns only .log files older than the specified number of days' {
# Fast test using TestDrive
}
}
Describe 'Log maintenance smoke test' -Tag 'Smoke' {
It 'can read the real log folder' {
Test-Path -LiteralPath 'C:\Logs' | Should -BeTrue
}
}
Run only the unit tests:
Invoke-Pester -TagFilter Unit
Exclude slow or environment-dependent tests:
Invoke-Pester -ExcludeTagFilter Slow, RequiresAdmin, Network
In real teams, trying to run every test every time often doesn’t stick.
Start by making the fast, side-effect-free tests the default, and split off environment-dependent tests with tags to run when actually needed.
A table by script type
“How much of this script belongs in unit tests, where do integration tests start, and what do I use at each level?” is largely determined by the type of script.
| Script type | What unit tests examine | What to use | What integration tests examine |
|---|---|---|---|
| File collection and target selection | The boundaries of the condition. How many days back are included, whether the extension filter really narrows the set | Create small real files in $TestDrive |
Whether the count against the real folder matches expectations |
| CSV and JSON input/output | Column names, column order, character encoding, line endings | Read and write real files in $TestDrive |
Whether the receiving system can actually open the file |
| Deletion, moves, overwrites | The selected deletion targets, and the dry run under -WhatIf |
Mock Remove-Item / Mock Move-Item |
Run once in a staging environment and check the result |
| Service and process operations | The decision logic that picks the next operation based on state | Build states with Mock Get-Service and the like |
Actually start and stop services on a test machine |
| External APIs, notifications, email | The contents of the request or message body you assemble | Mock Invoke-RestMethod / Mock Send-MailMessage |
Send once to a staging destination |
| Logic that decides by date or period | Boundary-date decisions. Same day, previous day, leap day | Pin the date with a parameter (do not Mock it) |
— |
| Reading the registry or system settings | How the value you read is interpreted | Mock Get-ItemProperty |
Check the real value on the actual target machine |
There are two ways to read this table.
- Unit tests examine your own decisions. They do not verify that the standard commands work correctly (section 19).
- Do not execute dangerous operations in unit tests. Replace deletion, moves, sending, and notification with
Mock, and confirm the execution itself in integration tests, kept to a small number of runs.
Note that dates alone are pinned with a parameter rather than with Mock. Mocking Get-Date also catches every other use of the current date inside the same script.
14. Run in CI
Pester is useful even when run locally, but if your team manages scripts together, being able to run it in CI gives extra peace of mind.
For example, prepare a file like tools/Invoke-ProjectTests.ps1:
$ErrorActionPreference = 'Stop'
# An old Pester may still be present in the environment, so require v5 or later explicitly
Import-Module Pester -MinimumVersion 5.0.0
$config = New-PesterConfiguration
$config.Run.Path = @(
Join-Path $PSScriptRoot '..\tests'
)
$config.Run.Exit = $true
$config.Output.Verbosity = 'Detailed'
$config.TestResult.Enabled = $true
$config.TestResult.OutputFormat = 'JUnitXml'
$config.TestResult.OutputPath = Join-Path $PSScriptRoot '..\test-results.xml'
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = @(
Join-Path $PSScriptRoot '..\src'
)
$config.CodeCoverage.OutputPath = Join-Path $PSScriptRoot '..\coverage.xml'
Invoke-Pester -Configuration $config
On the CI side, run this script:
pwsh -NoProfile -File .\tools\Invoke-ProjectTests.ps1
The point is to avoid writing too much CI-specific configuration inside the test files themselves.
Test files are where you write specifications. Output format for CI, coverage, exit codes, and so on are easier to keep organized in the execution script.
Once this execution script exists, the configuration on the CI side stays short whichever service you use.
GitHub Actions
Add .github/workflows/pester.yml.
name: pester
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Pester v5
shell: pwsh
run: |
Install-Module -Name Pester -MinimumVersion 5.0.0 `
-Scope CurrentUser -Force -SkipPublisherCheck
- name: Run Pester
shell: pwsh
run: ./tools/Invoke-ProjectTests.ps1
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: pester-results
path: |
test-results.xml
coverage.xml
The points worth remembering:
- Install Pester explicitly. Windows ships with the Pester v3 series, so unless you install it again, v5 syntax will not run.
- Add
-SkipPublisherCheck. The bundled Pester carries a Microsoft signature, so without this switch the installation stops on the grounds that the publisher differs. - State
shell: pwshexplicitly. On GitHub-hosted Windows runners the default ispwsh, but on a self-hosted runner without PowerShell 7 it falls back to Windows PowerShell. Being explicit saves you from puzzling over environment differences. - Collect the result files with
if: always(). A failing test is exactly when you want to look at the results, so make the step run on failure too.
What makes the job fail is $config.Run.Exit = $true. With it, Invoke-Pester exits with a non-zero exit code when a test fails, and the step fails too. Forget it, and the job goes green even when the tests are red.
Azure Pipelines
azure-pipelines.yml looks like this:
trigger:
- main
pool:
vmImage: 'windows-latest'
steps:
- task: PowerShell@2
displayName: 'Install Pester v5'
inputs:
pwsh: true
targetType: 'inline'
script: |
Install-Module -Name Pester -MinimumVersion 5.0.0 `
-Scope CurrentUser -Force -SkipPublisherCheck
- task: PowerShell@2
displayName: 'Run Pester'
inputs:
pwsh: true
filePath: 'tools/Invoke-ProjectTests.ps1'
- task: PublishTestResults@2
displayName: 'Publish test results'
condition: always()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: 'test-results.xml'
failTaskOnFailedTests: true
The formats PublishTestResults@2 accepts are JUnit, NUnit, VSTest, XUnit, and CTest. Because the execution script sets $config.TestResult.OutputFormat = 'JUnitXml', JUnit is the right choice here. If you change the output format, change both sides together.
With failTaskOnFailedTests: true, the task fails as soon as the result file contains a failure. The default is false, in which case the results are merely displayed and the task passes anyway.
15. Treat Coverage as a Map, Not a Target
Pester can produce code coverage too. But it is better not to chase the number too hard at first, because coverage is not the same thing as test quality.
For example, calling the deletion-target selection function once marks those lines as covered. But if the boundary conditions and exclusion conditions have not been verified, that gives you no real-world assurance.
Use coverage like this:
- Find functions that are not exercised at all
- Find important branches with no tests
- Prioritize starting from the scripts that change most often
- Keep evidence of test execution in CI
Rather than raising the number, look at “are the important decisions being tested?”
Collection is configured on the CodeCoverage side of New-PesterConfiguration.
$config = New-PesterConfiguration
$config.Run.Path = @('.\tests')
$config.CodeCoverage.Enabled = $true
# What to measure. Point at the scripts under test, not at the test files
$config.CodeCoverage.Path = @('.\src')
# Output format. The default is JaCoCo, which CI coverage displays ingest easily
$config.CodeCoverage.OutputFormat = 'JaCoCo'
$config.CodeCoverage.OutputPath = '.\coverage.xml'
# Target value. Falling below it does not fail the tests, so treat it as a rough guide
$config.CodeCoverage.CoveragePercentTarget = 75
Invoke-Pester -Configuration $config
The CodeCoverage.Path you specify is the scripts under test, not the test files. Point it at .\tests and you end up measuring the coverage of the test code itself, which produces a high number with nothing behind it.
The default output format is JaCoCo. Choosing CoverageGutters gives you a format that shows line-by-line coverage inside the editor. If you are feeding CI, leaving it on JaCoCo is fine.
CoveragePercentTarget is a guideline number, not a gate: falling below it does not make the run fail. The moment you feel tempted to turn coverage into a pass/fail condition is the moment to reread the opening of this section. Tests written to satisfy a number usually tell you nothing when something breaks.
16. An Order for Introducing Tests to Existing Scripts
When introducing Pester to an existing PowerShell codebase, it is easier not to put everything under test at once. Here is a recommended order.
1. Pick the scripts whose failure would hurt
The best first candidates look like this:
- They delete, move, or overwrite things
- They run daily or monthly
- They are in the runbook but only one person understands them
- They have had condition mistakes in the past
- Their output CSV is used by other business processes
Start with the things that are useful but would hurt if they broke.
2. Extract only the read-side logic into functions
The first thing to test is not the mutating logic but the reading logic.
Read the logs
Filter the targets
Count the items
Shape the data for CSV
This part is easy to test with $TestDrive, and little can go wrong.
3. Pass dates and paths in from outside
Hard-coded dates and paths make testing difficult.
# Avoid this
$root = 'C:\Logs'
$limit = (Get-Date).AddDays(-30)
A testable shape looks like this:
param(
[string] $Path,
[datetime] $Now = (Get-Date)
)
Just being able to pass values in from outside dramatically improves test stability.
4. Put the dangerous operations last
Group deletions and moves at the end.
Build the target list
↓
Record the targets in a log
↓
Verify with -WhatIf
↓
Execute
Verify in the same order in your tests.
17. Common Stumbling Blocks
| Symptom | Cause | Fix |
|---|---|---|
| Passes locally but fails in CI | Different current directory | Base paths on $PSScriptRoot |
| Results vary by day | Calling Get-Date directly |
Provide a parameter like -Now |
| Tests nearly delete real files | Using real folders | Use $TestDrive and Mock |
| Mock has no effect | Module boundary or scope mismatch | Check -ModuleName and how the code is loaded |
| Unclear how far to test | Logic is not split into functions | Split into target selection, shaping, and mutation |
| Tests are slow | Touching external services or the network | Mock external dependencies in unit tests |
| Test names tell you nothing | Names like It 'works' |
Put the condition and expected result in the name |
What looks like a Pester problem is often actually caused by the structure of the script.
The parts that are hard to test are usually the parts that break in operation too.
Getting stuck on Install-Module (common on corporate networks)
The Install-Module in section 3 goes through as written if the machine can reach the internet directly. On a corporate machine managed by IT, however, this is a frequent stopping point.
| Symptom | Cause | Fix |
|---|---|---|
| Cannot connect to the PowerShell Gallery | Traffic is not going through the corporate proxy | Specify -Proxy and -ProxyCredential |
| Fails with something like “The underlying connection was closed” | TLS 1.2 is not among the default protocols in Windows PowerShell 5.1 | Enable TLS 1.2 for the session before running the command |
| Stops with a prompt to install the NuGet provider | Still on PowerShellGet 1.0.0.1, the version bundled with Windows PowerShell | Install the NuGet provider first |
| No outbound access at all | Closed network | Run Save-Module on another machine and carry the folder in, or register an internal repository with Register-PSRepository |
The first three usually go through when you run these steps in this order.
# 1. Enable TLS 1.2 (needed on Windows PowerShell 5.1, unnecessary on PowerShell 7)
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor
[Net.SecurityProtocolType]::Tls12
# 2. Install the NuGet provider (install it up front so the interactive prompt does not block the run)
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force
# 3. Install Pester through the proxy
$proxyUri = 'http://proxy.example.local:8080'
$proxyCredential = Get-Credential -Message 'Proxy authentication'
Install-Module -Name Pester -MinimumVersion 5.0.0 `
-Scope CurrentUser -Force -SkipPublisherCheck `
-Proxy $proxyUri -ProxyCredential $proxyCredential
The TLS 1.2 setting applies only to that session. If retyping it every time is a nuisance, put it in your profile script.
If the proxy does not require authentication, -ProxyCredential can be omitted. If you do not know the proxy URL, ask IT for “the proxy settings for reaching the PowerShell Gallery (www.powershellgallery.com).”
On a closed network with no outbound access, run the following on a machine that does have internet access and carry the resulting folder in as is.
# On the machine with internet access
Save-Module -Name Pester -MinimumVersion 5.0.0 -Path 'D:\modules'
On the receiving machine, place D:\modules\Pester into a module search path such as $env:USERPROFILE\Documents\WindowsPowerShell\Modules. You can check the available locations with $env:PSModulePath.
Documenting this procedure up front makes onboarding much faster as the team grows. “Pester will not install” really is a common reason that test maintenance never gets started.
18. Rules Worth Settling for Test Maintenance
If a team manages PowerShell scripts together, settle on rules before fussing over fine details of style.
For example:
- Test files are named
*.Tests.ps1 - Code under test is loaded relative to
$PSScriptRoot - File operation tests use
$TestDrive - Deletion, moves, notifications, and API calls are mocked by default
- Dates are parameterized so they can be pinned
DescribeorItblocks carry tags likeUnit,Smoke,RequiresAdmin- CI runs
Unitby default - Mutating functions get
SupportsShouldProcesswherever possible - Past defects are preserved as regression tests
Too many rules and they won’t be followed. At first, even just these three are enough:
Use TestDrive
Pin the dates
Mock the dangerous operations
Following just these three makes PowerShell testing considerably more stable.
19. Decide What Not to Test, Too
In building up tests, “what not to test” matters as much as “what to test.”
For example, these are things you should not strain to verify in unit tests:
- That Windows’s own
Get-ChildItemworks correctly - That
Remove-Itemreally deletes files - The internal behavior of standard PowerShell commands
- That an external API always responds
- That a network share is always available
What you should test is your own decisions:
- Under which conditions something becomes a target
- Which paths get passed
- Which columns get output
- How failures are handled
- Whether dangerous operations can be dry-run
Separate the places where you trust the standard commands from the places where you protect your own logic.
20. Conclusion
PowerShell is a convenient tool for quickly automating everyday work. But scripts that stay in production for a long time gradually accumulate responsibility. What began as a one-liner only you used eventually becomes an operations job that runs every day and affects other people’s work and business data.
Building up tests with Pester is the work of protecting your scripts as that shift happens.
The key points:
- Test target selection first
- Make dates and paths passable from outside
- Confine file operations to
$TestDrive Mockdeletion, moves, notifications, and API calls- Make mutating functions dry-runnable with
-WhatIf - Write test names that read as specifications
- In CI, start with the fast, side-effect-free tests
Safe PowerShell operation does not come from installing some big mechanism all at once.
Split into small functions. Write small tests. Make it possible to verify before the dangerous step.
Through that accumulation, PowerShell moves from “a convenient but slightly scary script” toward “a business tool you can verify after every change.”
Reference Links
- The complete sample code for this article (the script under test, the Pester tests, and a CI execution script) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/pester-powershell-test-maintenance
- Pester Quick Start
- Pester Installation and Update
- Pester File placement and naming
- Pester TestDrive
- Pester Mocking
- Pester Tags
- Pester Configuration
- Pester Test Results
- Pester Code Coverage
- PowerShell Gallery: Pester
- PowerShell Documentation - Microsoft Learn
- Install a package manager for PowerShell (TLS 1.2 and the NuGet provider) - Microsoft Learn
- Install-Module (-Proxy / -ProxyCredential) - Microsoft Learn
- PublishTestResults@2 - Azure Pipelines task reference
- Workflow syntax for GitHub Actions (jobs.<job_id>.steps[*].shell)
Related Articles
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Calling COM and .NET from PowerShell — Widening What Your Scripts Can Reach
A practical guide to calling .NET classes from PowerShell, embedding C# and Win32 APIs with Add-Type, driving COM, cleaning up leftover E...
Parameter Design and Modularization for PowerShell Scripts — From a Script That Works to a Script You Can Hand Over
A step-by-step procedure for raising a PowerShell script to a quality you can hand to someone else. Covers the param block and [CmdletBin...
The Differences Between Windows PowerShell 5.1 and PowerShell 7 — A Practical Guide to Migrating In-House Scripts
The relationship between Windows PowerShell 5.1 and PowerShell 7 (side-by-side coexistence and pwsh.exe), Microsoft's official position t...
Automating Excel and CSV Work with PowerShell — Practical Recipes for Aggregation, Reconciliation, and Report Output
Practical recipes for automating CSV aggregation, reconciliation, and Excel report output with PowerShell. Covers the default encodings o...
How to Run PowerShell from C# (CSharp) and Receive the Results as Objects
How to launch PowerShell from C# and receive results as PSObject rather than strings — a practical walkthrough of the PowerShell SDK, Add...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Frequently Asked Questions
Common questions about the topic of this article.
- What should I test first with Pester?
- Start with the logic that picks what to delete (target selection), not the deletion itself. In operations scripts, prioritizing these four areas tends to pay off: condition logic, the shape of the output, the step just before a dangerous operation, and external dependencies. You do not have to overhaul an existing script all at once. Begin by extracting the decision logic that sits in front of the dangerous operation into a function, and verifying its return value with Pester.
- What is TestDrive in Pester?
- $TestDrive is a temporary area that Pester provides for tests. Instead of touching the real C:\Logs or a shared folder, you can verify file operations against files created only inside the test. When you test PowerShell scripts that touch files, getting into the habit of reaching for $TestDrive first keeps you from breaking something real, such as deleting live files by mistake.
- How far should I go with Pester's Mock?
- Replace operations that must not actually run — deletion, moves, Web API calls, email and notifications — with Mock; pin dates by passing them in as a parameter; and for file creation or reading and writing CSV, create small real files in $TestDrive instead. Mocking everything drifts too far from how PowerShell actually behaves and can hide problems with character encoding, line endings, and column names, so the point is to separate where you use the real thing from where you mock.
- Why do Pester tests that pass locally fail in CI?
- The most common cause is a different current directory, which you can fix by loading the code under test relative to $PSScriptRoot. If results vary from day to day, the function is calling Get-Date directly, so pin the date with a parameter such as -Now. If a Mock has no effect, suspect a module boundary or a scope mismatch and check -ModuleName and how the code is loaded. What looks like a Pester problem is often really the structure of the script.