DynamicAccessor - Chaining Assertion ver.1.4.0.0

テストブームはまだ続いています。さて、テスト可能性の高い設計は良いのですが、本来あるべきである設計を歪めて(単純なところでいえば、virtualである必要でないものをvirtualにするとか、privateであるべきものをprotectedにするとか、無駄なinterfaceとか、不自然な引数の取り方とか)テスト可能性を確保するのは、私は嫌だなー。などと思っていましたが、しかし、モックについてはMolesを使うことで、最高の形で解決しました。

次に何を考えるべきかな、と浮かんだのはprivateのテスト。privateのテストは考えないという流儀もあるし、それは尤もだと思いますが、publicのものをテストするにも、ちょっと確認とりたかったり値を弄ってやりたかったりなど、触れると楽な場合もいっぱいあるので、出来るにこしたことはありません。MSTestにはAccessorの自動生成で完全なタイプセーフとIntelliSenseの保証をしてくれて、それはそれで大変素敵。なのですが、もう少し軽くテスト出来る機構を用意しました。MSTestへの依存はないので、NUnitでも他のテストフレームワークでも使えます。

privateへのアクセスはリフレクションが常套手段ですが、C#4.0ならdynamicがあるよね。というわけで、dynamicで包んだアクセサを用意しました。dynamicにしたからってprivateのものは呼び出せないので、DynamicObjectで包んでリフレクション経由になるようにしています。

AsDynamic()

こんな感じで使えます。

// こんなprivateばっかなクラスがあったとして
public class PrivateMock
{
    private string privateField = "homu";

    private string PrivateProperty
    {
        get { return privateField + privateField; }
        set { privateField = value; }
    }

    private string PrivateMethod(int count)
    {
        return string.Join("", Enumerable.Repeat(privateField, count));
    }
}

// AsDynamic()をつけるだけでPrivateプロパティが呼べる
var actual = new PrivateMock().AsDynamic().PrivateProperty;
Assert.AreEqual("homuhomu", actual);

// dynamicは拡張メソッドが呼べないのでIsを使う場合はキャストしてくださいな。
(new PrivateMock().AsDynamic().PrivateMethod(3) as string).Is("homuhomuhomu");

// 勿論setも出来ます(インデクサもいけます。ジェネリックメソッドも若干の制限付きですが呼べます)
var mock = new PrivateMock().AsDynamic();
mock.PrivateProperty = "mogumogu";
(mock.privateField as string).Is("mogumogu");

オブジェクトへの拡張メソッドにより、全てのオブジェクトに対しAsDynamic()が使える状態です。IntelliSense汚染なので通常だとあまり許容できることではないのですが、UnitTestなのでOKだろう、と。AsDynamic()後はDynamicObjectとして、全ての呼び出しがリフレクション経由となり、public/privateのメソッド/プロパティ/フィールド/インデクサに自由にアクセス可能となっています。見た目は普通と全く一緒で大変自然なのがdynamicの利点。

dynamicの状態では拡張メソッドの呼び出しは不可能なので、IsによるAssertionを行う場合は、キャストして型を適用してやる必要があります。メンドクセーという場合はAssert.AreEqualなど、本来用意されているものはobjectが対象なので、そのまんま使えます。どちらでも好き好きでどうぞ。

ダイナミックとジェネリックとメソッド呼び出し

実装内部の話。DynamicObjectでTryInvokeMemberです。んで、最初はすんごく簡単に実装出来ると思ったんですよ!dynamicでリフレクション包むだけね、はいはい、余裕余裕、と。が、実際に書きだすとどうも引っかかる。オーバーロードが。ジェネリックが。型推論が。ふつーに呼んでるとうまくオーバーロードを解決してくれなくて、AmbiguousMatchException(あいまいな一致)を投げてくれます。なので、手動でマッチさせる必要があります。

public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
{
    var csharpBinder = binder.GetType().GetInterface("Microsoft.CSharp.RuntimeBinder.ICSharpInvokeOrInvokeMemberBinder");
    if (csharpBinder == null) throw new ArgumentException("is not generic csharp code");

    var typeArgs = (csharpBinder.GetProperty("TypeArguments").GetValue(binder, null) as IList<Type>).ToArray();
    var method = MatchMethod(binder.Name, args, typeArgs);
    result = method.Invoke(target, args);

    return true;
}

