鷲ノ巣

C# とか PowerShell とか。当ブログの記事は(特に公開直後は)頻繁に改定される場合があることをご了承ください。

C# で書いた PSCmdlet のテスト

たまにはどとねとな話題(と言っても PowerShell ですけど)を書かねば。

あ、あの連載とかあの連載とかも終わったわけではないので続きはいずれ書きます。ええ。

Cmdlet と PSCmdlet

C#PowerShell コマンドレットを書くときは、Cmdlet クラスか、PSCmdlet クラスから派生したクラスを作ります。
これらの使い分けですが、コマンドレットの動作が与えられた引数のみで決まる場合は Cmdlet、現在の PowerShell 環境に依存した動作をする場合は PSCmdlet という切り分けでよいと思います。

この 2 つのクラスには、もうひとつ、大きな差異があります。
それは、PSCmdlet は new で直接インスタンス化することができないという点です。
PSCmdlet は PowerShell 環境(Runspace)上で動作するため、実行する際にも Runspace 経由で動かさなければならないのです。
これは、単体テストを書くときに大きな制約となります。

具体的に

Cmdlet から派生したクラスであれば、このようにテストすることが可能です。

// TestCmdletCommand は Cmdlet から派生したクラスだとする
var command = new TestCmdletCommand();
var result = command.Invoke<string>();

Assert.AreEqual("Hello, PowerShell", result.Single());

PSCmdlet から派生したクラスだと、同じことができません。

// TestPSCmdletCommand は PSCmdlet から派生したクラスだとする
var command = new TestPSCmdletCommand();
var result = command.Invoke<string>();

// InvalidOperationException!!
Assert.AreEqual("Hello, PowerShell", result.Single());

ではどうすればよいのかというと、PowerShell 環境を作ってやらないといけません。
こんなふうに。ああ面倒くさい。

var commandType = typeof(TestPSCmdletCommand);

using (var powershell = PowerShell.Create())
{
    powershell
        .AddCommand(new CmdletInfo("Test-PSCmdlet", commandType));

    var result = powershell.Invoke<string>();

    Assert.AreEqual("Hello, PowerShell", result.Single());
}

これで何が困るのか。
上のコードとの最大の違いは、TestPSCmdletCommand クラスのインスタンスに直接触れないという点です。

問題点

実際には、様々なパラメーターを設定してテストしたいでしょう。
Cmdlet だったら、こう書けます。

// TestCmdletCommand は Cmdlet から派生したクラスだとする
var command = new TestCmdletCommand
    {
        Message = "Hello, PowerShell"
    };

var result = command.Invoke<string>();

Assert.AreEqual("Hello, PowerShell", result.Single());

PSCmdlet だと、こう。

var commandType = typeof(TestPSCmdletCommand);

using (var powershell = PowerShell.Create())
{
    powershell
        .AddCommand(new CmdletInfo("Test-PSCmdlet", commandType))
        .AddParameter("Message", "Hello, PowerShell");
    
    var result = powershell.Invoke<string>();

    Assert.AreEqual("Hello, PowerShell", result.Single());
}

それでも、public なパラメーターだったらまだマシです。
例えば、ファイルやネットワークやデータベースにアクセスするコマンドだったら?
ユニット テストという観点からは、そういった外部 I/O はモック化したいところですよね。

しつこいようですが、Cmdlet 派生クラスだったら何も悩むことはありません。

// データベース アクセスに使うモックのつもり
var connection = new MockDatabaseConnection();

var command = new TestCmdletCommand
    {
        // internal なプロパティにしておく
        // InternalsVisibleTo 属性を使えば、テスト コードから internal なプロパティを触れる
        InternalConnection = connection
    };

/*
// コンストラクターのパラメーターにしてもいい。
// パラメーター付きコンストラクターはテストの時にしか使われない
var command = new TestCmdletCommand(connection);
*/

var result = command.Invoke<string>();

Assert.AreEqual("Hello, PowerShell", result.Single());

