Pester v3 & v5 互換のテストコードを書く (PowerShell)

Pocket

PowerShell のテストフレームワークである Pester は、単体テストや自動テストなどを手軽に書けるので、スクリプトやコードの品質を保つのに役立つ。

しかし、 Pester は v3 から v5 にかけてかなり大きな破壊的変更がある。

この記事では、 Pester v3 と v5 の両方に互換があるテストコードの書き方について紹介したい。

何故 v3 と v5 で互換を取りたいか

Windows 10 や 11 では、デフォルトで Windows PowerShell 5.1 がインストールされており、更にそこには Pester モジュールの v3.4.0 もインストールされている。
Windows 上の PowerShell モジュールディレクトリはシステム全体で共通のため、たとえ別途 PowerShell 7.3 LTS などをインストールしていたとしても、既定では Pester v3.4.0 が読み込まれるわけだ。

一方、 Linux 等のそれ以外のシステムで PowerShell をインストールした場合は、 Pester が自動的にインストールされることはないため、 Pester モジュールを追加でインストールすることになる。
このとき、普通は最新版の v5 が入るだろう。

Windows PowerShell の Pester を v5 に更新させたり、 Linux 等のシステムでインストールする Pester モジュールを v3.4 に抑えさせたりできればよいが、まぁなかなかそうも行かない時がある。

そんなのっぴきならん状況だと、多少苦労してでもテストコード側を Pester v3 と v5 どちらにも互換を取ってしまったほうが手っ取り早いかも…なんて状況が地球上の何処かには存在するかもしれない。

テストコードの書き方

v3 と v4 の間の破壊的変更で一番デカいのは、 Should 構文の変更で互換切りを行っている部分だ。

構文自体の変更自体は v4 で行われたものだが、このときは以前の構文も互換性が残されていた。
ところが、 v5 で早速この互換が切られてしまっている。

コレのせいで、ほぼすべてのテストコードが、そのままでは v3 と v5 の間で互換を取ることが不可能になっている。
何考えてんだ……

位置指定パラメータからスイッチパラメータに変わっているため、このどちらにも渡せるようにするためには Splatting 表記 に頼ることにする。
v3 で使える Should のオペレーターそれぞれについて、 v3 以下なら位置指定パラメータで渡す方法で、 v4 以上ならスイッチパラメータで渡す方法で、それぞれ $BeForCompat のような形で $*ForCompat, $Not*ForCompat の形式の変数をテストコード開始時に定義する。
そして、その変数を Should@BeForCompat のように Splatting 表記にて使用する。

また、 v4 では Contain/ContainExactly オペレーターが -FileContentMatch/-FileContentMatchExactly にリネームされていたり、
v5 では *.Tests.ps1 ファイル先頭や、 Context, Describe 直下にセットアップコードが記載できなくなっているので、 全て BeforeAll ブロック内に記述する必要がある。

これらのマイグレーションをまとめると、 v3/v5 互換の Pester テストコードは以下のようになる。
(v3-to-v5-Migrations.Tests.ps1 というファイル名で保存されているものとする)

#Requires -Version 5
#Requires -Modules @{ ModuleName="Pester"; ModuleVersion="3.0" }
trap { break; }

$GlobalBeforeAll = {
    # Write here the code that needs to be executed at the start of the test
    $shouldParams = 'Be','BeExactly','BeGreaterThan','BeLessThan','BeLike','BeLikeExactly','BeNullOrEmpty','BeOfType','Exist','Match','MatchExactly','Throw';
    if ((Get-Module Pester).Version -ge [version]'4.0') {
        $shouldParams | ForEach-Object {
            Set-Variable "${_}ForCompat" @{ $_ = $true };
            Set-Variable "Not${_}ForCompat" @{ Not = $true; $_ = $true };
        };
        $FileContentMatchForCompat = @{ FileContentMatch = $true };
        $FileContentMatchExactlyForCompat = @{ FileContentMatchExactly = $true };
        $NotFileContentMatchForCompat = @{ Not = $true; FileContentMatch = $true };
        $NotFileContentMatchExactlyForCompat = @{ Not = $true; FileContentMatchExactly = $true };
    } else {
        $shouldParams | ForEach-Object {
            Set-Variable "${_}ForCompat" @($_);
            Set-Variable "Not${_}ForCompat" @('Not', $_);
        };
        $FileContentMatchForCompat = @('Contain');
        $FileContentMatchExactlyForCompat = @('ContainExactly');
        $NotFileContentMatchForCompat = @('Not','Contain');
        $NotFileContentMatchExactlyForCompat = @('Not','ContainExactly');
    }
}
if ((Get-Module Pester).Version -ge [version]'5.0') {
    BeforeAll $GlobalBeforeAll;
} else {
    . $GlobalBeforeAll;
}

