前書き
ここ数日 Twitter で private メンバーに対するテストについての議論を見かけました。
自分のスタンスは
「基本的に public メンバーに対するテストがあれば十分だけど、やんごとなき理由があるなら private メンバーのテストも書けば良い」という感じです。
考えうるやんごとなき理由
- チームの文化
地獄か? - public メンバーからのテストだとめちゃ Assert し辛い
- そのメンバーが業務上超重要なのでマジ絶対どうしてもテスト書きたい
議論の内容や自分の主張は本題ではないので置いておくのですが、一連の TL を見ていてふと「private メソッドはリフレクションでテスト出来るけどローカル関数はテスト出来るのか?」と気になりました。
ローカル関数のテストは今まで試したことが無かったので知見がありませんでした。
この記事は「ローカル関数のテストは可能か否かが気になったので試してみて結果どうだったのか」という記事です。
決して private メンバーやローカル関数のテストを推奨するものではありません。
ローカル関数のテストは出来るのか?どうやってやるのか?
結論から書くとリフレクションで出来ます。
対象のローカル関数を取得する際には MSIL になった時点のメソッド名などを指定する必要があるので、その点に注意する必要があります。
例
クラス、MSIL、テストコード の例を以下に示します。
クラス
namespace AnyProject
{
public sealed class Class1
{
private int InstancePropertyValue => 2;
private int PrivateMethodIncludeLocalFunction()
{
int LocalFunction(int arg)
{
return arg * this.InstancePropertyValue;
}
return LocalFunction(2);
}
private int PrivateMethodIncludeStaticLocalFunction()
{
int StaticLocalFunction(int arg)
{
return arg * 4;
}
return StaticLocalFunction(2);
}
}
}
上記のクラスから生成される MSIL
.class public sealed auto ansi beforefieldinit
AnyProject.Class1
extends [System.Runtime]System.Object
{
.method private hidebysig specialname instance int32
get_InstancePropertyValue() cil managed
{
.maxstack 8
// [11 42 - 11 43]
IL_0000: ldc.i4.2
IL_0001: ret
} // end of method Class1::get_InstancePropertyValue
.method private hidebysig instance int32
PrivateMethodIncludeLocalFunction() cil managed
{
.maxstack 8
// [15 7 - 15 31]
IL_0000: ldarg.0 // this
IL_0001: ldc.i4.2
IL_0002: call instance int32 AnyProject.Class1::'<PrivateMethodIncludeLocalFunction>g__LocalFunction|2_0'(int32)
IL_0007: ret
} // end of method Class1::PrivateMethodIncludeLocalFunction
.method private hidebysig instance int32
PrivateMethodIncludeStaticLocalFunction() cil managed
{
.maxstack 8
// [22 7 - 22 37]
IL_0000: ldc.i4.2
IL_0001: call int32 AnyProject.Class1::'<PrivateMethodIncludeStaticLocalFunction>g__StaticLocalFunction|3_0'(int32)
IL_0006: ret
} // end of method Class1::PrivateMethodIncludeStaticLocalFunction
.method public hidebysig specialname rtspecialname instance void
.ctor() cil managed
{
.maxstack 8
IL_0000: ldarg.0 // this
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: ret
} // end of method Class1::.ctor
.method private hidebysig instance int32
'<PrivateMethodIncludeLocalFunction>g__LocalFunction|2_0'(
int32 arg
) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.maxstack 8
// [17 37 - 17 69]
IL_0000: ldarg.1 // arg
IL_0001: ldarg.0 // this
IL_0002: call instance int32 AnyProject.Class1::get_InstancePropertyValue()
IL_0007: mul
IL_0008: ret
} // end of method Class1::'<PrivateMethodIncludeLocalFunction>g__LocalFunction|2_0'
.method assembly hidebysig static int32
'<PrivateMethodIncludeStaticLocalFunction>g__StaticLocalFunction|3_0'(
int32 arg
) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.maxstack 8
// [24 50 - 24 57]
IL_0000: ldarg.0 // arg
IL_0001: ldc.i4.4
IL_0002: mul
IL_0003: ret
} // end of method Class1::'<PrivateMethodIncludeStaticLocalFunction>g__StaticLocalFunction|3_0'
.property instance int32 InstancePropertyValue()
{
.get instance int32 AnyProject.Class1::get_InstancePropertyValue()
} // end of property Class1::InstancePropertyValue
} // end of class AnyProject.Class1
テストコード
using System;
using System.Reflection;
using AnyProject;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AnyProjectTest
{
[TestClass]
public class Class1Tests
{
[TestMethod]
public void TestLocalFunction()
{
// Arrange
Type targetType = typeof(Class1);
MethodInfo method = targetType.GetMethod(
"<PrivateMethodIncludeLocalFunction>g__LocalFunction|2_0",
BindingFlags.Instance | BindingFlags.NonPublic);
object instance = Activator.CreateInstance(targetType);
var parameters = new object[] { 2 };
// Act
var actual = (int)method.Invoke(instance, parameters);
// Assert
Assert.AreEqual(4, actual);
}
[TestMethod]
public void TestStaticLocalFunction()
{
// Arrange
Type targetType = typeof(Class1);
MethodInfo method = targetType.GetMethod(
"<PrivateMethodIncludeStaticLocalFunction>g__StaticLocalFunction|3_0",
BindingFlags.Static | BindingFlags.NonPublic);
object instance = Activator.CreateInstance(targetType);
var parameters = new object[] { 2 };
// Act
var actual = (int)method.Invoke(instance, parameters);
// Assert
Assert.AreEqual(8, actual);
}
}
}
注意点
前述の通りリフレクションでローカル関数を取得する際は MSIL 上でのメソッド名を指定する必要があります。
ローカル関数の MSIL 上でのメソッド名は自動的に決まるため、クラス内のメンバー定義が変わるとメソッド名も変更される場合があります。
※メソッド名の末尾についている 2_0
や 3_0
などの値がコロコロ変わる。
また、ローカル関数を定義する際に static が明示されていなくても静的ローカル関数として取り扱うことが出来る場合は自動的に static で定義されます。
※ローカル関数 StaticLocalFunction(int)
は static を明示していないにも関わらず MSIL 上では static で定義されている。
そのため
- クラスのメンバー定義が変わる
- MSIL 上のメソッド名が変わる
- メソッドが取得出来ずテストが通らない
といった事態や
- 今まで静的ローカル関数でなかったものが意図せず静的ローカル関数になる
- 指定すべき BindingFlags が変わる
- メソッドが取得出来ずテストが通らない
といった事態に注意する必要があります。
テストコードの保守性は控えめに言って劣悪だと思うのでローカル関数のテストは基本的に避けるべきだと言えます。
以上です。
余談
C# には Internal メンバーを特定のアセンブリに公開する仕組みとして InternalsVisibleToAttribute が有りますが、似たような感じで PrivatesVisibleToAttribute が欲しいです。