本シリーズ
単体テストと副作用 - 第1話 はじまり
単体テストと副作用 - 第2話 オブジェクト指向
単体テストと副作用 - 第3話 DIとモック
単体テストと副作用 - 第4話 副作用の回避
単体テストと副作用 - 第5話 オブジェクト指向への反乱
前回のあらすじ
- 女神さまからの宿題、テストフレームワーク、テストダブル、依存性の注入について調べた。
- DIとモックオブジェクトを使用することにより、副作用を回避できる?
お題おさらい
- カンマ区切りの文字列を、標準入力から受け取る。
- 受け取った文字列をカンマで分割し、各値のデータ型を調べる。
- 空白または
null
のデータ型はnull
。 -
true
またはfalse
のデータ型はbool
。 - 数値のデータ型は
number
。 - それ以外の値のデータ型は
string
。
- 空白または
- 各値を
値 : データ型
という形式で、標準出力に1秒間隔で表示する。
DIを意識した設計
今度は失敗しないお(・ω・)
後で副作用をモックオブジェクトにすり替えることを意識して書くお。
まずはデータ型の種類を定義。
namespace TestableSample3.Lib
{
public enum TypeKind
{
Null,
Bool,
Number,
String
}
}
続いて、値とデータ型の組である TypedValue
を作る。
今度はコンソール出力を排除するお。
using System;
using System.Text.RegularExpressions;
namespace TestableSample3.Lib
{
public class TypedValue
{
public string Value { get; }
public TypeKind TypeKind { get; }
public TypedValue(string value)
{
Value = value;
if (Regex.IsMatch(value, @"^(|null)$"))
{
TypeKind = TypeKind.Null;
}
else if (Regex.IsMatch(value, @"^(true|false)$"))
{
TypeKind = TypeKind.Bool;
}
else if (Regex.IsMatch(value, @"^-?[0-9]+(\.[0-9]+)?$"))
{
TypeKind = TypeKind.Number;
}
else
{
TypeKind = TypeKind.String;
}
}
public override string ToString()
{
return $"{Value} : {ToString(TypeKind)}";
}
private string ToString(TypeKind typeKind)
{
switch (typeKind)
{
case TypeKind.Null:
return "null";
case TypeKind.Bool:
return "bool";
case TypeKind.Number:
return "number";
case TypeKind.String:
return "string";
default:
return "";
}
}
}
}
ここからは、副作用を持つ機能を定義してゆく。
まずはインターフェイスから。
namespace TestableSample3.Lib
{
public interface IConsoleWrapper
{
string ReadLine (string message);
void WriteLine(string str);
}
}
using System.Threading.Tasks;
namespace TestableSample3.Lib
{
public interface ITaskWrapper
{
Task Delay(int milliseconds);
}
}
そして、具象クラスを記述する。
using System;
namespace TestableSample3.Lib
{
public class ConsoleWrapper : IConsoleWrapper
{
public string ReadLine(string message)
{
if (!string.IsNullOrEmpty(message))
{
Console.Write(message);
}
return Console.ReadLine();
}
public void WriteLine(string str)
{
Console.WriteLine(str);
}
}
}
using System.Threading.Tasks;
namespace TestableSample3.Lib
{
public class TaskWrapper : ITaskWrapper
{
public Task Delay(int milliseconds)
{
return Task.Delay(milliseconds);
}
}
}
これで、一通りの機能が揃った。
では、これらを使うプログラム全体の流れを書いてみる。
今回は、コンソール入出力や指定時間待機を直接呼ばず、さっき定義したインターフェイスのみを使用するよ。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Unity;
using TestableSample3.Lib;
namespace TestableSample3.App
{
public class MainFlow
{
[Dependency]
public IConsoleWrapper ConsoleWrapper { get; set; }
[Dependency]
public ITaskWrapper TaskWrapper { get; set; }
public async Task Run()
{
var typedValues = ReadTypedValues();
foreach (var tv in typedValues)
{
await TaskWrapper.Delay(1000);
ConsoleWrapper.WriteLine(tv.ToString());
}
}
private IEnumerable<TypedValue> ReadTypedValues()
{
var valueStrs = ConsoleWrapper.ReadLine("カンマ区切りの値を入力:");
return valueStrs.Split(",", StringSplitOptions.None)
.Select(x => new TypedValue(x));
}
}
}
using System.Threading.Tasks;
using Unity;
using TestableSample3.Lib;
namespace TestableSample3.App
{
internal class Program
{
private static async Task Main(string[] args)
{
var container = new UnityContainer();
container.RegisterType<IConsoleWrapper, ConsoleWrapper>();
container.RegisterType<ITaskWrapper , TaskWrapper >();
container.RegisterType<MainFlow>();
var mainFlow = container.Resolve<MainFlow>();
await mainFlow.Run();
}
}
}
これでプログラムは完成したお。
後はテストだけだね。
まずは TypedValue
のテスト。
前回できなかった文字列表現のテストもできるよ。
using System.Collections.Generic;
using Xunit;
using TestableSample3.Lib;
namespace TestableSample3.Test
{
public class Test_TypedValue
{
[Theory]
[MemberData(nameof(Constructor_Data))]
public void Constructor(TypeKind expected, string value)
{
var typedValue = new TypedValue(value);
Assert.Equal(value , typedValue.Value);
Assert.Equal(expected, typedValue.TypeKind);
}
public static IEnumerable<object[]> Constructor_Data()
{
return new[] {
new object[] { TypeKind.Null , "" },
new object[] { TypeKind.Null , "null" },
new object[] { TypeKind.Bool , "true" },
new object[] { TypeKind.Bool , "false" },
new object[] { TypeKind.Number, "0.0" },
new object[] { TypeKind.Number, "789" },
new object[] { TypeKind.Number, "-12.345" },
new object[] { TypeKind.String, "a123" },
new object[] { TypeKind.String, "$#@?+" }
};
}
[Theory]
[MemberData(nameof(ToString_Data))]
public void ToString_(string expectedValue, string expectedType, string value)
{
var typedValue = new TypedValue(value);
var str = typedValue.ToString();
Assert.Equal($"{expectedValue} : {expectedType}", str);
}
public static IEnumerable<object[]> ToString_Data()
{
return new[]
{
new object[] { "" , "null" , "" },
new object[] { "null" , "null" , "null" },
new object[] { "true" , "bool" , "true" },
new object[] { "false" , "bool" , "false" },
new object[] { "0.0" , "number", "0.0" },
new object[] { "789" , "number", "789" },
new object[] { "-12.345", "number", "-12.345" },
new object[] { "a123" , "string", "a123" },
new object[] { "$#@?+" , "string", "$#@?+" }
};
}
}
}
最後に、前回全くテストできなかった MainFlow
のテストをやるお。
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Moq;
using Unity;
using Xunit;
using TestableSample3.App;
using TestableSample3.Lib;
namespace TestableSample3.Test
{
public class Test_MainFlow
{
private MainFlow MainFlow { get; }
private Mock<IConsoleWrapper> ConsoleWrapperMock { get; }
private Mock<ITaskWrapper > TaskWrapperMock { get; }
public Test_MainFlow()
{
ConsoleWrapperMock = new Mock<IConsoleWrapper>();
TaskWrapperMock = new Mock<ITaskWrapper>();
var container = new UnityContainer();
container.RegisterInstance<IConsoleWrapper>(ConsoleWrapperMock.Object);
container.RegisterInstance<ITaskWrapper >(TaskWrapperMock .Object);
container.RegisterType<MainFlow>();
MainFlow = container.Resolve<MainFlow>();
}
[Theory]
[MemberData(nameof(Run_Data))]
public async Task Run(IEnumerable<TypedValue> expecteds, string value)
{
ConsoleWrapperMock.Setup(x => x.ReadLine(It.IsAny<string>()))
.Returns<string>(x => value);
TaskWrapperMock.Setup(x => x.Delay(It.IsAny<int>()))
.Returns<int>(x => Task.CompletedTask);
await MainFlow.Run();
var count = expecteds.Count();
TaskWrapperMock .Verify(x => x.Delay (It.IsAny<int >()), Times.Exactly(count));
ConsoleWrapperMock.Verify(x => x.WriteLine(It.IsAny<string>()), Times.Exactly(count));
foreach (var e in expecteds)
{
ConsoleWrapperMock.Verify(x => x.WriteLine(e.ToString()), Times.Once);
}
}
public static IEnumerable<object[]> Run_Data()
{
return new[]
{
new object[]
{
new[]
{
new TypedValue(""),
new TypedValue("null"),
new TypedValue("true"),
new TypedValue("false"),
new TypedValue("-1"),
new TypedValue("2.3"),
new TypedValue(" "),
new TypedValue("abcde")
},
",null,true,false,-1,2.3, ,abcde"
},
};
}
}
}
(^ω^)
副作用を完全に回避できたお。
もしも副作用の機能をテストしたいなら、ConsoleWrapper
と TaskWrapper
を個別にテストすれば事足りるね。
やったお!
単体テスト完全に理解したお(・∀・)!
『…きこえますか…今…あなたの頭の中の空洞に…直接呼びかけています…』
とうとう脳味噌すら無くなったお…(´・ω・`)
『…今回のお題は基礎に過ぎません…いい気にならないでください…』
それは薄々感じているので、大丈夫だお(´・ω・`)
『…次回はいよいよ最後の審判…最終回です…。愚かな人の子よ…反乱です…反乱を起こすのです…』
なんか物騒なことを言い出したお。
何に対して反乱を起こせばいいのかな(・ω・)?
『…あなた方の慣れ親しんだ概念…』
『…C#とは切り離せない…重要なパラダイム…』
『…"オブジェクト指向"に…反乱を起こすのです…』
~ 次回へ続く ~
ここまでのまとめ
- DIとモックオブジェクトを使用して、副作用を回避することに成功した。
- オブジェクト指向への反乱とは?
本シリーズ
単体テストと副作用 - 第1話 はじまり
単体テストと副作用 - 第2話 オブジェクト指向
単体テストと副作用 - 第3話 DIとモック
単体テストと副作用 - 第4話 副作用の回避
単体テストと副作用 - 第5話 オブジェクト指向への反乱