private Type AssignableBoundType(Type left, Type right)
{
    return (left == null || right == null) ? null
        : left.IsAssignableFrom(right) ? left
        : right.IsAssignableFrom(left) ? right
        : null;
}

private MethodInfo MatchMethod(string methodName, object[] args, Type[] typeArgs)
{
    // name match
    var nameMatched = typeof(T).GetMethods(TransparentFlags)
        .Where(mi => mi.Name == methodName)
        .ToArray();
    if (!nameMatched.Any()) throw new ArgumentException(string.Format("\"{0}\" not found : Type <{1}>", methodName, typeof(T).Name));

    // type inference
    var typedMethods = nameMatched
        .Select(mi =>
        {
            var genericArguments = mi.GetGenericArguments();

            if (!typeArgs.Any() && !genericArguments.Any()) // non generic method
            {
                return new
                {
                    MethodInfo = mi,
                    TypeParameters = default(Dictionary<Type, Type>)
                };
            }
            else if (!typeArgs.Any())
            {
                var parameterGenericTypes = mi.GetParameters()
                    .Select(pi => pi.ParameterType)
                    .Zip(args.Select(o => o.GetType()), Tuple.Create)
                    .GroupBy(a => a.Item1, a => a.Item2)
                    .Where(g => g.Key.IsGenericParameter)
                    .Select(g => new { g.Key, Type = g.Aggregate(AssignableBoundType) })
                    .Where(a => a.Type != null);

                var typeParams = genericArguments
                    .GroupJoin(parameterGenericTypes, x => x, x => x.Key, (_, Args) => Args)
                    .ToArray();
                if (!typeParams.All(xs => xs.Any())) return null; // types short

                return new
                {
                    MethodInfo = mi,
                    TypeParameters = typeParams
                        .Select(xs => xs.First())
                        .ToDictionary(a => a.Key, a => a.Type)
                };
            }
            else
            {
                if (genericArguments.Length != typeArgs.Length) return null;

                return new
                {
                    MethodInfo = mi,
                    TypeParameters = genericArguments
                        .Zip(typeArgs, Tuple.Create)
                        .ToDictionary(t => t.Item1, t => t.Item2)
                };
            }
        })
        .Where(a => a != null)
        .Where(a => a.MethodInfo
            .GetParameters()
            .Select(pi => pi.ParameterType)
            .SequenceEqual(args.Select(o => o.GetType()), new EqualsComparer<Type>((x, y) =>
                (x.IsGenericParameter)
                    ? a.TypeParameters[x].IsAssignableFrom(y)
                    : x.Equals(y)))
        )
        .ToArray();

    if (!typedMethods.Any()) throw new ArgumentException(string.Format("\"{0}\" not match arguments : Type <{1}>", methodName, typeof(T).Name));

    // nongeneric
    var nongeneric = typedMethods.Where(a => a.TypeParameters == null).ToArray();
    if (nongeneric.Length == 1) return nongeneric[0].MethodInfo;

    // generic--
    var lessGeneric = typedMethods
        .Where(a => !a.MethodInfo.GetParameters().All(pi => pi.ParameterType.IsGenericParameter))
        .ToArray();

    // generic
    var generic = (typedMethods.Length == 1)
        ? typedMethods[0]
        : (lessGeneric.Length == 1 ? lessGeneric[0] : null);

    if (generic != null) return generic.MethodInfo.MakeGenericMethod(generic.TypeParameters.Select(kvp => kvp.Value).ToArray());

    // ambiguous
    throw new ArgumentException(string.Format("\"{0}\" ambiguous arguments : Type <{1}>", methodName, typeof(T).Name));
}

private class EqualsComparer<TX> : IEqualityComparer<TX>
{
    private readonly Func<TX, TX, bool> equals;

    public EqualsComparer(Func<TX, TX, bool> equals)
    {
        this.equals = equals;
    }

    public bool Equals(TX x, TX y)
    {
        return equals(x, y);
    }

    public int GetHashCode(TX obj)
    {
        return 0;
    }
}

泥臭い。LINQ的には普段あまり使わないGroupJoinや、SequenceEqualでのIEqualityComparerとか、ここぞとばかりに色々仕込んで実に楽しげになりました。何とも酷いゴリ押し。速度とかどうなのこれ、ただリフレクションを使っただけじゃないよね、というのは、UnitTestですから。だから、許容される。そうでなければ、やれない。

