Pester の使い方｜実装編

[Pester の使い方｜基礎編](https://qiita.com/nimzo6689/items/65948bbfcda1f775a91f) に引き続き、Pester の解説です。
今回は、実際にテストコードを実装していくときに有用な機能の解説をしていきます。

# 1. アサーション

アサーションとは、テストコードの結果が想定通りの値かを検証するための機能です。

Pester では `Should` コマンドレットを使用します。パイプラインから渡して検証することがほとんどです。
`Should` のオプションを指定することにより、様々なアサーションの種類を利用することができます。

以下、実装例です。

```powershell:Get-YuruCharaInfo.Tests.ps1
BeforeAll {
    function Get-YuruCharaInfo($prefecture) {

        switch ($prefecture) {
            '愛媛' {
                @{
                    Name        = 'みきゃん'
                    Motif       = @('ミカン', '犬')
                    DateOfBirth = [datetime]'2011/11/11'
                }
            }
            Default {
                throw '不明です。'
            }
        }
    }
}

Describe 'ゆるキャラ情報' {
    It '愛媛県のゆるキャラを取得する' {
        $mican = Get-YuruCharaInfo '愛媛'

        $mican | Should -Not -BeNullOrEmpty
        $mican.Name | Should -Be 'みきゃん'
        $mican.Motif | Should -Contain 'ミカン'
        $mican.DateOfBirth | Should -BeOfType System.DateTime
        $mican.DateOfBirth | Should -BeLessThan (Get-Date)
    }
}
```

利用可能なアサーションの種類については公式ドキュメントをご参照ください。

https://pester.dev/docs/assertions/

## 1-1. エラー時のアサーション

`-Throw` オプションを使用することで期待していた例外がスローされているかを検証することができます。

:::note warn
注意
スローする側の実行式は全体を中括弧 `{}` で囲む必要があります。
:::

前回のサンプルコードの `throw` を検証する場合、以下のように書けます。

```powershell:Get-YuruCharaInfo.Tests.ps1
It '該当のゆるキャラが見つからなかった場合' {
    { Get-YuruCharaInfo '愛媛' } | Should -Not -Throw
    { Get-YuruCharaInfo '非存在' } | Should -Throw '不明です。'
}
```

## 1-2. ロギングや標準出力などの検証

テスト対象が CmdletBinding 属性や Parameter 属性を使用する高度な関数であれば、
共通パラメータを渡すことでログや標準出力などで出力された文字列を検証することができます。

```powershell:Invoke-Something.Tests.ps1
BeforeAll {
    function Invoke-Something {
        [CmdletBinding()]
        param ( )

        Write-Error '何かしらのエラー'
        Write-Warning '何かしらの警告'
        Write-Information '何かしらの情報'
        Write-Output '何かしらの標準出力'
    }
}

Describe 'Invoke-Something' {
    It 'ログの標準出力の確認' {
        Invoke-Something `
            -ErrorAction Continue `
            -ErrorVariable errorLog `
            -WarningVariable wranLog `
            -InformationVariable infoLog `
            -OutVariable stdout

        $errorLog.Count | Should -Be 1
        $errorLog[0].Exception.Message | Should -Be '何かしらのエラー'
        $wranLog[0].Message | Should -Be '何かしらの警告'
        $infoLog[0].MessageData | Should -Be '何かしらの情報'
        $stdout[0] | Should -Be '何かしらの標準出力'
    }
}
```

# 2. パラメータ化テスト

パラメータ化テストは、同じテストシナリオを異なる入力データで複数回実行するテストの手法です。
これにより、コードの再利用性を高め、異なるテストケースを効率的にカバーできます。

以下は、うるう年の判定を行う関数をパラメータ化テストするテストコードです。

`It` ブロックに `-ForEach` パラメータに渡しているハッシュテーブル内のキー名を
`<xxx>` のように指定することで、失敗したときにどのケースで失敗したのかが確認しやすくなります。
また、 `-ForEach` パラメータに渡している値が配列の場合は、 `<_>` で指定可能です。 

※ `-ForEach` パラメータは `-TestCases` パラメータと同じです。

```powershell:Test-LeapYear.Tests.ps1
BeforeAll {
    function Test-LeapYear([int]$year) {

        if ($year % 400 -eq 0) {
            return $true
        } elseif ($year % 100 -eq 0) {
            return $false
        } elseif ($year % 4 -eq 0) {
            return $true
        } else {
            return $false
        }
    }
}

Describe 'Test-LeapYear' {
    It '<year> がうるう年である、は <expected> です。' -ForEach @(
        @{ year = 2020; expected = $true }
        @{ year = 2021; expected = $false }
        @{ year = 2000; expected = $true }
        @{ year = 1900; expected = $false }
    ) {
        Test-LeapYear $year | Should -Be $expected
    }
}
```

# 3. 一時的なフォルダ作成｜TestDrive

テストが実際のファイルシステムを変更することなく、安全にファイル操作をテストできるようにするために、
Pester では TestDrive という機能が提供されています。

TestDrive は一時的にユーザ権限配下のフォルダに作成される temp フォルダです。
このフォルダは `Describe` または、 `Context` が実行されたタイミングで作成され、実行完了した後に削除されます。

以下、使い方のサンプルです。

```powershell:TestDrive.Tests.ps1
Describe 'TestDriveの検証' {
    BeforeAll {
        Push-Location 'TestDrive:\'
    }

    It 'TestDriveのフルパスを表示' {
        # (Get-Location).Path の場合、"TestDrive:\" が返るため、Get-Item でフルパスを取得している。
        (Get-Item (Get-Location).Path).FullName |
        Should -BeLike "${env:USERPROFILE}\AppData\Local\Temp\*"
    }

    AfterAll {
        Pop-Location
    }
}
```

本稿では説明省きますが、レジストリに対しても `TestRegistry:` で同じようなことができます。

[Isolating Windows Registry Operations using the TestRegistry](https://pester.dev/docs/usage/testregistry)

# 4. SetUp / TearDown

Pester では各テストケース、または、テストコード全体を実行する上で、
最初・最後に実行する処理を設定する機能が提供されています。

使用できるコマンドレットと機能の関係は以下の通りです。

<dl>
  <dt><code>BeforeAll</code></dt>
  <dd>全体を実行する前に一度だけ実行されます。<br>
      ここで宣言した変数は <code>It</code> ブロック内で参照可能です。</dd>
  <dt><code>AfterAll</code></dt>
  <dd>全体を実行した後に一度だけ実行されます。</dd>
  <dt><code>BeforeEach</code></dt>
  <dd>各テストケースが実行される前に毎回実行されます。<br>
      変数のスコープは <code>It</code> ブロックと同じになります。</dd>
  <dt><code>AfterEach</code></dt>
  <dd>各テストケースが実行された後に毎回実行されます。<br>
      変数のスコープは <code>It</code> ブロックと同じになります。</dd>
</dl>

`BeforeAll` と `AfterAll` については `Describe` や `Context` ブロックの外でも利用できます。
また、実行される順番は以下の通りです。

1. BeforeAll
1. Describe > BeforeAll
1. Context > BeforeAll
1. Describe > BeforeEach
1. Context > BeforeEach
1. It
1. Context > AfterEach
1. Describe > AfterEach
1. Context > AfterAll
1. Describe > AfterAll
1. AfterAll

:::note info
情報
各 Before や After 系のブロックの宣言する順番は実行順序に影響しません。
そのため、仮に `AfterAll` をファイルの先頭に実装したとしても、一番最後に実施されます。
:::

以下、それぞれの使用例です。

```powershell:Remove-OldFile.ps1
function Remove-OldFile {
    param (
        [Parameter(Mandatory = $true)]
        [String]$Path,
        [String]$Filter = '*.*',
        [int]$MaximumAge = 90
    )

    Get-ChildItem $Path -Filter $Filter |
        Where-Object LastWriteTime -lt (Get-Date).AddDays(-$MaximumAge) |
        Remove-Item
}
```

```powershell:Remove-OldFile.Tests.ps1
BeforeAll {
    . $PSCommandPath.Replace('.Tests.ps1', '.ps1')

    $TestPath = 'TestDrive:\'
    New-Item $TestPath -ItemType Directory | Out-Null
    Push-Location $TestPath
}

AfterAll {
    Pop-Location
}

Describe Remove-OldFile {

    BeforeEach {
        $oldItem = New-Item 'old.txt' -ItemType File -Force
        $oldItem.LastWriteTime = (Get-Date).AddDays(-90)
        $newItem = New-Item 'new.txt' -ItemType File -Force
        $newItem.LastWriteTime = (Get-Date).AddDays(-89)
    }

    AfterEach {
        Remove-Item $oldItem -ErrorAction SilentlyContinue
        Remove-Item $newItem -ErrorAction SilentlyContinue
    }

    It '90日以前に更新されたファイルを削除する' {
        Remove-OldFile $TestPath

        'old.txt' | Should -Not -Exist
        'new.txt' | Should -Exist
    }

    It '89日以前に更新されたファイルを削除する' {
        Remove-OldFile -Path $TestPath -MaximumAge 89

        'old.txt' | Should -Not -Exist
        'new.txt' | Should -Not -Exist
    }
}
```

## 4-1. Pester 5 の新仕様｜BeforeDiscovery について

上記で紹介したコマンドレットのほかに `BeforeDiscovery` も Pester 5 から利用できるようになりました。
名前からして `BeforeAll` と混同しやすいですが、こちらは実行されるタイミングが異なります。

Pester 5 から、 `*.Tests.ps1` ファイルを実行するフェーズが Discovery と Run という２フェーズに分けて実行されるようになりました。

Discovery の間に、Pester はテストファイルを高速にスキャンし、
すべての `Describe`、`Context`、`It`、および、他の Pester ブロックを見つけます。

その後、Run で実際のテストコードが実行されます。
この Run フェーズ内では Discovery フェーズで宣言して変数は参照することができません。

以下、主な注意点をまとめました。

1．すべてのテストコードは `It`、`BeforeAll`、`BeforeEach`、`AfterAll`、または `AfterEach` に配置します。
   ※ `Describe`、`Context`、またはファイルのトップに直接書かれたコードは Discovery で実行されるため、
   　 多くの場合、意図しない挙動になる可能性があります。

2．パラメータ化テストにて `-ForEach` で指定する変数は `BeforeDiscovery` で定義する必要があります。
   ※ `-ForEach` は Discovery フェーズで実行されるため。

以下、ディスクボリュームの HealthStatus を確認するテストコードの実装例です。
もし、`$volumes` を `BeforeAll` で初期化した場合、`-ForEach` の `$volumes` は null になってしまいます。

```powershell:Get-Volume.Tests.ps1
BeforeDiscovery {
    $volumes = Get-Volume
}

Describe 'Volume - <_>' -ForEach $volumes {
    It 'Volume の HealthStatus を検証する' {
        $_.HealthStatus | Should -Be 'Healthy'
    }
}
```

# 5. Mock

モックは、テスト対象のコードが他の関数やコマンドレットを呼び出す場合に、
その呼び出しを仮想的に置き換えることができる機能です。

モックを使用すると、テスト中に実際の外部リソース（ファイルシステム、データベース、ネットワーク接続など）を介さずに、
テスト対象のコードの特定の部分を単体でテストできます。
モックすることでテストコードの関心の範囲をより小さくでき、コマンド間の依存性を分離することができます。

モックは `Mock` コマンドで作成することができ、`BeforeAll` や `BeforeEach`、
または、 `It` ブロックでテスト対象の処理をまだ呼び出す前の箇所にて宣言します。

試しに `Get-Date` を常に 2019 年 1 月 1 日を返すようにモックする場合は以下のように書きます。

```powershell:Mock.Tests.ps1
Describe "Get-Date をモック" {
    BeforeEach {
        Mock Get-Date {
            [DateTime]::new(2017, 1, 1)
        }
    }
    It '2017年1月1日を取得' {
        (Get-Date).ToString('yyyy-MM-dd') | Should -Be "2017-01-01"
    }
}
```

## 5-1. モックのコール数を検証する｜Should -Invoke -Times

モックのコール数も検証可能です。
アサーションの書き方はこちらになります。

`Should -Invoke -CommandName <モックした関数・コマンドレット名> -Times <想定のコール数>`

`-ParameterFilter` をつけることで、引数の呼び出しパターンごとに何回コールされたのかも検証することができます。

```powershell:Get-NetworkInfo.Tests.ps1
BeforeAll {
    function Get-NetworkInfo {
        [CmdletBinding()]
        param (
            [ValidateSet("ip" , "ua", "lang")]$Resource
        )
    
        (Invoke-WebRequest -Uri "https://ifconfig.me/$Resource").Content
    }
}

Describe 'Get-NetworkInfo' {
    BeforeAll {
        Mock Invoke-WebRequest {
            @{
                Content = '192.168.0.1'
            }
        }
    }

    It 'ネットワーク情報を検証する' {
        Get-NetworkInfo -Resource 'ip' | Should -BeExactly '192.168.0.1'

        Should -Invoke -CommandName Invoke-WebRequest -Times 1 -ParameterFilter { $Uri -like '*/ip' }
        Should -Invoke -CommandName Invoke-WebRequest -Times 0 -ParameterFilter { $Uri -notlike '*/ip' }
    }
}
```

## 5-2. 引数によって Mock の挙動を変える｜ParameterFilter

`Mock` コマンドの引数に `-ParameterFilter` をつけることで、
指定された引数で実行された場合のみ、モックされたコマンドで実行するように挙動を変更することができます。

:::note warn
注意
指定された引数以外で実行された場合はモックされていないコマンドが実行されます。
:::

前回のサンプルコードの続きに、 `Invoke-WebRequest` の引数によって呼び出される Mock を分ける場合、
以下のような書き方で実現できます。

```powershell:ParameterFiltering.Tests.ps1
Describe 'Get-NetworkInfo 引数によって Mock の挙動を変える' {
    BeforeAll {
        Mock Invoke-WebRequest {@{ Content = '192.168.0.1' }} -ParameterFilter { $Uri -like '*/ip' }
        Mock Invoke-WebRequest {@{ Content = 'Mozilla/5.0' }} -ParameterFilter { $Uri -like '*/ua' }
        Mock Invoke-WebRequest {@{ Content = 'ja;q=0.5' }} -ParameterFilter { $Uri -like '*/lang' }
    }

    It 'ネットワーク情報を検証する' {
        Get-NetworkInfo -Resource 'ip' | Should -BeExactly '192.168.0.1'
        Get-NetworkInfo -Resource 'ua' | Should -BeExactly 'Mozilla/5.0'
        Get-NetworkInfo -Resource 'lang' | Should -BeExactly 'ja;q=0.5'

        Should -Invoke -CommandName Invoke-WebRequest -Times 3
    }
}
```

## 5-3. .NET クラスのモッキング

Pester の Mock でモックができるのは、PowerShell の関数やコマンドレットのみのため、
.NET クラスのメソッドはそのままモックすることはできません。

回避策として、PowerShell の関数でラップするという方法があります。
ここは正直あまりイケてないです。

```powershell:DriveInfo.Tests.ps1
BeforeAll {
    function Get-DriveInfo($DriveLetter) {
        [System.IO.DriveInfo]::new($DriveLetter)
    }
}

Describe 'DriveInfo' {
    It '存在しないドライブを取得しようとしたとき' {
        Mock Get-DriveInfo { @{IsReady = $false } }

        (Get-DriveInfo -DriveLetter 'N').IsReady | Should -BeFalse
    }
}
```

# 6. 実施するテストケースを指定・除外する

## 6-1. 一部のテストケースを除外する｜Skip

`Describe` や `Context`、 `It` のパラメータに `-Skip` を追加することで、
それらのテストケースの実行をスキップすることができます。

`-Skip` は switch パラメータなので、コロンと繋げて Boolean な変数を指定することで、
特定の条件が満たされた場合にだけテストをスキップさせることもできます。

```powershell:Invoke-Skip.Tests.ps1
Describe '何かのテスト' {
    It 'これはスキップする' -Skip {
        1 | Should -Be 2
    }

    It 'これは実行される' {
        1 | Should -Be 1
    }

    Context 'Windows であればスキップする' -Skip:$IsWindows {
        It 'Windows であれば、これもスキップされる' {
            $IsWindows | Should -BeFalse
        }
    }
}
```

スキップされたテストケースは、出力結果の Skipped に反映されます。

```powershell:Terminal
Invoke-Pester .\Invoke-Tag.Tests.ps1 -Tag 'Windows'

#=> Starting discovery in 1 files.
#=> Discovery found 3 tests in 28ms.
#=> Running tests.
#=> [+] <snip>\Pester-Demo\Invoke-Skip.Tests.ps1 206ms (11ms|171ms)
#=> Tests completed in 232ms
#=> Tests Passed: 1, Failed: 0, Skipped: 2 NotRun: 0
```

## 6-2. タグを指定して対象・除外を切り替える｜Tag, ExcluedTag

こちらも `Describe` や `Context`、 `It` のパラメータに `-Tag` を追加することで、
特定のテストをグループ化、または、フィルタリングする機能です。
`Skip` よりもこちらで紹介する機能の方がより使われている印象です。

Pester を実行する際に、 `Invoke-Pester` のパラメータに `-Tag` または、`-ExcludeTag` を使って、
どのタグを対象・除外にするのかを切り替えます。
`Invoke-Pester` で指定するタグ名に関してはワイルドカードが使えます。

```powershell:Invoke-Tag.Test.ps1
Describe '何かのテスト' {
    It '遅い処理のテスト' -Tag 'Slow' {
        Start-Sleep -Seconds 10
        1 | Should -Be 1
    }

    It 'タグが指定ないので、-Tag が付くと実行されない' {
        1 | Should -Be 1
    }

    It 'タグは複数指定が可能' -Tag 'MacOS', 'Linux' {
        $IsMacOS -or $IsLinux | Should -BeTrue
    }
}
```

Tag パラメータなしで実施すると、上記すべてのテストコードが実施されます。
例えば、`Windows` タグを指定すると、該当するタグがないので、何も実施されずに終了します。
その場合は、 NotRun に実施されなかったテストケース数が反映されます。

```powershell:Terminal
Invoke-Pester .\Invoke-Tag.Tests.ps1 -Tag 'Windows'

#=> Starting discovery in 1 files.
#=> Discovery found 3 tests in 81ms.
#=> Running tests.
#=> Tests completed in 27ms
#=> Tests Passed: 0, Failed: 0, Skipped: 0 NotRun: 3
```