Hatena::ブログ(Diary)

@fsugiyamaの技術日誌

2011/12/17

Pester - BDD style testing framework for PowerShell

このエントリは PowerShell Advent Calendar 2011 に参加しています。

はじめに

PowerShellスクリプトに対してユニットテスト、書いてますかー?」
「TDD で PowerShellスクリプト、書いてますかー?」
「テストコードがあれば、勇気を持ってリファクタリングできる」
「テストコード、書いてますかー?」
とアントキの猪木が言うわけはありませんが、 PowerShellユーザーの皆さんはこの辺いかがお考えでしょうか?
かくいう私は、ユニットテストを書いたり、TDD(Test Driven Development)でPowerShellスクリプトを書いたりしていません。試行錯誤しながらスクリプトを書いた後に、いくつかのパターンで実行し、その結果を目視確認し、問題がないようだったら「完成♪」とか言っている口です。
ただ、書き捨てのスクリプトでも、不慣れなAPIを使ったり、よくわからない問題領域を扱う場合などは、TDD しながら小刻みに実装していきたいと思うことはあります。また、長期に渡って使うスクリプトに関しては、後々も積極的にリファクタリングに取り組めるので、テストコードがあった方が「幸せだな〜」*1とも思います。
ということで、TDD で PowerShell スクリプトをちょっと書いてみようと思いました。そのために、まずはテストフレームワークを調べてみることにしました。

PowerShell用のテストフレームワーク

検索してみると、Webアプリケーションなどのテスト自動化のためにPowerShellを使う事例は結構見つかるのですが、PowerShellで書いたスクリプト自身へのテストに関して、日本語での言及はあまりありません。
そんな中、Pester という PowerShell用のテストフレームワークに辿り着きました。"BDD(Behavior Driven Development) style"と謳っていて、作成するプログラムの要求仕様を自然言語に近い形で、テストコードとして記述できるのが特徴のようです。このフレームワーク自身、PowerShellで書かれています。
他にも psexpect というテストフレームワークがあります。ただ、サンプルコードを比較したところ、テストコードの表現力という点で Pester に魅力を感じました。

追記
PSUnit というのもありますね。

Pesterのインストール

こちら から zipファイルをダウンロードし、好きな場所に解凍するだけです。自分はインストールディレクトリを d:\Tooldev\Pester にしました。

PesterでTDD? BDD?

PowerShellコンソール*2を起動します。
まずは Pesterモジュールをインポートし、Pesterを利用できるようにします。
f:id:fsugiyama:20111216001009j:image

次に Pesterモジュールの New-Fixture関数を使って、コードとテストコードの雛形ファイルを生成します。
f:id:fsugiyama:20111216001010j:image

New-Fixtureの第1引数には生成するファイルを置くディレクトリ、第2引数にはスクリプト名を指定します。
実行例では FizzBuzz.ps1(これから作成するスクリプト)と FizzBuzz.Tests.ps1(そのテストコード)が作成されます。Pesterの規約により、テストコードのファイル名は *.Tests.ps1*3 とする必要があります。
生成されたファイルを見てみます。FizzBuzz.ps1は、中身が空の関数が1つ定義されているだけです。

function FizzBuzz {
}

FizzBuzz.Tests.ps1 の中身はこんな感じです。

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path).Replace(".Tests.", ".")
. "$here\$sut"

Describe "FizzBuzz" {
    It "does something useful" {
        $true.should.be($false)
    }
}

最初の3行でテスト対象の FizzBuzz.ps1 を dot sourcing しています。その後ろがテストコードで、失敗するテストケースが書いてあります。
とりあえずこのテストを実行してみましょう。
テストコードは、PesterモジュールのInvoke-Pester関数で実行します。Invoke-Pester関数の引数にテストファイルのパス名を指定することで、特定のテストファイルを実行することができます。引数にディレクトリパスを指定した場合は、そのディレクトリ以下(サブディレクトリも含む)にあるすべてのテストファイルを実行します。引数を指定しない場合、カレントディレクトリを指定したのと同じです。
f:id:fsugiyama:20111216001011j:image
テストが失敗した旨が赤く表示され、「期待値はFalseだが、実際にはTrueだった」「テストに失敗したのは FizzBuzz.Tests.ps1の7行目」というのがわかります。
それと、コマンドプロンプトに "@{should=System.Object}" と表示されるようになりました。これなあに?

ともあれ、このテストケースは破棄し、テストコード(仕様、振る舞い)をFizzBuzz.Tests.ps1に書いていくことにします。

