きなこもち.net

.NET Framework × UiPath,Orchestrator × Azure × AWS × Angularなどの忘備録

dotnet core × Xunit × MemberDataを使ったテストの実装方法を改善してみた

goal

XUnitを使ったテストにおいて、MemberDataを使い、データドリブンテストを行ったとき、Testエクスプローラーで1件のテストとして認識されるのを何とかしたい。
つまり、データごとのテストとして認識されたい。

前提

テストデータとして、複雑なオブジェクトを想定する。
プリミティブ型の場合、標準の方法でやりたいことが実現できているので、そのケースは対象外とする。

テストデータの実装例は以下の通り。

public class TestData
{
    public int IntProp { get; set; }
    public string StringProp { get; set; }

    public InnerTestData MyProperty { get; set; } = new InnerTestData();
    public class InnerTestData
    {
        public int IntProp { get; set; }
        public string StringProp { get; set; }
    }
}

パターン1

ポイント

基本的な使い方に従い作成した。

実装例

public static IEnumerable<object[]> InData
{
    get
    {
        yield return new object[] {
        new TestData(),
        "expected"
        };
        yield return new object[] {
        new TestData(),
        "expected"
        };
    }
}

[Theory]
[MemberData(nameof(InData))]
public void Test1(TestData testData, string expected)
{
    Assert.True(true);
}

エクスプローラーの表示例

TestProject1.UnitTest1.Test1(testData: TestData { IntProp = 0, MyProperty = InnerTestData { IntProp = 0, StringProp = null }, StringProp = null }, expected: "expected") 成功    < 1 ミリ秒        

実際のテストケースは2件あるが、1件のテストとしてまとめられた。こうなると、どちらか片方だけを指定してテストを流したい、Debugしたいと思ったときに、それができず不便。

パターン2

ポイント

stackoverflowを参考に、'MemberDataSerializer'クラスを定義し、それを使ったテストにした。

実装例

public static IEnumerable<object[]> TestData()
{
    yield return new object[] { 
        "test1", new MemberDataSerializer<TestData>(new TestData()) 
        };
    yield return new object[] { 
        "test2", new MemberDataSerializer<TestData>(new TestData()) 
        };
}

[Theory]
[MemberData(nameof(TestData))]
public void Test2(string testName, MemberDataSerializer<TestData> testCase)
{
    Assert.True(true);
}

エクスプローラーの表示例

テスト グループ: TestProject1.UnitTest1.Test2 成功 (2)  6 ミリ秒     
TestProject1.UnitTest1.Test2(testName: "test1", testCase: MemberDataSerializer`1 { Object = TestData { IntProp = 0, MyProperty = InnerTestData { ... }, StringProp = null } }) 成功    < 1 ミリ秒        
TestProject1.UnitTest1.Test2(testName: "test2", testCase: MemberDataSerializer`1 { Object = TestData { IntProp = 0, MyProperty = InnerTestData { ... }, StringProp = null } }) 成功    6 ミリ秒     

一つ一つのデータがテストケースとして認識されるようになった。
MemberDataSerializerクラスの定義が必要だが、それさえあればシンプルに実装ができる。
テストデータとして渡すオブジェクトが複雑になったとき、それだけではテストケースの概要をつかめなくなりそうなので、テストケース名か、テストの短い概要を渡すようにするとよいかもしれない。

パターン3

テストケースで利用するデータをDictionary型のコレクションに格納しておく。データドリブンテストとしては、それぞれのテストケースのKey値を定義し、使うようにする。
テストメソッドの中で、Keyを使ってテストデータや、期待値を取得することで、やりたいことを実現している。

実装例

public static Dictionary<string, object[]> TestCases = new Dictionary<string, object[]>()
{
    {
        "TestCase3-1", 
        new object[]{new TestData(),"Expected" } 
    },
    {
        "TestCase3-2", 
        new object[]{new TestData(),"Expected" } 
    }
};

public static IEnumerable<Object[]> TestData3()
{
    for (int i = 1; i <= TestCases.Count; i++)
    {
        yield return new object[] { $"TestCase3-{i}" };
    }
}

[Theory]
[MemberData(nameof(TestData3))]
public void Test3(string testCase)
{
    var testData = TestCases[testCase][0] as TestData;
    var expected = TestCases[testCase][1] as string;
    Assert.True(true);
}

エクスプローラーの表示例

テスト グループ: TestProject1.UnitTest1.Test3 成功 (2)  < 1 ミリ秒        
TestProject1.UnitTest1.Test3(testCase: "TestCase3-1") 成功    < 1 ミリ秒        
TestProject1.UnitTest1.Test3(testCase: "TestCase3-2") 成功    < 1 ミリ秒

このパターンでも、データごとにテストケースとして認識された。
パターン2に比べ、表示内容がシンプルになるので、場合によってはテストケースを見つけやすいかもしれない。

比較

  • パターン1は論外。
  • 実装量は、パターン2が少なくてよさそう。
  • テストエクスプローラーの表示的には、シンプルな表示ができるパターン3もよいが、拡張性まで考えるとパターン2がよさそう。
  • テストの複雑さについては、データのキャストなどの手間を省けるため、パターン2が優勢。

結論:パターン2を採用する。

参考

public class MemberDataSerializer<T> : IXunitSerializable where T : new()
{
    public T Object { get; private set; }

    public MemberDataSerializer()
    {
        Object = new T();
    }

    public MemberDataSerializer(T objectToSerialize)
    {
        Object = objectToSerialize;
    }

    public void Deserialize(IXunitSerializationInfo info)
    {
        if (info == null) throw new InvalidOperationException();

        var data = info.GetValue<string>("objValue");
        if (data == null) throw new InvalidOperationException();

        var objectData = JsonConvert.DeserializeObject<T>(data);
        if (objectData == null) throw new InvalidOperationException();

        Object = objectData;
    }

    public void Serialize(IXunitSerializationInfo info)
    {
        var json = JsonConvert.SerializeObject(Object);
        info.AddValue("objValue", json);
    }
}