2014年11月23日日曜日

デフォルトの振る舞い - from "Prig: Open Source Alternative to Microsoft Fakes" Wiki -

まとまってきたドキュメントを日本語の記事にもしておくよシリーズ第 4 段!(元ネタ:Prigwiki より、FEATURES: Default Behavior。同シリーズの他記事:1 2 3

自動化されたテストで既存の振る舞いを囲う時、そのメソッドが呼び出されているかどうかだけを、単にチェックしたくなることがあると思います。例えば、「対象のメソッドが、現在の環境やプラットフォームの情報にアクセスしているかどうかを知りたいので、クラス Environment のいずれかのメソッドの呼び出しを検出したい」というようなことが考えられますね。これを実現するために、Prig では間接スタブのデフォルトの振る舞いを変更する機能をサポートしています。今回はこの機能を解説していきましょう。


以下の記事、ライブラリを使用/参考にさせていただいています。この場を借りてお礼申し上げます m(_ _)m
Muhammad Shujaat Siddiqi Prefer 32-Bit with Any CPU Platform Target - Visual Studio 2012 Enhancements
何かのときにすっと出したい、プログラミングに関する法則・原則一覧 by @hiroki_daichi on @Qiita
OpenTouryoProject/OpenTouryo - GitHub
Code Generation in a Build Process
Microsoft.Fakes stub interface fails to be found - Stack Overflow
Microsoft Fakes: Trying to shim a class but dependencies are still there - Stack Overflow
c# - How do you call a constructor via an expression tree on an existing object? - Stack Overflow
Why did we build
rspec の書き方がわからない"" - Togetterまとめ
Myron Marston » Notable Changes in RSpec 3





目次

デフォルトの振る舞い
例として、以下のような「古き良き時代の」コードに対して、仕様の変更が発生した、ということを想像してみてください - もちろん、テストコードはありませんね (´・ω・`)
public struct CommunicationContext
{
public int VerifyRuntimeVersion(string[] args)
{
...(snip)...
}
public int VerifyPrereqOf3rdParty(string[] args)
{
...(snip)...
}
public int VerifyUserAuthority(string[] args)
{
...(snip)...
}
public int VerifyProductLicense(string[] args)
{
...(snip)...
}
}
public static class JobManager
{
public static void NotifyStartJob(CommunicationContext ctx, string[] args)
{
int err = 0;
Log("前提条件の検証を開始します。");
if ((err = ctx.VerifyRuntimeVersion(args)) != 0)
goto fail;
if ((err = ctx.VerifyPrereqOf3rdParty(args)) != 0)
goto fail;
if ((err = ctx.VerifyUserAuthority(args)) != 0)
goto fail;
if ((err = ctx.VerifyProductLicense(args)) != 0)
goto fail;
Log("前提条件の検証を終了します。");
Log("連携パラメータの構築を開始します。");
int mode = 0;
// … mode を計算するための大量のコードがある想定 …
if (err != 0)
goto fail;
bool notifiesError = false;
// … notifiesError を計算するための大量のコードがある想定 …
if (err != 0)
goto fail;
string hash = null;
// … hash を計算するための大量のコードがある想定 …
if (err != 0)
goto fail;
Log(string.Format("連携パラメータの構築を終了します。" +
"code: {0}, mode: {1}, notifiesError: {2}, hash: {3}", err, mode, notifiesError, hash));
UpdateJobParameterFile(err, mode, notifiesError, hash);
return;
fail:
Log(string.Format("連携の通知に失敗しました。code: {0}, {1}:{2} 場所 {3}",
err, Environment.MachineName, Environment.CurrentDirectory, Environment.StackTrace));
UpdateJobParameterFile(err, 0, false, null);
}
public static void UpdateJobParameterFile(int code, int mode, bool notifiesError, string hash)
{
...(snip)...
}
static void Log(string msg)
{
...(snip)...
}
}
view raw 04_01.cs hosted with ❤ by GitHub

NotifyStartJob は、最初に、あるジョブを実行するための前提条件を検証し、全ての条件が満たされていれば、ジョブに引き渡すパラメータファイルを生成します。goto 文を使って、共通のエラー処理に飛ばす手法は、C 言語が主流だった時代によく見られたものですが、例外による通知が標準になった現在でも、そのエラーが本当の例外でなく単に業務的なエラーだったり、パフォーマンスの問題があったりすれば、まだ利用されることがあるかもしれません。

ちょっと簡単ではないということを感じたあなたは、テストコードを書くことにしました。まずは、古くからあるモックフレームワークとちょっとの修正で可能な範囲で、その振る舞いを囲うことにします。プロダクトコードを見回すと、CommunicationContext は構造体である必要はなさそう。クラスに変更し、メンバーを virtual にすることにしました:
public class CommunicationContext
{
public virtual int VerifyRuntimeVersion(string[] args)
{
...(snip)...
}
public virtual int VerifyPrereqOf3rdParty(string[] args)
{
...(snip)...
}
public virtual int VerifyUserAuthority(string[] args)
{
...(snip)...
}
public virtual int VerifyProductLicense(string[] args)
{
...(snip)...
}
}
view raw 04_02.cs hosted with ❤ by GitHub

メンバーが static であることも止めたいですが、それはできません。なぜなら、他の多くのメンバから参照されてしまっているためです。仕方がないので、ここで、Prig を使い間接スタブを作成することにします。Package Manager Console で、以下のコマンドを実行してスタブ設定を作成し・・・:
PM> Add-PrigAssembly -AssemblyFrom <JobManager の Assembly へのフルパス。例えば、"C:\Users\User\GofUntestable\GofUntestable\bin\Debug\GofUntestable.exe">
view raw 04_03.cs hosted with ❤ by GitHub

以下のコマンドで、UpdateJobParameterFile の設定をクリップボードにコピーし、スタブ設定(例えば、GofUntestable.v4.0.30319.v1.0.0.0.prig)に追加します:
PM> Add-Type -Path <JobManager の Assembly へのフルパス。例えば、"C:\Users\User\GofUntestable\GofUntestable\bin\Debug\GofUntestable.exe">
PM> Find-IndirectionTarget ([<JobManager のフルネーム。例えば、GofUntestable.JobManager>]) UpdateJobParameterFile | Get-IndirectionStubSetting | Clip
view raw 04_04.ps1 hosted with ❤ by GitHub

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<section name="prig" type="Urasandesu.Prig.Framework.PilotStubberConfiguration.PrigSection, Urasandesu.Prig.Framework" />
</configSections>
<prig>
<stubs>
<add name="UpdateJobParameterFileInt32Int32BooleanString" alias="UpdateJobParameterFileInt32Int32BooleanString">
<RuntimeMethodInfo xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns:x="http://www.w3.org/2001/XMLSchema" z:Id="1" z:FactoryType="MemberInfoSerializationHolder" z:Type="System.Reflection.MemberInfoSerializationHolder" z:Assembly="0" xmlns:z="http://schemas.microsoft.com/2003/10/Serialization/" xmlns="http://schemas.datacontract.org/2004/07/System.Reflection">
<Name z:Id="2" z:Type="System.String" z:Assembly="0" xmlns="">UpdateJobParameterFile</Name>
<AssemblyName z:Id="3" z:Type="System.String" z:Assembly="0" xmlns="">GofUntestable</AssemblyName>
<ClassName z:Id="4" z:Type="System.String" z:Assembly="0" xmlns="">GofUntestable.JobManager</ClassName>
<Signature z:Id="5" z:Type="System.String" z:Assembly="0" xmlns="">Void UpdateJobParameterFile(Int32, Int32, Boolean, System.String)</Signature>
<Signature2 z:Id="6" z:Type="System.String" z:Assembly="0" xmlns="">System.Void UpdateJobParameterFile(System.Int32, System.Int32, System.Boolean, System.String)</Signature2>
<MemberType z:Id="7" z:Type="System.Int32" z:Assembly="0" xmlns="">8</MemberType>
<GenericArguments i:nil="true" xmlns="" />
</RuntimeMethodInfo>
</add>
</stubs>
</prig>
</configuration>
view raw 04_05.xml hosted with ❤ by GitHub

上記の準備によって、全てのテストが書けるようになります。例えば、以下のようなケースが書けると思います:
[TestFixture]
public class JobManagerTest
{
[Test]
public void NotifyStartJob_should_verify_using_the_methods_of_CommunicationContext_then_UpdateJobParameterFile()
{
using (new IndirectionsContext())
{
// Arrange
var mockCtx = new Mock<CommunicationContext>();
mockCtx.Setup(_ => _.VerifyRuntimeVersion(It.IsAny<string[]>())).Returns(0);
mockCtx.Setup(_ => _.VerifyPrereqOf3rdParty(It.IsAny<string[]>())).Returns(0);
mockCtx.Setup(_ => _.VerifyUserAuthority(It.IsAny<string[]>())).Returns(0);
mockCtx.Setup(_ => _.VerifyProductLicense(It.IsAny<string[]>())).Returns(0);
var ctx = mockCtx.Object;
var args = new[] { "foo", "bar", "baz", "qux" };
var mockUpdateJobParameterFile = new Mock<IndirectionAction<int, int, bool, string>>();
PJobManager.UpdateJobParameterFileInt32Int32BooleanString().Body = mockUpdateJobParameterFile.Object;
// Act
JobManager.NotifyStartJob(ctx, args);
// Assert
mockCtx.Verify(_ => _.VerifyRuntimeVersion(args), Times.Once());
mockCtx.Verify(_ => _.VerifyPrereqOf3rdParty(args), Times.Once());
mockCtx.Verify(_ => _.VerifyUserAuthority(args), Times.Once());
mockCtx.Verify(_ => _.VerifyProductLicense(args), Times.Once());
mockUpdateJobParameterFile.Verify(_ => _(0, 0, false, null), Times.Once());
}
}
}
view raw 04_06.cs hosted with ❤ by GitHub

ところで、今回の仕様変更は何だったかというと、「ライセンスによる検証を削除する」というものでした。具体的には、「VerifyProductLicense はもう呼ばれない」ということを実装すれば良いことになります。以下のようにテストの Assert の一部を書き換え、テストが失敗することを確認します:
...(snip)...
// Assert
mockCtx.Verify(_ => _.VerifyRuntimeVersion(args), Times.Once());
mockCtx.Verify(_ => _.VerifyPrereqOf3rdParty(args), Times.Once());
mockCtx.Verify(_ => _.VerifyUserAuthority(args), Times.Once());
// VerifyProductLicense はもう呼ばれませんので、検証を Once() から Never() に変更します。
mockCtx.Verify(_ => _.VerifyProductLicense(args), Times.Never());
mockUpdateJobParameterFile.Verify(_ => _(0, 0, false, null), Times.Once());
...(snip)...
view raw 04_07.cs hosted with ❤ by GitHub

あとは、テストが通るようにプロダクトコードを変更するだけですね!あなたは安心して、JobManager を以下のように書き換え、テストが成功することを確認します。簡単な仕事でしたね?
...(snip)...
public static void NotifyStartJob(CommunicationContext ctx, string[] args)
{
int err = 0;
Log("前提条件の検証を開始します。");
if ((err = ctx.VerifyRuntimeVersion(args)) != 0)
goto fail;
if ((err = ctx.VerifyPrereqOf3rdParty(args)) != 0)
goto fail;
if ((err = ctx.VerifyUserAuthority(args)) != 0)
goto fail;
// 製品ライセンスはもうチェックする必要はない。
//if ((err = ctx.VerifyProductLicense(args)) != 0)
goto fail;
Log("前提条件の検証を終了します。");
Log("連携パラメータの構築を開始します。");
int mode = 0;
...(snip)...
UpdateJobParameterFile(err, mode, notifiesError, hash);
return;
fail:
Log(string.Format("連携の通知に失敗しました。code: {0}, {1}:{2} 場所 {3}",
err, Environment.MachineName, Environment.CurrentDirectory, Environment.StackTrace));
UpdateJobParameterFile(err, 0, false, null);
}
view raw 04_08.cs hosted with ❤ by GitHub

・・・アイヤー、これはヒドイ。問題に気づきましたか?これでは常に goto fail; へ行ってしまい、ジョブに渡すパラメータファイルが常に作成されてしまいます!加えて、残念なことに、JobManager は err を code として、UpdateJobParameterFile に 設定していますので、最悪ジョブが常に実行されることになるでしょう。今年 2 月にあった Apple 史上最悪のセキュリティバグが思い出されますね。

このような問題を防止するには、意図しないメソッドが呼び出されたら例外を投げるようにします。この例では、fail ラベルで、問題があった環境の情報をログに書き込む処理があり、Environment の各プロパティを参照しているようです。従って、それらが呼び出された時に例外をスローするのが良いでしょう。Environment は mscorlib に属すクラスで、ほとんどのメンバが static です。この場合、Prig を使って間接スタブを作成する以外に、選択の余地はありませんね:
PM> Add-PrigAssembly -Assembly "mscorlib, Version=4.0.0.0"
view raw 04_09.ps1 hosted with ❤ by GitHub

上のコマンドを実行すると、mscorlib の間接スタブ設定を作成することができます。そうしましたら、Environment の public static なメンバーの間接スタブを作成しましょう(以下のコマンドの結果を mscorlib.v4.0.30319.v4.0.0.0.prig に貼り付けます):
PM> [System.Environment].GetMembers([System.Reflection.BindingFlags]'Public, Static') | ? { $_ -is [System.Reflection.MethodInfo] } | Get-IndirectionStubSetting | Clip
view raw 04_10.ps1 hosted with ❤ by GitHub

さて、これで Environment の public static なメンバーに対して、一度にデフォルトの振る舞いを変更できるようになりました。テストコードに、以下の変更を加えます:
...(snip)...
[Test]
public void NotifyStartJob_should_verify_using_the_methods_of_CommunicationContext_then_UpdateJobParameterFile()
{
using (new IndirectionsContext())
{
// Arrange
// Environment の大体のメソッドのデフォルトの振る舞いを設定します。
PEnvironment.
ExcludeGeneric().
// Environment.CurrentManagedThreadId は、Mock<T>.Setup<TResult>(Expression<Func<T, TResult>>) で使われているため、除外しておきましょう。
Exclude(PEnvironment.CurrentManagedThreadIdGet()).
// Environment.OSVersion は、Times.Once() で使われているため、除外しておきましょう。
Exclude(PEnvironment.OSVersionGet()).
DefaultBehavior = IndirectionBehaviors.NotImplemented;
var mockCtx = new Mock<CommunicationContext>();
mockCtx.Setup(_ => _.VerifyRuntimeVersion(It.IsAny<string[]>())).Returns(0);
mockCtx.Setup(_ => _.VerifyPrereqOf3rdParty(It.IsAny<string[]>())).Returns(0);
...(snip)...
view raw 04_11.cs hosted with ❤ by GitHub

使用しているモックフレームワークによっては、既に予約されているメンバーがあるため、Environment のいくつかのメンバーを除外することがちょっとメンドクサイ(Moq のケースだと、上記の通り、Environment.CurrentManagedThreadId や Environment.OSVersion がそれに当たります)。ですが、これで意図しないメソッドの呼び出しを監視することができるようになりました。NotifyStartJob に対する上記の修正では、もはやテストが通らないことがわかると思います。これで本当に安全になったわけですね!

ちなみにこの機能、テストケースを書く時、モックのされ具合を確認することや、複雑な条件下にある外部アクセスから守るガードとしても利用ができます。また、IndirectionBehaviors.DefaultValue を設定すれば、デフォルト値を返すようなデフォルトの振る舞いに変更することもできます。レガシーコードにおいては、1 つのメソッドで何度も何度も void Foo() のようなシグネチャのメソッドが呼び出されている状況(中でメンバ変数 or グローバル変数ガシガシ変えてる!!!)も少なくないでしょう。しかし、テスト中のもの以外を何もしない振る舞いに設定すれば、簡単に観点を絞り込むことができるようになるはずです。



0 件のコメント:

コメントを投稿