今天文章的內容是參考於 C# - how to inject, mock or stub DateTime for unit tests,今天為接續後面三個方法的探討。
我們在 Day-20 曾提到 Roy Osherove 在單元測試的藝術提過區域工廠的概念,其意思是在被測試類別針對要被抽離的物件撰寫一個區域工廠方法,在寫商業邏輯的時候可呼叫該方法新增物件。如此在撰寫測試的時候,可寫一個類別繼承該類別並改寫這個工廠方法的內容,已達到假物件注入的手法,同樣的手法也可適用在 DateTime.Now,程式碼如下:
public class Decision
{
public string WhatToDo()
{
var currentDateTime = GetDateTime();
if (currentDateTime.Hour > 8 && currentDateTime.Hour < 18)
{
return Work();
}
else if (currentDateTime.Hour > 18 && currentDateTime.Hour < 22)
{
return Exercise();
}
else
{
return Sleep();
}
}
protected virtual DateTime GetDateTime()
{
return DateTime.Now;
}
private string Work()
{
return "Work!";
}
private string Exercise()
{
return "Exercise!";
}
private string Sleep()
{
return "Sleep!";
}
}
因此,我們就可以寫一隻繼承該類別的含假物件類別(擷取與覆寫),如下:
public class StubDecision : Decision
{
private readonly DateTime thisDateTime;
public StubDecision(DateTime inThisDateTime)
{
thisDateTime = inThisDateTime;
}
protected override DateTime GetDateTime()
{
return thisDateTime;
}
}
好,那最後就是測試碼:
[Test]
public void WorkTest()
{
// Arrange
var decision = new StubDecision(new DateTime(2020, 01, 01, 08, 00, 00));
// Act
var whatToDo = decision.WhatToDo();
// Assert
Assert.Equal("Work!", whatToDo);
}
[Test]
public void ExerciseTest()
{
// Arrange
var decision = new StubDecision(new DateTime(2020, 01, 01, 18, 00, 00));
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
[Test]
public void SleepTest()
{
// Arrange
var decision = new StubDecision(new DateTime(2020, 01, 01, 23, 00, 00));
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
本方法是採用 C# Func 的寫法,Func 是委派手法中有回傳值的(若想了解什麼是委派可查詢關鍵字 delegate),因次我們可以在建構函式的時候 Func 手法,在撰寫商業邏輯時,程式碼中代入真實時間,而在測試時,透過委派的手法給予假時間,程式碼如下:
public class Decision
{
public string WhatToDo(Func<DateTime> getCurrentDateTime = null)
{
var currentDateTime = getCurrentDateTime == null ? DateTime.Now : getCurrentDateTime();
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!";
}
}
於是乎,在測試的時候就可以透過 Lambda 語法注入虛設常式,測試碼如下:
[Test]
public void WorkTest()
{
// Arrange
var decision = new Decision();
DateTimeWrapper.Set(new DateTime(2020, 01, 01, 08, 00, 00));
// Act
var whatToDo = decision.WhatToDo(() => new DateTime(2020, 01, 01, 10, 00, 00));
// Assert
Assert.Equal("Work!", whatToDo);
}
[Test]
public void ExerciseTest()
{
// Arrange
var decision = new Decision();
DateTimeWrapper.Set(new DateTime(2020, 01, 01, 18, 00, 00));
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
[Test]
public void SleepTest()
{
// Arrange
var decision = new Decision();
DateTimeWrapper.Set(new DateTime(2020, 01, 01, 23, 00, 00));
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
那最後一種寫法是使用 static 的方式,相較其他四種方式其缺點較明顯如測試不可同時進行,在最後需要做 Reset 的動作等。不過,因其撰寫手法簡單,相信許多歷史較悠久的測試碼可以看到其蹤影,所以還是值得觀看其手法,並理解後可改寫其他方式,來看看其原始碼:
public class DateTimeWrapper{
private static DateTime? dateTime;
public static DateTime Now { get { return dateTime ?? DateTime.Now; } }
public static void Set(DateTime setDateTime)
{
dateTime = setDateTime;
}
public static void Reset()
{
dateTime = null;
}
}
public class Decision
{
public string WhatToDo()
{
var currentDateTime = DateTimeWrapper.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!";
}
}
可以看出,我們呼叫 Now 是檢查 DateTimeWrapper 類別裡面 static dateTime 屬性有沒有值,沒有的話即呼叫現在的時間,而測試碼如下:
[Test]
public void WorkTest()
{
// Arrange
var decision = new Decision();
DateTimeWrapper.Set(new DateTime(2020, 01, 01, 08, 00, 00));
// Act
var whatToDo = decision.WhatToDo();
// Assert
Assert.Equal("Work!", whatToDo);
}
[Test]
public void ExerciseTest()
{
// Arrange
var decision = new Decision();
DateTimeWrapper.Set(new DateTime(2020, 01, 01, 18, 00, 00));
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
[Test]
public void SleepTest()
{
// Arrange
var decision = new Decision();
DateTimeWrapper.Set(new DateTime(2020, 01, 01, 23, 00, 00));
// Act + Assert
// ... 結構與 WorkTest 都一樣
}
要注意的是,使用 static 方法要在最後撰寫 TearDown 的方法,如下:
[TearDown]
public void TearDown()
{
// 將 dateTime 重新設定為 null
DateTimeWrapper.Reset();
}
不然下個測試如果沒有撰寫好設定時間,會用上一個測試的時間去跑結果 。
其實這五個方法看下來,大多就是在探討如何接縫的問題,不同的手法都可以達到注入假物件的概念;除了第五個 static 手法考慮在記憶體有限的狀況下可以使用,若平常沒有記憶體的考量,大多採用其他四種。其中,若要程式碼簡潔且易擴充,可使用第二種假物件框架的手法;而重構時,大多可採用第三種策略,繼承完並覆寫的手法去撰寫 Legacy Code 的測試碼。