PSCmdlet 派生クラスの場合、インスタンスを直接触ることができませんので、internal プロパティにもコンストラクターの引数にもセットできません。
さてどうしよう。

解決策

結論から言うと、コマンドレット クラスからさらに派生して、テスト用のクラスを作ってしまえばよいのです。
上記のコードで、

typeof(TestPSCmdletCommand)

を渡しているところがありますね。ここを差し替えます。

// TestPSCmdletCommandWrapper は TestPSCmdletCommand から派生したクラス
var commandType = typeof(TestPSCmdletCommandWrapper);

using (var powershell = PowerShell.Create())
{
    powershell
        .AddCommand(new CmdletInfo("Test-PSCmdlet", commandType));
    
    var result = powershell.Invoke<string>();

    Assert.AreEqual("Hello, PowerShell", result.Single());
}

これで、テスト用の TestPSCmdletCommandWrapper のコンストラクターが呼ばれるようになります。
あとはそのコンストラクターの中で、パラメーターをセットするなり、I/O をモックに差し替えるなりすればよいわけです。

いまいち

しかし、これ、あまり良いコードではない気がするのです。
というのも、結局 TestPSCmdletCommandWrapper のインスタンスに外部(テストコードとか)から触ることはできないわけですから、テスト用の環境設定は TestPSCmdletCommandWrapper の中でやるしかありません。

ユニット テストは一般的に、一つのテストが 3 つのステップから成ります。

Arrange
テストの準備
Act
テスト対象の実行
Assert
実行結果の検証

この場合、Act と Assert はテスト内にあるけれども、Arrange は TestPSCmdletCommandWrapper 内に分散してしまっていることになるわけです。
また、TestPSCmdletCommandWrapper のコンストラクターにパラメーターを渡すことはできませんから、様々なテスト ケースに対応するのも難しくなっています。
テスト ケースの数だけ派生クラスを作るなんてのは御免こうむりたいものです。

どうしましょうか。

解決策2

PSCmdlet 派生クラスを動作させるのには PowerShell 環境が必要なのでした。
では、その環境を利用して、テスト用の値を渡せればよいのではないか。
例えば PowerShell の変数を利用するとか…。

ちょっと工夫すれば、あらかじめ変数の値をセットした環境を作ることができます。

var commandType = typeof(TestPSCmdletCommandWrapper);
var connection = new MockDatabaseConnection();

var sessionState = InitialSessionState.Create();
sessionState.Variables.Add(
    new SessionStateVariableEntry(TestPSCmdletCommandWrapper.InternalConnectionVariableName, connection, null));

using (var runspace = RunspaceFactory.CreateRunspace(sessionState))
{
    runspace.Open();

    using (var powershell = PowerShell.Create())
    {
        powershell.Runspace = runspace;

        powershell.AddCommand(new CmdletInfo("Test-PSCmdlet", commandType));

        var result = powershell.Invoke<string>();

        Assert.AreEqual("Hello, PowerShell", result.Single());
    }
}

これで、InternalConnectionVariableName が示す名前の変数に、MockDatabaseConnection のインスタンスがセットされた状態でコマンドが実行されるようになります。
あとは、TestPSCmdletCommand クラスの中でそれを使うようにするだけです。

こんな感じでどうでしょうか。


gist58828e028b46e1767eac

TestPSCmdletCommandWrapper には「変数から DB 接続を取得する」というコードがありますが、これはテスト ケースに対して中立なコードです。
テスト ケースに関する Arrange、Act、Assert はすべてテスト メソッドの中にまとまっています。

さいごに

変数経由でやるのはちょっとやりすぎな気がしなくもないのですが、どう思われますでしょうか。
PSCmdlet 派生クラスが直接インスタンス化できるようになれば、こんなことをしなくても済むのですが。

そもそも、このようにテストしにくいケースでは、テストしにくいコード(PSCmdlet 派生部分)を極力薄く保ち、その背後にテストしやすいプレーンなコードを置くというのが正攻法かもしれませんね。