名前からマッチ->型の当てはめ->実引数からマッチ->非ジェネリックメソッドを優先->引数が全てジェネリックでなければ優先->最終的にメソッドが一つにまで絞り込めなければエラー。コンパイラの行う、正確なオーバーロードの解決法はC#言語仕様書の7.5.3に書いてあります。従っていません。というか、outとかrefとか非対応だし、ジェネリックに関しても持ってる情報が足りなすぎてマッチしたくてもできない。特に痛いのはコード上での型が吹っ飛んでいて、GetTypeによる具象型しか取得できないこと。そのせいで、引数の複数の同一のTは、ほぼ同じ型同士でないとダメになってしまっていて(コード上で宣言しているインターフェイスの情報が取れないのでどうしょもない)。その他、入れ子なジェネリックの場合もコケます(対応させるの面倒くさい)。

でも、ふつーの9割がたなシチュエーションでは動作するはずです。

ちなみに、ジェネリックの型引数は通常、DynamicObjectのBinderでは取得出来ません。Binderの実際の型(の持つインターフェイス)であるICSharpInvokeOrInvokeMemberBinderのTypeArgumentsが持っているのですが、ICSharpInvokeOrInvokeMemberBinderがinternalのため、外側から手出しは出来ないのです。どうするかって、もうここまで来たら何も躊躇うことなくリフレクションです本当にありがとうございました。GetInterface("Microsoft.CSharp.RuntimeBinder.ICSharpInvokeOrInvokeMemberBinder");とか、負けたにも程がある。

そんなわけで、ふつーに作ると存外面倒くさいっぽいです。いや、こんな泥臭くなるのは何かおかしい気はかなりしなくもないんですが、うーん。まあ、泥臭くてもライブラリ側で吸収出来るなら、それはそれでいいかな、と。結果だけを見れば。ライブラリが泥臭さを担保するかわりに、ユーザーはAsDynamic()だけで綺麗に呼び出せる。それはとっても嬉しいなって。

余談

そんなDynamicAccessorのテスト作るのにIsとかAssertEx.Throws、あって良かった。大変助かった。ExpectedExceptionAttributeなんて使ってられない。どっぐふーどどっぐふーど。個人的にはMSTestは凄い好きというか、テストツール選ぶのにあたって優先度が一番高い項目はIDE統合(デバッグ含むというかデバッグ最重要)なので、統合できてないものはその時点でアウトです(どれも、一手間加えれば統合出来るのでしょうけれど)。その上で、Chaining Assertionを使えばMSTestの色々な不満が一気に解消出来て、最高に幸せだなあ、と、自画自賛。

まとめ

当初のIsだけでサクッと軽量なテスト~とかってノリは何処に行ったんでしょうか。ぐぬぬ。それでも、最大限のシンプルさは保ち続けている、と、思いたい。内部がどうあれ、外からはAsDynamic()が足されただけだし、それ自体もシンプルそのものですよね、ね?コンセプトはまだ守れてると、思いたい。

ところで、dynamic使ってます?ぶっちけ全然使ってません。結局のところvarが最高に便利なわけで、dynamicは、例えば以前書いたDynamicJsonであったり、これのようなリフレクションであったりと、通常のC#とは違う場所との糊なわけで、そうそう出番のあるものでもない、ですねん。C#4.0の言語的な追加の最たるものはdynamicなわけですが、普段はそんな使わない代物なわけだと、言語的にはC#4.0はあんま変わらなかったねー、という印象で。LL的な視点から、C#にはdynamicで動的言語でもあるんだって?という意見をたまに見ますが、純C#上ではぶっちゃけほとんど使わないのでそんなでもない。

じゃあなくてもいいか、というと、んー、まあ、このように、たまにある分には便利だし、言語的にもスムースに入り込んでいるので、良いのではないか、むしろ良いのではないか、とは思います。あんま使わないけどたまには思い出してあげると大変可愛い。

そういえば私はメタ構文変数としてhoge, huga, hageの順にhogehogeと使ってるのですが、Twiterでhomuにする。というのを見て、なんかいいな、とか思ってしまったので、当分はhomu, mogu, mamiの順に使おうかと思っている昨今。こーいうのは半年後ぐらいに、あちゃーという気持ちになるのが常なのですがー。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive