本シリーズ
単体テストと副作用 - 第1話 はじまり
単体テストと副作用 - 第2話 オブジェクト指向
単体テストと副作用 - 第3話 DIとモック
単体テストと副作用 - 第4話 副作用の回避
単体テストと副作用 - 第5話 オブジェクト指向への反乱
前回のあらすじ
-
Main
関数にベタ書きでは、テストコードが書けなかった。 - 副作用を意識すべし。
- 次はにオブジェクト指向っぽく書いてみる。
お題おさらい
- カンマ区切りの文字列を、標準入力から受け取る。
- 受け取った文字列をカンマで分割し、各値のデータ型を調べる。
- 空白または
null
のデータ型はnull
。 -
true
またはfalse
のデータ型はbool
。 - 数値のデータ型は
number
。 - それ以外の値のデータ型は
string
。
- 空白または
- 各値を
値 : データ型
という形式で、標準出力に1秒間隔で表示する。
オブジェクト指向?
オブジェクト指向といっても、完璧にできる自信無いお(´・ω・`)
まあ、何となく適当にやってみよう…。
まずは、データ型を定義してみる。
namespace TestableSample2.App
{
public enum TypeKind
{
Null,
Bool,
Number,
String
}
}
続いて、値とデータ型の組をクラスにしよう。
using System;
using System.Text.RegularExpressions;
namespace TestableSample2.App
{
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 void WriteLine()
{
Console.WriteLine($"{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 "";
}
}
}
}
そうしたら、プログラムの流れを書いて、と。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace TestableSample2.App
{
public static class MainFlow
{
public static async Task Run()
{
var typedValues = ReadTypedValues();
foreach (var tv in typedValues)
{
await Task.Delay(1000);
tv.WriteLine();
}
}
private static IEnumerable<TypedValue> ReadTypedValues()
{
Console.Write("カンマ区切りの値を入力:");
var valueStrs = Console.ReadLine();
return valueStrs.Split(",", StringSplitOptions.None)
.Select(x => new TypedValue(x));
}
}
}
using System.Threading.Tasks;
namespace TestableSample2.App
{
internal class Program
{
private static async Task Main(string[] args)
{
await MainFlow.Run();
}
}
}
これで、テストコードが書けるようになったのだろうか…?
using System.Collections.Generic;
using Xunit;
using TestableSample2.App;
namespace TestableSample2.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, "$#@?+" }
};
}
}
}
(^ω^)
ここでは、テストフレームワークは「xUnit.net」を使っている。
MemberData
属性で指定された Constructor_Data
メソッドは、テストである Constructor
メソッドに引数を渡す役割がある。
ここで定義された9個の配列の分だけテストが走り、全て通った。
やったお。
テストコードが書けたお(^ω^)
女神さま、見ていてくれたかな。
『…きこえますか…今…あなたのゾウリムシ並みの脳味噌に…直接呼びかけています…』
ゾウリムシって単細胞生物では…(´・ω・`)
『…あなたの書いたコードには…2つの欠陥があります…。責務が分離できていないこと…。副作用を回避できていないこと…。では…順を追って見てゆきましょう…』
責務の分離
『…TypedValue
クラスを見てください…この部分です…』
//...
public void WriteLine()
{
Console.WriteLine($"{Value} : {ToString(TypeKind)}");
}
//...
『…TypedValue
クラスは何のためのクラスですか…?』
えっと…。
TypedValue
クラスは、文字列値とそのデータ型の情報を持ったクラスだお。
コンストラクタでは文字列値を受け取って、データ型を自動で判別してる。
あとは、文字列表現を生成する機能もあるね。
『…このクラスは…値とデータ型の組の情報と…それにまつわる機能で構成されるべきです…。不要な責務は…排除しなくてはなりません…』
ふむ。
つまり、上述のコンソール出力は、このクラスの責務ではない、と。
『…その通りです…。更に言えば…このコンソール出力は副作用です…。この種の副作用は…テストコードではテストできません…』
そういえば、TypedValue
の文字列表現に関するテストが書けてないお…(´・ω・`)
『…テストを妨げる副作用は…何としても回避するのです…』
副作用の回避?
『…前述の通り…副作用はテストが難しいか…または全くテストできません…。副作用の塊である MainFlow
クラスを見てみましょう…』
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace TestableSample2.App
{
public static class MainFlow
{
public static async Task Run()
{
var typedValues = ReadTypedValues();
foreach (var tv in typedValues)
{
await Task.Delay(1000);
tv.WriteLine();
}
}
private static IEnumerable<TypedValue> ReadTypedValues()
{
Console.Write("カンマ区切りの値を入力:");
var valueStrs = Console.ReadLine();
return valueStrs.Split(",", StringSplitOptions.None)
.Select(x => new TypedValue(x));
}
}
}
この MainFlow
クラスは、プログラム全体の流れを記述する static なクラスだお。
標準入力から文字列を受け取り、TypedValue
に加工して、1秒ごとに標準出力する。
『…TypedValue
への変換以外は副作用ですね…』
そっか。
だから全くテストできなかったんだね。
…?
さっき女神さまが言った、「副作用の回避」ってどうやるんだお(´・ω・`)?
他のクラスに副作用のある責務をまとめたとして、その機能を使ったら、使った側のクラスもテストできなくなるんでは…?
『…あなたに"3種の神器"を与えます…。即ち…"テストフレームワーク"…"テストダブル"…"依存性の注入"です…。次回までに…使い方を覚えてくるのです…』
宿題が出たお(・ω・)
~ 次回へ続く ~
ここまでのまとめ
- オブジェクト指向は責務を意識すると、機能を明確に分離しやすい。
- 副作用を回避する方法がある?
- そのためには、テストフレームワーク、テストダブル、依存性の注入を覚える必要がある。
本シリーズ
単体テストと副作用 - 第1話 はじまり
単体テストと副作用 - 第2話 オブジェクト指向
単体テストと副作用 - 第3話 DIとモック
単体テストと副作用 - 第4話 副作用の回避
単体テストと副作用 - 第5話 オブジェクト指向への反乱