PesterによるPowerShellのテスト整備 ── 運用スクリプトを壊しにくくする実務の型

· · PowerShell, Pester, Windows, テスト, 自動化, CI, 既存資産活用

1. 最初に押さえるべきこと

PowerShell スクリプトは、最初は小さな作業の自動化として始まります。

ファイルを集める。 ログを検索する。 CSV を作る。 古いファイルを移動する。 サービスの状態を確認する。

どれも数十行なら、目で見て動作確認できます。

しかし、実務で使い続けているうちに、次のような変更が入ります。

  • 対象フォルダーを増やす
  • 除外条件を追加する
  • CSV の列を変える
  • 削除前にアーカイブする
  • タスクスケジューラや CI から実行する
  • エラー時に通知する

この段階になると、「一度手元で動いた」だけでは足りません。

PowerShell の怖さは、便利さと表裏一体です。 読み取りだけなら気軽に試せますが、削除、移動、上書き、サービス再起動、権限変更のような処理は、少しの条件ミスが事故につながります。

そこで使いたいのが Pester です。

Pester は PowerShell 用のテストフレームワークです。この記事では、Pester の機能を網羅するのではなく、すでにある PowerShell スクリプトを実務で壊しにくくするための「テスト整備」の進め方を整理します。

PowerShell のテストは、きれいなコードを書くためだけのものではありません。変更前に不安を減らし、変更後に根拠を持って確認するための道具です。

2. Pesterで何を守るのか

Pester を入れると、すべてが自動で安全になるわけではありません。

まず決めるべきなのは、「何をテストで守るか」です。

PowerShell の運用スクリプトでは、次の4つを優先すると効果が出やすいです。

守りたいこと テストで見る内容
条件判定 どのファイル、行、ユーザー、サービスを対象にするか
出力の形 CSV に出す列名、戻り値のプロパティ、件数
危険操作の手前 削除・移動・停止の対象が意図通りか
外部依存 ファイルシステム、API、コマンド実行、日時、環境変数の扱い

特に最初にテストしたいのは、削除処理そのものではありません。

削除対象を選ぶ処理です。

たとえば、古いログを削除するスクリプトなら、いきなり Remove-Item をテストするのではなく、先に「どのログが対象として選ばれるか」をテストします。

この分け方をすると、テストしやすくなります。

対象を集める関数
  ↓
対象を確認・記録する処理
  ↓
移動・削除・通知などの変更処理

PowerShell のテスト整備は、既存スクリプトをいきなり大改造することではありません。

まずは、危険な処理の前にある判断部分を関数として切り出し、その戻り値を Pester で確認します。

3. バージョンをそろえる

この記事では Pester v5 を前提にします。

古い Windows 環境では、あらかじめ Pester が入っていても v3 系であることがあります。既存環境に入っているものをそのまま使うのではなく、まずバージョンを確認します。

Get-Module Pester -ListAvailable |
  Sort-Object Version -Descending |
  Select-Object Name, Version, Path

新しく入れる場合は、PowerShell Gallery からインストールします。

Install-Module -Name Pester -Scope CurrentUser -Force -SkipPublisherCheck
Import-Module Pester
Get-Module Pester

チームで使う場合は、手元の開発端末、ビルドサーバー、タスク実行環境で Pester のバージョンがずれていないかを確認します。

PowerShell のテストでよくある混乱は、コードの問題ではなく、テストランナーのバージョン差から起きます。

特に、古い記事や社内メモには Pester v4 以前の書き方が残っていることがあります。新しく整備するなら、v5 の書き方に寄せた方が後で読みやすくなります。

4. ファイルの置き場所を決める

Pester では、テストファイルを *.Tests.ps1 という名前にするのが一般的です。

最小構成は次のようになります。

scripts/
  Get-OldLogFile.ps1
  Get-OldLogFile.Tests.ps1

もう少し規模が大きい場合は、srctests を分けます。

src/
  public/
    Get-OldLogFile.ps1
    Remove-OldLogFile.ps1

tests/
  public/
    Get-OldLogFile.Tests.ps1
    Remove-OldLogFile.Tests.ps1

どちらでも構いません。

大事なのは、規則を決めることです。

  • 1つの関数に対して1つのテストファイルを置く
  • テストファイル名には .Tests.ps1 を付ける
  • テスト対象の読み込み方をそろえる
  • 単体テストと結合テストを混ぜすぎない

最初は、対象の .ps1 とテストの .Tests.ps1 を隣に置く形で十分です。

5. 最小のテストを動かす

まず、単純な関数を用意します。

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
}

ここでは、$Now を引数として受け取れるようにしています。

これはテストしやすくするためです。

