Testing PowerShell with Pester — A Practical Approach to Making Operations Scripts Harder to Break

· · PowerShell, Pester, Windows, Testing, Automation, CI, Legacy Asset Reuse

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 an 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)

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/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

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 call Remove-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.

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'

Import-Module Pester

$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.

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?”

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 accidents are unlikely.

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.

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
  • Describe or It blocks carry tags like Unit, Smoke, RequiresAdmin
  • CI runs Unit by default
  • Mutating functions get SupportsShouldProcess wherever 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-ChildItem works correctly
  • That Remove-Item really 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
  • Mock deletion, 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.”

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog