Pester の使い方|基礎編 に引き続き、Pester の解説です。
今回は、実際にテストコードを実装していくときに有用な機能の解説をしていきます。
1. アサーション
アサーションとは、テストコードの結果が想定通りの値かを検証するための機能です。
Pester では Should
コマンドレットを使用します。パイプラインから渡して検証することがほとんどです。
Should
のオプションを指定することにより、様々なアサーションの種類を利用することができます。
以下、実装例です。
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)
}
}
利用可能なアサーションの種類については公式ドキュメントをご参照ください。
1-1. エラー時のアサーション
-Throw
オプションを使用することで期待していた例外がスローされているかを検証することができます。
注意
スローする側の実行式は全体を中括弧 {}
で囲む必要があります。
前回のサンプルコードの throw
を検証する場合、以下のように書けます。
It '該当のゆるキャラが見つからなかった場合' {
{ Get-YuruCharaInfo '愛媛' } | Should -Not -Throw
{ Get-YuruCharaInfo '非存在' } | Should -Throw '不明です。'
}
1-2. ロギングや標準出力などの検証
テスト対象が CmdletBinding 属性や Parameter 属性を使用する高度な関数であれば、
共通パラメータを渡すことでログや標準出力などで出力された文字列を検証することができます。
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
パラメータと同じです。
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
が実行されたタイミングで作成され、実行完了した後に削除されます。
以下、使い方のサンプルです。
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
4. SetUp / TearDown
Pester では各テストケース、または、テストコード全体を実行する上で、
最初・最後に実行する処理を設定する機能が提供されています。
使用できるコマンドレットと機能の関係は以下の通りです。
BeforeAll
- 全体を実行する前に一度だけ実行されます。
ここで宣言した変数はIt
ブロック内で参照可能です。 AfterAll
- 全体を実行した後に一度だけ実行されます。
BeforeEach
- 各テストケースが実行される前に毎回実行されます。
変数のスコープはIt
ブロックと同じになります。 AfterEach
- 各テストケースが実行された後に毎回実行されます。
変数のスコープはIt
ブロックと同じになります。
BeforeAll
と AfterAll
については Describe
や Context
ブロックの外でも利用できます。
また、実行される順番は以下の通りです。
- BeforeAll
- Describe > BeforeAll
- Context > BeforeAll
- Describe > BeforeEach
- Context > BeforeEach
- It
- Context > AfterEach
- Describe > AfterEach
- Context > AfterAll
- Describe > AfterAll
- AfterAll
情報
各 Before や After 系のブロックの宣言する順番は実行順序に影響しません。
そのため、仮に AfterAll
をファイルの先頭に実装したとしても、一番最後に実施されます。
以下、それぞれの使用例です。
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
}
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 という2フェーズに分けて実行されるようになりました。
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 になってしまいます。
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 日を返すようにモックする場合は以下のように書きます。
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
をつけることで、引数の呼び出しパターンごとに何回コールされたのかも検証することができます。
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
をつけることで、
指定された引数で実行された場合のみ、モックされたコマンドで実行するように挙動を変更することができます。
注意
指定された引数以外で実行された場合はモックされていないコマンドが実行されます。
前回のサンプルコードの続きに、 Invoke-WebRequest
の引数によって呼び出される Mock を分ける場合、
以下のような書き方で実現できます。
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 の関数でラップするという方法があります。
ここは正直あまりイケてないです。
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 な変数を指定することで、
特定の条件が満たされた場合にだけテストをスキップさせることもできます。
Describe '何かのテスト' {
It 'これはスキップする' -Skip {
1 | Should -Be 2
}
It 'これは実行される' {
1 | Should -Be 1
}
Context 'Windows であればスキップする' -Skip:$IsWindows {
It 'Windows であれば、これもスキップされる' {
$IsWindows | Should -BeFalse
}
}
}
スキップされたテストケースは、出力結果の Skipped に反映されます。
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
で指定するタグ名に関してはワイルドカードが使えます。
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 に実施されなかったテストケース数が反映されます。
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