Describe 'Pester v3, v5 compatibility notation test' {
    BeforeAll {
        # DON'T use $MyInvocation.MyCommand.Path
        $filePath = '.\v3-to-v5-Migrations.Tests.ps1';
    }

    It "Pattern: (<A>, <B>)" -TestCases @(
        @{A=1; B=2};
        @{A=3; B=4};
    ) {
        param ($A, $B);
        ($A / $A) | Should @BeForCompat 1;
        ($B * $A) | Should @NotBeForCompat 0;
        $filePath | Should @FileContentMatchExactlyForCompat 'BeforeAll';
    }
}
PS > # Pester 3.4.0 の場合
PS > Get-Module Pester

ModuleType Version    PreRelease Name  
---------- -------    ---------- ----  
Script     3.4.0                 Pester

PS > Invoke-Pester

Describing Pester v3, v5 compatibility notation test
 [+] Pattern: (1, 2) 74ms
 [+] Pattern: (3, 4) 5ms
Tests completed in 53ms
Passed: 2 Failed: 0 Skipped: 0 Pending: 0 Inconclusive: 0
PS >
PS > # Pester 5.5.0 の場合
PS /> Get-Module Pester

ModuleType Version    PreRelease Name  
---------- -------    ---------- ----  
Script     5.5.0                 Pester

PS > Invoke-Pester

Starting discovery in 1 files.
Discovery found 2 tests in 21ms.
Running tests.
[+] .../v3-to-v5-Migrations.Tests.ps1 115ms (82ms|13ms)
Tests completed in 117ms
Tests Passed: 2, Failed: 0, Skipped: 0 NotRun: 0
PS >

うーん、面倒くさいね。

Tips

こんなアホなことはせずに、Pester v3 なら v3 のコードで、 v5 なら v5 のコードで記述する場合、せめてテストコードの先頭で #Requires 構文 を書いて、どのバージョンの Pester をターゲットにしているかしっかり明示しよう。

# Pester v3, v4 をターゲットにする場合
#Requires -Modules @{ ModuleName="Pester"; ModuleVersion="3.0"; MaximumVersion="4.99" }
(Get-Module Pester).Version;
Describe 'desc' {
    It 'it' { 1 | Should Be 1; };
};
# Pester v5 以降をターゲットにする場合
#Requires -Modules @{ ModuleName="Pester"; ModuleVersion="5.0" }
BeforeAll { (Get-Module Pester).Version; };
Describe 'desc' {
    It 'it' { 1 | Should -Be 1; };
};

本来 #Requires 構文には、必要なモジュールが現在のセッションにない場合自動でインポートする機能があり、例えば複数のバージョンのモジュールがサイドバイサイドでインストールされていた場合だと、条件に一致するバージョンのものがインポートされる便利な機能がある。
しかし Pester の場合は、必ずしも良い感じには機能してくれるとは限らない。

テストコードが読み込まれる前 Invoke-Pester した時点で Pester モジュールのインポートが走ってしまうことや、複数のバージョンのモジュールがインポートされた状態になると、いずれか一つのモジュールが条件を満たせば #Requires の検証をパスしてしまうのに、インポートされたもののうち最も古いバージョンのモジュールで実行されてしまうような挙動をとるためだ。

それでも、 Pester モジュールがひとつしかインストールされていないような多くの状況では、バージョンを明示しておくことでエラー原因がわかりやすくはなるはずだ。

コメントを残す

メールアドレスが公開されることはありません。

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください