はじめに
.NET Coreも3.* になったし、そろそろ.NET Frameworkで作ったプロジェクトも置き換えようと始めてみたのですが、単体テストにPrivateObjectがないことに気がつきました。
あれ? それじゃあプライベートメソッドのテストが出来ないじゃないですか? どういうことですか!?
.NET Core と .NET Standard での単体テストのベスト プラクティス
https://docs.microsoft.com/ja-jp/dotnet/core/testing/unit-testing-best-practices
パブリック メソッドの単体テストを行うことでプライベート メソッドを検証する
ほとんどの場合、プライベート メソッドをテストする必要はありません。 プライベート メソッドは実装の詳細です。 プライベート メソッドは独立して存在することはない、と考えることができます。
ご、ごめんなさい!!
確かにそうですね。プライベートメソッドのテストを書きながら「面倒だなぁ。でもやっておいた方がなんか安心するしなぁ」という感じで作ってました。あと単純にテストのパターン数が減らせるので。1
とはいえ、既に作っちゃったわけで、これからは心を入れ替えて Publicだけでやっていくんで、既存部分だけは何とかならないかなと思うわけです。
一時的な措置ということでリフレクションを使って代替案としたいと思ったのが始まりです。
置き換え案(初期案)
初期に考えたものです。課題点があります。
説明はコメントとして書いておきました。
using System;
using System.Linq;
using System.Reflection;
/// <summary>
/// .Net Coreで消えた PrivateObjectの代替クラス。
/// プライベートメソッドのテストに使用する
/// </summary>
public class PrivateObject
{
private readonly object _instance;
private readonly Type _testTargetType;
/// <summary>
/// コンストラクタの引数が存在しないパターン
/// </summary>
/// <param name="testTargetClass">テスト対象のクラス</param>
public PrivateObject(object testTargetClass)
{
_testTargetType = testTargetClass.GetType(); // テスト対象のタイプを取得
var c = _testTargetType.GetConstructor(Type.EmptyTypes); // コンストラクタ情報を取得して
_instance = c.Invoke(null); // インスタンス化(コンストラクタの引数なし)
SetAllPropertiesToInstance(testTargetClass); // プロパティ類を全部コピー
}
/// <summary>
/// コンストラクタの引数が存在するパターンはこちら。
/// 引数は可変長になっているからいくつ引数があっても大丈夫。
/// </summary>
/// <param name="testTargetClass"></param>
/// <param name="constArg"></param>
public PrivateObject(object testTargetClass, params object[] constArg)
{
_testTargetType = testTargetClass.GetType(); // テスト対象のタイプを取得
// コンストラクタ情の引数の型情報を取得する
var ctors = _testTargetType.GetConstructors();
var ctor = ctors[0];
var t = ctor.GetParameters().Select(a => a.ParameterType).ToArray();
var c = _testTargetType.GetConstructor(t); // コンストラクタ情報を引数を含めて取得して
_instance = c.Invoke(constArg); // 引数込みでインスタンス化
SetAllPropertiesToInstance(testTargetClass); // プロパティ類を全部コピー
}
/// <summary>
/// インスタンスに全てのプロパティ情報をセットする
/// </summary>
/// <param name="testTargetClass"></param>
private void SetAllPropertiesToInstance(object testTargetClass)
{
foreach (var t in _testTargetType.GetProperties())
{
SetPropertyToInstance(testTargetClass, t.Name);
}
}
/// <summary>
/// インスタンスにプロパティ情報をセットする
/// </summary>
/// <param name="obj"></param>
/// <param name="PropertyName"></param>
private void SetPropertyToInstance(object obj, string PropertyName)
{
var prop = _testTargetType.GetProperty(PropertyName); // プロパティ名からプロパティ情報を取得する
var value = prop.GetValue(obj); // プロパティ値を抜き出して、
prop.SetValue(_instance, value); // セットする
}
/// <summary>
/// プライベートメソッドを実行する
/// </summary>
/// <param name="methodName"></param>
/// <param name="arg"></param>
/// <returns></returns>
public object Invoke(string methodName, params object[] arg)
{
// メソッド名からメソッド情報を取得する。
var method = _testTargetType.GetMethod(methodName, BindingFlags.InvokeMethod | BindingFlags.NonPublic | BindingFlags.Instance);
try
{
return method.Invoke(_instance, arg); // メソッドを実行する
}
catch (Exception e)
{
// エラーが発生したら「ここ」のエラー情報じゃなくて発生元のエラーを投げる
throw e.InnerException;
}
}
}
// Act
var privObj = new PrivateObject(testTargetClass);
var result = privObj.Invoke("PrivateMethod", arg1, arg2);
// Act
var privObj = new PrivateObject(testTargetClass, constArg1, constArg2);
var result = privObj.Invoke("PrivateMethod", arg1, arg2);
課題点
お気づきのように、テスト対象のコンストラクタに引数がない場合は良いのですが、ある場合はPrivateObjectのインスタンス化時に、その引数も渡してあげる必要がある状態となっています。
それを無くすためにはテストオブジェクト対象の内容をそのままコピーすることが出来ればいいわけで、原理的には出来るはずだと思うのですが(.NET Frameworkなら出来ていたわけで)、生憎と今の私の拙いリフレクション知識ではこれが精一杯です。これだけでも大分軽減できたのでまずは十分です。
置き換え案2(課題点解決)
色々調べたところ、InvokeMemberを使えば解決できるということが分かりました。
using System;
using System.Reflection;
/// <summary>
/// .Net Coreで消えた PrivateObjectの代替クラス。
/// プライベートメソッドのテストに使用する
/// </summary>
public class PrivateObject
{
private readonly object _obj;
public PrivateObject(object obj)
{
_obj = obj;
}
public object Invoke(string methodName, params object[] args)
{
var type = _obj.GetType();
var bindingFlags = BindingFlags.InvokeMethod | BindingFlags.NonPublic | BindingFlags.Instance;
try
{
return type.InvokeMember(methodName, bindingFlags, null, _obj, args);
}
catch (Exception e)
{
throw e.InnerException;
}
}
}
シンプルで良し!
もちろん元々のを完全に再現しているわけではないので、色々と足りないところはありますが、まぁ十分かなと。
こういうメタプログラミングも学んでいかないといけないですね。
参考
- C#リフレクションTIPS 55連発
- ConstructorInfoを使ってコンストラクタを呼び出し、インスタンスを作成する
- Name of the constructor arguments in c#
- Type.InvokeMemberを使ってメンバの呼び出しを行う
-
publicで3分岐、privateで4分岐があったとしたら、privateのテストもやれば3+4の足し算で済むけど、publicのみだと、3×4の乗算になるので。 ↩