今天文章的內容是參考於 C# - how to inject, mock or stub DateTime for unit tests,在 C# 中 DateTime 是專門幫我們處理時間的資料結構,其好處是可以幫我們紀錄年月日時分秒,也可以透過程式呼叫現在的時間點,但呼叫現在的時間這點會成為單元測試中不穩定的因素;因此,今天的議題就是來探討如何在撰寫 DateTime.Now 的情境中抽離並驗證其商業邏輯是否正確,以下為使用 DateTime.Now 的範例:
public class Decision
{
public string WhatToDo()
{
var currentDateTime = DateTime.Now;
if (currentDateTime.Hour > 8 && currentDateTime.Hour < 18)
{
return Work();
}
else if (currentDateTime.Hour > 18 && currentDateTime.Hour < 22)
{
return Exercise();
}
else
{
return Sleep();
}
}
private string Work()
{
return "Work!";
}
private string Exercise()
{
return "Exercise!";
}
private string Sleep()
{
return "Sleep!";
}
}
而截選本文主要原因是裡面運用了寫實務上系統很常用的 DateTime.Now 外,另一方面也提出了許多接縫的手法。所以接下來來探討如何抽離 DateTime.Now 的手法吧!而本文提出了五種方向,如下:
那我們逐一來看吧~
第一個為新增時間包覆器的類別 —— DateTimeWrapper,裡面包含 thisDateTime 屬性、兩種建構函式與 Now 方法,如下:
public class DateTimeWrapper
{
private DateTime? thisDateTime;
public DateTimeWrapper()
{
thisDateTime = null;
}
public DateTimeWrapper(DateTime fixedDateTime)
{
thisDateTime = fixedDateTime;
}
public DateTime Now { get { return thisDateTime ?? DateTime.Now; } }
}
因此,就可以開始撰寫商業邏輯,如下:
public class Decision
{
private readonly DateTimeWrapper thisDateTimeWrapper;
public Decision(DateTimeWrapper inThisDateTimeWrapper)
{
thisDateTimeWrapper = inThisDateTimeWrapper;
}
public string WhatToDo()
{
var currentDateTime = thisDateTimeWrapper.Now;
if (currentDateTime.Hour > 8 && currentDateTime.Hour < 18)
{
return Work();
}
else if (currentDateTime.Hour > 18 && currentDateTime.Hour < 22)
{
return Exercise();
}
else
{
return Sleep();
}
}
private string Work()
{
return "Work!";
}
private string Exercise()
{
return "Exercise!";
}
private string Sleep()
{
return "Sleep!";
}
}
[Test]
public void WorkTest()
{
// Arrange
var dateTimeWrapper = new DateTimeWrapper(new DateTime(2020, 01, 01, 08, 00, 00));
var decision = new Decision(dateTimeWrapper);
// Act
var whatToDo = decision.WhatToDo();
// Assert
Assert.Equal("Work!", whatToDo);
}
[Test]
public void ExerciseTest()
{
// Arrange
var dateTimeWrapper = new DateTimeWrapper(new DateTime(2020, 01, 01, 18, 00, 00));
var decision = new Decision(dateTimeWrapper);
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
[Test]
public void SleepTest()
{
// Arrange
var dateTimeWrapper = new DateTimeWrapper(new DateTime(2020, 01, 01, 23, 00, 00));
var decision = new Decision(dateTimeWrapper);
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
這樣寫好處在於,若處理既有程式碼,可更動少量的程式碼即達到做為測試的接縫。
第二個為假物件框架 (NSubstitute),就要提到我們最愛提的把商業邏輯介面化,從前言可看出 DateTime.Now 已經和 WhatToDo 耦合再一起,若要使用虛設常式抽離並實作,則先需新增一個時間的介面,如下:
public interface IDateTimeWrapper
{
public DateTime Now { get { return DateTime.Now; } }
}
public class DateTimeWrapper : IDateTimeWrapper {}
因此,商業邏輯就改寫如下:
public class Decision
{
private readonly IDateTimeWrapper DateTimeWrapper;
public Decision(IDateTimeWrapper inDateTimeWrapper)
{
DateTimeWrapper = inDateTimeWrapper;
}
public string WhatToDo()
{
var currentDateTime = DateTimeWrapper.Now;
// ... 後面都一樣
}
// ... 後面都一樣
}
因此,就可以撰寫測試碼,而撰寫測試碼時,IDateTimeWrapper 的 Now 就可利用 NSubstitute 中的 Returns 注入相對應的屬性,如下:
[Test]
public void WorkTest()
{
// Arrange
var dateTimeWrapper = Substitute.For<IDateTimeWrapper>();
dateTimeWrapper.Now.Returns(new DateTime(2020, 01, 01, 08, 00, 00));
var decision = new Decision(dateTimeWrapper);
// Act
var whatToDo = decision.WhatToDo();
// Assert
Assert.Equal("Work!", whatToDo);
}
[Test]
public void ExerciseTest()
{
// Arrange
var dateTimeWrapper = Substitute.For<IDateTimeWrapper>();
dateTimeWrapper.Now.Returns(new DateTime(2020, 01, 01, 18, 00, 00));
var decision = new Decision(dateTimeWrapper);
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
[Test]
public void SleepTest()
{
// Arrange
var dateTimeWrapper = Substitute.For<IDateTimeWrapper>();
dateTimeWrapper.Now.Returns(new DateTime(2020, 01, 01, 23, 00, 00));
var decision = new Decision(dateTimeWrapper);
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
這種撰寫方式與我們先前所寫的 Code Style 比較一致,且不需要手刻虛設常式,程式碼較簡潔。
那因文章篇幅,其他的方式會在明天一一說明,並統整比較五個方法之前的效益。