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を利用できるようにします。
次に Pesterモジュールの New-Fixture関数を使って、コードとテストコードの雛形ファイルを生成します。
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関数の引数にテストファイルのパス名を指定することで、特定のテストファイルを実行することができます。引数にディレクトリパスを指定した場合は、そのディレクトリ以下(サブディレクトリも含む)にあるすべてのテストファイルを実行します。引数を指定しない場合、カレントディレクトリを指定したのと同じです。
テストが失敗した旨が赤く表示され、「期待値は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 と謳っている所以なのでしょうか。
テストを実行してみます。
Get-FizzBuzzがないとの理由でテストに失敗しました。実装してないので当然ですね。
では FizzBuzz.ps1に Get-FizzBuzz関数を定義します。
function Get-FizzBuzz([int] $num) {
if ($num % 15 -eq 0) {
return "FizzBuzz"
} else {
return $null
}
}
15の倍数以外は、とりあえず $null を返すようにしました。
テストを実行します。
グリーンが表示されました。テストに通りました。
テストは成功したのですが、これだけでは不安に感じたので、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")
}
}
そして実行。
結果はレッド表示。テスト失敗です。
Get-FizzBuzz()関数を修正します。
function Get-FizzBuzz([int] $num) {
if ($num % 15 -eq 0) {
return "FizzBuzz"
} elseif ($num % 3 -eq 0) {
return "Fizz"
} else {
return $null
}
}
そしてテスト実行。
グリーン、グリーン、丘の上には〜♪
おk、テストにパスしました。
以降は省略しますが、このようなサイクルを繰り返し、Get-FizzBuzz()を小刻みに実装していきました。
- プログラムの仕様、振る舞いをテストコードで記述する
- テスト実行 → 結果レッド
- テスト結果がグリーンになるように実装・修正
- テスト実行 → 結果グリーン
実装してから数ヶ月後、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 { ... ... }
}
}
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:加山雄三ちっくに
*2:PowerShell ISEでテストを実行すると「このホストはトランスクリプションをサポートしません。」というエラーが表示されました。ただ、テスト結果の検証はできるようです。
*3:正確には拡張子が ps1でファイル名に .Tests.が含まれるファイルをテストコードスクリプトとみなします。従って、FizzBuzz.Tests.xxx.ps1もテストコードとみなされます。
- 19 http://atnd.org/events/22073
- 5 http://www.google.co.jp/url?sa=t&rct=j&q=このホストはトランスクリプションをサポートして??%
- 4 http://t.co/fQXpAfi0
- 4 http://t.co/tzDVSf0F
- 4 http://www.google.co.jp/url?sa=t&rct=j&q=&esrc=s&source=web&cd=11&cts=1331528789782&ved=0CCUQFjAAOAo&url=http://d.hatena.ne.jp/fsugiyama/20111217/p1&ei=ToRdT8q4M-GemQXG0bCdDw&usg=AFQjCNEj-Ap2tHZajKq65WQ58DqmzMg5GA&sig2=tn1u5_6lQy9fmhzGgRR6WA
- 3 http://www.google.co.jp/url?sa=t&rct=j&q=powershell&source=blogsearch&cd=3&ved=0CDMQmAEwAg&url=http://d.hatena.ne.jp/fsugiyama/20111217/p1&ctbm=blg&ei=7DTwTpfTBtGOmQWvp9ijAg&usg=AFQjCNEj-Ap2tHZajKq65WQ58DqmzMg5GA&cad=rja
- 2 http://d.hatena.ne.jp/keyword/PowerShell
- 2 http://d.hatena.ne.jp/keywordblogmobile/ねごと
- 2 http://search.yahoo.co.jp/search?p=このホストはトランスクリプションをサポートしてい?%
- 2 http://www.google.co.jp/search?q=powershell+トランスクリプションをサポートしていません&hl=ja&gbv=2&prmd=ivns