Describe "Get-FizzBuzz" {
    It "15の倍数に対して'FizzBuzz'を返す" {
        $result = Get-FizzBuzz(15)
        $result.should.be("FizzBuzz")
    }
}

どうでしょう? Pester や PowerShellを知らない人でも、このテストコードを見れば Get-FizzBuzz()は15の倍数に対して、文字列 'FizzBuzz' を返すのだなとわかりますよね? コメントではなく、コードでこのように記述できるのが、BDD style と謳っている所以なのでしょうか。
テストを実行してみます。
f:id:fsugiyama:20111216201357j:image

Get-FizzBuzzがないとの理由でテストに失敗しました。実装してないので当然ですね。
では FizzBuzz.ps1に Get-FizzBuzz関数を定義します。

function Get-FizzBuzz([int] $num) {
    if ($num % 15 -eq 0) {
        return "FizzBuzz"
    } else {
        return $null
    }
}

15の倍数以外は、とりあえず $null を返すようにしました。
テストを実行します。
f:id:fsugiyama:20111216201358j:image
グリーンが表示されました。テストに通りました。
テストは成功したのですが、これだけでは不安に感じたので、30の場合も 'FizzBuzz' が返ってくるかテストすることにします。

Describe "Get-FizzBuzz" {
    It "15の倍数に対して'FizzBuzz'を返す" {
        (Get-FizzBuzz(15)).should.be("FizzBuzz")
        (Get-FizzBuzz(30)).should.be("FizzBuzz")
    }
}

このテストを実行すると、グリーン表示されテストにパスしました。
不安が解消したので、次のテストケース(仕様、振る舞い)に移ります。

Describe "Get-FizzBuzz" {
    It "15の倍数に対して'FizzBuzz'を返す" {
        ... ...
    }

    It "3の倍数に対して'Fizz'を返す" {
        (Get-FizzBuzz(3)).should.be("Fizz")
    }
}

そして実行。
f:id:fsugiyama:20111216220922j:image
結果はレッド表示。テスト失敗です。
Get-FizzBuzz()関数を修正します。

function Get-FizzBuzz([int] $num) {
    if ($num % 15 -eq 0) {
        return "FizzBuzz"
    } elseif ($num % 3 -eq 0) {
        return "Fizz"
    } else {
        return $null
    }
}

そしてテスト実行。
f:id:fsugiyama:20111216201400j:image

グリーン、グリーン、丘の上には〜♪
おk、テストにパスしました。

以降は省略しますが、このようなサイクルを繰り返し、Get-FizzBuzz()を小刻みに実装していきました。

  1. プログラムの仕様、振る舞いをテストコードで記述する
  2. テスト実行 → 結果レッド
  3. テスト結果がグリーンになるように実装・修正
  4. テスト実行 → 結果グリーン

実装してから数ヶ月後、FizzBuzzのスーパーアルゴリズムがひらめいた時、テストコード(+ VCSで管理)があれば、ない場合と比較してアグレッシブに(?)リファクタリングに取り組めますよね :)
Pesterの開発者自らが TDDを実演した動画があります(Pester Demo on Vimeo)。興味のある方はご覧になってみてください。テストにパスする度に "Green, Green, On the Hill〜♪"と陽気に歌ってますから (んーなこたない

Pesterの補足

「なんとなく雰囲気はわかるよね?」ということでスルーしてきた Describe, It, should, be() について簡単に触れておきます。

Describe "Get-FizzBuzz" {
    It "15の倍数に対して'FizzBuzz'を返す" { ... ... }
    It "3の倍数に対して'Fizz'を返す" { ... ... }
}

Describe Get-FooBar {
    Describe 正常系のテスト {
        It 正常系パターン1 { ... ... }
        It 正常系パターン2 { ... ... }
    }

    Describe 異常系のテスト {
        It 異常系パターン1 { ... ... }
    }
}

f:id:fsugiyama:20111216201401j:image

Describe

Describeは複数のテストをまとめる「テストスイート」といった感じのものでしょうか。
実体は Pesterが提供する関数で、第1引数にその説明を文字列で指定します。通常は、テスト対象のスクリプト名や関数名、◯◯機能、テストカテゴリみたいなものを記述するのでしょうかね。テスト結果中の Describingの後ろに、その文字列が表示されます(黄色で表示)。2番目の引数は ScriptBlock で、この中にテストを加えていきます。
Describe は上記のように入れ子にすることができ、それに合わせてテスト結果がインデント表示されます。
Describe を上手く使えば、テストコード(仕様、振る舞い)の整理・理解のしやすさ、テスト結果の見やすさに貢献しそうですね。

It

Itは個々のテストに対応します。
実体は Pesterが提供する関数です。第1引数には、テスト名、仕様や振る舞いの説明などを文字列で指定します。これは個々のテスト結果に表示されます。2番目の引数は ScriptBlock で、具体的なテストコードを記述します。

should, be()

should や be() は、Pesterをインストールしたディレクトリの ObjectAdaptations\types.ps1xml ファイルで定義されています。次はその抜粋です。

... ...
<Type> 
    <Name>System.Object</Name> 
    <Members> 
        <ScriptProperty> 
            <Name>should</Name> 
            <GetScriptBlock>
                $value = $this
                $object = New-Object Object |
                Add-Member ScriptMethod be {
                    param($expected)
                    if ($expected -ne $this.actual) {
                        throw New-Object PesterFailure($expected,$this.actual)
                    }
                } -PassThru |
                ...  ...
                Add-Member NoteProperty -Name actual -Value $null -PassThru |
                Add-Member ScriptProperty -Name ThisValue -Value { $this.actual } `
                {
                    param($newactual)
                    $this.actual = $newactual
                } -PassThru
                $object.ThisValue = $value
                return $object
            </GetScriptBlock> 
        </ScriptProperty> 
    </Members> 
</Type> 
... ...

テスト実行時に呼び出す Invoke-Pester関数の初期処理でこのファイルが読み込まれ(Update-TypeData)、すべてのクラスの大元である System.Objectクラスに should という ScriptProperty を追加しています。したがって、Pester環境下で生成されるあらゆるオブジェクトが、この shouldプロパティを持つことになります。そして、この shouldプロパティにアクセスすると、 be()という検証メソッドを持つオブジェクトが返ってきます。検証メソッドは他にも have_count_of(), exist(), match(), be_like()が用意されています。もちろん、このファイルを編集することで、検証メソッドの追加といった拡張が可能です。
なるほど、このような仕組みで、自然言語(英語)に近い形で、テスト結果の検証を呼び出せるようにしているのですね。

    $result.should.be("FizzBuzz")

コマンドプロンプトに "@{should=System.Object}" が表示されるようになった件、このshouldプロパティが影響しているのでしょう。でも、何故表示されるのかは、いまひとつ理解できませんでした ^^;

追記
次のようにプロンプトを変更したところ、"@{should=System.Object}" は表示されなくなりました。

function prompt {
    <# "@{should=System.Object}" が表示される
    $(if (test-path variable:/PSDebugContext) { '[DBG]: ' } else { '' }) + 'PS ' +
        $(Get-Location) + $(if ($nestedpromptlevel -ge 1) { '>>' }) + '> '
    #>

    ### 以下のようにすると表示されなくなった
    $(if (test-path variable:/PSDebugContext) { '[DBG]: ' } else { '' }) + 'PS ' +
        $(Get-Location) + $(if ($nestedpromptlevel -ge 1) { '>>' } else { '' }) + '> '
}

おわりに

次のようなPowerShellの特徴を上手く使い、DSL っぽくテストコードが書ける Pester。おもしろいですね。

  • types.ps1xml や Add-Member を使ったメタプログラミング
  • 関数呼び出し時に括弧は不要
  • 関数呼び出し時の実引数はカンマではなくスペース区切り
  • ScriptBlock

FizzBuzz相手にTDDのまねごとをしただけでは、Happy PowerShell TDD Life は送れませんが、第一歩は踏み出せたのか? 設計・実装ツールとしてのTDDを上手く活用しながら、今後は PowerShellスクリプトを書いていきたいです。
長々となってしまいました。締めの言葉で終わります。
PowerShellでも〜? T、D、D、ダー!

※明日のPowerShell Advent Calendarは @buso さんが担当です。かわいいキャラクタが登場するお絵描きブログでは、PowerShell関連で度々お世話になっています。

*1:加山雄三ちっくに

*2PowerShell ISEでテストを実行すると「このホストはトランスクリプションをサポートしません。」というエラーが表示されました。ただ、テスト結果の検証はできるようです。

*3:正確には拡張子が ps1でファイル名に .Tests.が含まれるファイルをテストコードスクリプトとみなします。従って、FizzBuzz.Tests.xxx.ps1もテストコードとみなされます。

投稿したコメントは管理者が承認するまで公開されません。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証

トラックバック - http://d.hatena.ne.jp/fsugiyama/20111217/p1
Connection: close