関数の中で毎回 Get-Date を直接使うと、テスト実行日によって結果が変わります。日付を引数にしておけば、「2026年6月1日時点で30日より古いファイル」という条件を固定してテストできます。

次にテストを書きます。

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 '指定日数より古い .log ファイルだけを返す' {
        $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 '存在しないフォルダーでは失敗する' {
        { Get-OldLogFile -Path (Join-Path $TestDrive 'missing') } |
            Should -Throw
    }
}

実行します。

Invoke-Pester -Output Detailed .\Get-OldLogFile.Tests.ps1

ここで使っている $TestDrive は、Pester が用意するテスト用の一時領域です。

本物の C:\Logs や共有フォルダーを使わずに、テストの中だけで作ったファイルを使えます。ファイル操作を伴う PowerShell スクリプトでは、まず $TestDrive を使う習慣をつけると安全です。

6. テスト名は仕様として書く

Pester の It に書く文字列は、単なる説明ではありません。

後から読む人にとっては、小さな仕様書になります。

たとえば、次の名前は少し弱いです。

It 'works' {
    # ...
}

何が動けばよいのか分かりません。

実務では、次のように条件と期待結果を入れる方が読みやすくなります。

It '指定日数より古い .log ファイルだけを返す' {
    # ...
}

It 'ちょうど期限日のファイルは対象にしない' {
    # ...
}

It '存在しないフォルダーでは失敗する' {
    # ...
}

良いテスト名は、失敗したときに効きます。

CI のログに次のように出たとき、何が壊れたのかすぐ分かります。

[-] Get-OldLogFile.ちょうど期限日のファイルは対象にしない

テスト名は、未来の自分へのメモです。

7. 境界条件を1つ足す

先ほどの Get-OldLogFile は、次の条件で古いファイルを判定しています。

$_.LastWriteTime -lt $limit

-lt なので、期限日と同じ日時のファイルは対象外です。

この判断は小さいですが、実務では重要です。

「30日より古い」なのか、「30日前を含む」なのかで対象件数が変わるからです。

境界条件をテストに追加します。

It 'ちょうど期限日のファイルは対象にしない' {
    $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'
}

テストは、たくさん書けばよいものではありません。

しかし、日付、数値、件数、権限、ファイル名のパターンのように、境界がある処理はテストの価値が高いです。

8. 戻り値の形を固定する

PowerShell スクリプトでは、戻り値の形がいつの間にか変わることがあります。

最初は FileInfo をそのまま返していた。 途中で Select-Object を入れた。 さらに CSV 用に列名を変えた。

このような変更は、後続処理に影響します。

戻り値のプロパティをテストしておくと、予期しない変更に気づけます。

It '後続処理で使うプロパティを返す' {
    $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'
}

CSV 出力やレポート作成に渡す関数では、値だけでなく列名も仕様です。

「動いた」だけでなく、「次の処理が期待する形で返っている」ことを確認します。

9. 削除処理は対象選定と分ける

次に、削除処理を考えます。

悪い例から見ます。

Get-ChildItem C:\Logs -Filter *.log -File |
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } |
    Remove-Item -Force

短くて便利ですが、テストしにくいです。

対象選定と削除が1本のパイプラインでつながっているため、どこを確認すればよいか分かりにくくなります。

実務では、次のように分けます。

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

ここでは SupportsShouldProcess を付けています。

これにより、関数側で -WhatIf を受けられるようになります。

Remove-OldLogFile -Path C:\Logs -Days 30 -WhatIf

削除系の PowerShell 関数では、可能な限り -WhatIf で予行できる形にしておく方が安全です。

10. Mockで危険操作を置き換える

Pester では Mock を使って、実際のコマンド実行を置き換えられます。

削除処理のテストで本当に Remove-Item を実行する必要はありません。

呼ばれるべき場面で呼ばれたか。 呼ばれてはいけない場面で呼ばれていないか。

そこを見れば十分です。

Remove-OldLogFile.Tests.ps1 の例です。

BeforeAll {
    . $PSScriptRoot\Get-OldLogFile.ps1
    . $PSScriptRoot\Remove-OldLogFile.ps1
}

Describe 'Remove-OldLogFile' {
    It '古いログファイルに対して Remove-Item を呼ぶ' {
        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 'WhatIf では Remove-Item を呼ばない' {
        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
    }
}

このテストでは、本物の C:\Logs\old.log は存在しなくても構いません。

Get-OldLogFileRemove-Item もモックしています。

見ているのは、Remove-OldLogFile の判断です。

  • 対象があれば Remove-Item を呼ぶ
  • -WhatIf のときは Remove-Item を呼ばない
  • 呼ぶときは意図したパスを渡す

危険な処理ほど、実行そのものではなく、呼び出し条件をテストする方が安全です。

11. Mockを使いすぎない

Mock は便利ですが、使いすぎるとテストの価値が落ちます。

すべてをモックすると、実際の PowerShell の動きから離れすぎるからです。

目安は次の通りです。

処理 おすすめ
日付 引数で固定する
ファイル作成 $TestDrive を使う
削除・移動 Mock-WhatIf で確認する
Web API 呼び出し Invoke-RestMethod などを Mock する
メール送信・通知 送信コマンドを Mock する
CSV の読み書き 小さな実ファイルを $TestDrive に作る

ファイルの読み書きまで全部モックすると、実際の文字コード、改行、列名の問題を見逃すことがあります。

一方で、削除、通知、外部 API、サービス停止のような処理は、本当に実行しない方がよいです。

「本物を使う場所」と「モックする場所」を分けます。

12. 既存スクリプトをテストしやすく直す

Pester を入れると、既存スクリプトの書き方も少し変わります。

ただし、最初から大きな設計変更は不要です。

まずは次のように直します。

直す前

$limit = (Get-Date).AddDays(-30)

Get-ChildItem C:\Logs -Filter *.log -File |
    Where-Object { $_.LastWriteTime -lt $limit } |
    Remove-Item -Force

直した後

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

変更点は大きくありません。

  • 日付を引数にした
  • 対象選定を関数にした
  • 削除処理を別関数にした
  • SupportsShouldProcess を付けた

これだけでテストしやすくなります。

PowerShell のテスト整備では、設計論から入るよりも、「日付」「パス」「外部コマンド」「変更操作」を外から差し替えられるようにする方が効果的です。

13. テストの分類を決める

Pester では DescribeContextIt にタグを付けられます。

たとえば、速い単体テストと、実際の環境に触る結合テストを分けます。

Describe 'Get-OldLogFile' -Tag 'Unit' {
    It '指定日数より古い .log ファイルだけを返す' {
        # TestDrive を使う速いテスト
    }
}

Describe 'Log maintenance smoke test' -Tag 'Smoke' {
    It '実際のログフォルダーを読み取れる' {
        Test-Path -LiteralPath 'C:\Logs' | Should -BeTrue
    }
}

単体テストだけ実行します。

Invoke-Pester -TagFilter Unit

遅いテストや環境依存のテストを除外します。

Invoke-Pester -ExcludeTagFilter Slow, RequiresAdmin, Network

現場では、すべてのテストを毎回実行しようとすると続かないことがあります。

まずは、速くて副作用のないテストを標準にします。

環境依存のテストはタグで分け、必要なタイミングで実行します。

14. CIで動かす

Pester は手元で実行するだけでも役に立ちます。

ただし、チームでスクリプトを管理するなら、CI で実行できるようにしておくと安心です。

たとえば、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

CI 側では、このスクリプトを実行します。

pwsh -NoProfile -File .\tools\Invoke-ProjectTests.ps1

ポイントは、CI 固有の設定をテストファイルの中に書きすぎないことです。

テストファイルは仕様を書く場所です。 CI 用の出力形式、カバレッジ、終了コードなどは、実行用スクリプトにまとめると整理しやすくなります。

15. カバレッジは目標ではなく地図として見る

Pester ではコードカバレッジも出せます。

ただし、最初からカバレッジの数字を追いかけすぎない方がよいです。

カバレッジは、テストの品質そのものではありません。

たとえば、削除対象の抽出関数を1回呼べば、その行は通ったことになります。しかし、境界条件や除外条件が確認されていなければ、実務上の安心にはつながりません。

カバレッジは、次のように使います。

  • まったく通っていない関数を見つける
  • 重要なのにテストがない分岐を見つける
  • 変更頻度の高いスクリプトから優先順位をつける
  • CI でテスト実行の証跡を残す

数字を上げることよりも、「大事な判断がテストされているか」を見ます。

16. 既存スクリプトへの導入順序

既存の PowerShell 資産に Pester を入れる場合、いきなり全体をテスト対象にしない方が進めやすいです。

おすすめの順番です。

1. 失敗すると困るスクリプトを選ぶ

最初は、次のようなスクリプトが向いています。

  • 削除、移動、上書きを含む
  • 毎日または毎月動く
  • 手順書に残っているが属人化している
  • 過去に条件ミスがあった
  • 出力 CSV が他の業務に使われている

便利だけれど壊れると困るものから始めます。

2. 読み取り部分だけを関数に切り出す

最初にテストするのは、変更処理ではなく読み取り処理です。

ログを読む
対象を絞る
件数を数える
CSV 用の形に整える

この部分は $TestDrive でテストしやすく、事故も起きにくいです。

3. 日付とパスを外から渡す

日付やパスが固定されていると、テストしにくくなります。

# 避けたい
$root = 'C:\Logs'
$limit = (Get-Date).AddDays(-30)

テストしやすい形です。

param(
    [string] $Path,
    [datetime] $Now = (Get-Date)
)

値を外から渡せるだけで、テストの安定性が大きく上がります。

4. 危険操作を最後にする

削除や移動は、最後にまとめます。

対象を作る
  ↓
対象をログに残す
  ↓
-WhatIf で確認する
  ↓
実行する

テストでも同じ順番で確認します。

17. よくあるつまずき

症状 原因 対処
手元では通るが CI で落ちる カレントディレクトリが違う $PSScriptRoot を基準にする
日によって結果が変わる Get-Date を直接使っている -Now のような引数を用意する
テストで本物のファイルを消しそうになる 実フォルダーを使っている $TestDriveMock を使う
Mock が効かない モジュール境界やスコープが違う -ModuleName や読み込み方法を確認する
どこまでテストすべきか分からない 仕様が関数に分かれていない 対象選定、整形、変更処理に分ける
テストが遅い 外部サービスやネットワークに触っている 単体テストでは外部依存を Mock する
テスト名を見ても分からない It 'works' のような名前になっている 条件と期待結果を名前に入れる

Pester の問題に見えて、実際にはスクリプトの構造が原因になっていることも多いです。

テストしにくい部分は、だいたい運用でも壊れやすい部分です。

18. テスト整備で決めておきたいルール

チームで PowerShell スクリプトを管理するなら、細かい書き方よりも先にルールを決めます。

たとえば次のようなルールです。

  • テストファイルは *.Tests.ps1 にする
  • テスト対象は $PSScriptRoot から読み込む
  • ファイル操作のテストは $TestDrive を使う
  • 削除、移動、通知、API 呼び出しは原則 Mock する
  • 日付は固定できるように引数化する
  • Describe または ItUnitSmokeRequiresAdmin などのタグを付ける
  • CI では Unit を標準実行する
  • 変更系関数には可能な限り SupportsShouldProcess を付ける
  • 過去の不具合は再発防止テストとして残す

ルールは多すぎると守られません。

最初は、次の3つだけでも十分です。

TestDrive を使う
日付を固定する
危険操作は Mock する

この3つを守るだけで、PowerShell のテストはかなり安定します。

19. どこまでテストしないかも決める

テスト整備では、「何をテストするか」と同じくらい「何をテストしないか」も重要です。

たとえば、次のようなものは単体テストで無理に確認しすぎない方がよいです。

  • Windows 自体の Get-ChildItem が正しく動くこと
  • Remove-Item が本当にファイルを削除すること
  • PowerShell の標準コマンドの内部仕様
  • 社外 API が常に応答すること
  • ネットワーク共有が常に使えること

テストすべきなのは、自分たちの判断です。

  • どの条件で対象にするか
  • どのパスを渡すか
  • どの列を出力するか
  • 失敗時にどう扱うか
  • 危険操作を予行できるか

標準コマンドを信じるところと、自分たちのロジックを守るところを分けます。

20. まとめ

PowerShell は、日常作業をすばやく自動化できる便利な道具です。

しかし、実務で長く使うスクリプトは、少しずつ責任が重くなります。

最初は自分だけが使う1行コマンドだったものが、やがて毎日動く運用処理になり、他の人の作業や業務データに影響するようになります。

Pester によるテスト整備は、その変化に合わせてスクリプトを守るための作業です。

ポイントは次の通りです。

  • まず対象選定をテストする
  • 日付やパスを外から渡せるようにする
  • ファイル操作は $TestDrive に閉じ込める
  • 削除、移動、通知、API 呼び出しは Mock する
  • 変更系関数は -WhatIf で予行できるようにする
  • テスト名を仕様として読めるようにする
  • CI では速くて副作用のないテストから回す

PowerShell の安全な運用は、いきなり大きな仕組みを入れることではありません。

小さな関数に分ける。 小さなテストを書く。 危険な処理の前に確認できる形にする。

この積み重ねで、PowerShell は「便利だけれど少し怖いスクリプト」から、「変更しても確認できる業務ツール」に近づきます。

参考リンク

関連する記事

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

VBScript非推奨化に備える移行ガイド

Windows の VBScript 段階廃止に向け、VBA や Excel マクロ、社内ツールの依存を棚卸しし、静的検出と実行ログで隠れた呼び出しを洗い出す手順、PowerShell や Office Scripts などへの代替選定、署名運用と段階展開までを実務目線で整...

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る