導入
ValueObjectは「値そのものが同一性」を持つ型です。1000円は1000円というだけで等しく、どの1000円かを区別する必要はありません。タスクのタイトルも「空文字は不可・50文字まで」というルールを持つ以上、ただのstringのまま各所でチェックするより、専用の型TaskTitleにまとめた方が安全です。
図解
flowchart TB
A["stringのまま各所で<br/>if (title.Length > 50) throw ..."] -->|検証がバラバラで漏れる| X["バグの温床"]
B["TaskTitleに一本化"] -->|生成時に必ず検証| Y["不正な値が存在しえない"]
サンプル
var m1 = new Money(1000);
var m2 = new Money(1000);
Assert(m1.Equals(m2), "同じ金額は同じ値として扱う");
AssertThrows<ArgumentException>(() => new Money(-1), "負の金額は作れない");
Console.WriteLine("全テスト成功");
readonly struct Money : IEquatable<Money>
{
public int Amount { get; }
public Money(int amount)
{
if (amount < 0) throw new ArgumentException("金額は0以上です");
Amount = amount;
}
public bool Equals(Money other) => Amount == other.Amount;
}
void Assert(bool condition, string name)
{
if (!condition) throw new Exception($"FAIL: {name}");
}
void AssertThrows<TException>(Action action, string name) where TException : Exception
{
try
{
action();
throw new Exception($"FAIL: {name}(例外が発生しませんでした)");
}
catch (TException) { }
}
readonly struct+IEquatable<T>で「不変・値で等しい」を表現する- コンストラクタで不正な値を
throwして弾く(生成できた時点で常に正しい状態が保証される) AssertThrows<TException>は「期待どおり例外が発生したか」を確認するテストヘルパー。これ以降、検証ロジックのテストで多用する
演習
契約どおりのTaskTitleをTDDで実装します。
var t1 = new TaskTitle(" 牛乳を買う ");
var t2 = new TaskTitle("牛乳を買う");
Assert(t1.Value == "牛乳を買う", "前後の空白はトリムされる");
Assert(t1.Equals(t2), "同じ文字列なら値として等しい");
AssertThrows<ArgumentException>(() => new TaskTitle(""), "空文字は作れない");
AssertThrows<ArgumentException>(() => new TaskTitle(" "), "空白だけも作れない");
AssertThrows<ArgumentException>(() => new TaskTitle(new string('a', 51)), "51文字は作れない");
Console.WriteLine("全テスト成功");
// TODO: 次のルールを満たす TaskTitle を実装してください
// ・Value(string)を公開する読み取り専用プロパティに持つ
// ・空文字/空白だけの値はArgumentExceptionで拒否する
// ・50文字を超えたらArgumentExceptionで拒否する
// ・前後の空白はトリムして保持する
// ・IEquatable<TaskTitle> を実装し、Valueが同じなら等しいとみなす
void Assert(bool condition, string name)
{
if (!condition) throw new Exception($"FAIL: {name}");
}
void AssertThrows<TException>(Action action, string name) where TException : Exception
{
try
{
action();
throw new Exception($"FAIL: {name}(例外が発生しませんでした)");
}
catch (TException) { }
}
___
- 期待される出力:
全テスト成功
ヒント1を見る
readonly struct TaskTitle : IEquatable<TaskTitle> { public string Value { get; } public TaskTitle(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("タイトルは空にできません"); if (value.Length > 50) throw new ArgumentException("タイトルは50文字以内です"); Value = value.Trim(); } public bool Equals(TaskTitle other) => Value == other.Value; public override string ToString() => Value; }
ヒント2を見る
空白判定はstring.IsNullOrWhiteSpace(value)、整形はvalue.Trim()を使います(Lengthのチェックはトリム前・後どちらでも51文字の例では変わりません)
まとめ
- ValueObjectは値そのものが同一性。不変(
readonly struct)にして、コンストラクタで不正値を拒否する TaskTitleは「空文字禁止・50文字以内・トリムする」という検証ロジックを1か所に閉じ込めたAssertThrowsのように、例外が起きることを確認するテストの書き方も手に入れた
次回: 決まった選択肢から1つを選ぶ値、Priority(優先度)をenumで表現します。