使用NSubstitute建立測試替身
前言
最近對許多前輩TDD文章還滿感興趣,但筆者其實對單元測試的經驗值還不大足夠,因此就先從如何做單元測試這件事情開始起步吧!以下將就筆者目前使用的架構,記錄一下如何使用NSubstitute來建立測試分身,解除測試單元對於外部資料(資料庫)的依賴,如觀念不正確就勞煩前輩們指導一下囉!
系統架構
目前系統使用三層式架構,區分使用介面層(Presentation Layer)、商業邏輯層(Business Logic Layer)以及資料存取層(Data Access Layer)來各司其職。資料存取是使用Entity Framework來做 OR-Mapping,利用泛型IRepository<T>來鬆綁與資料庫耦合的問題,以此作為商業邏輯層與資料存取層的邊界介面,並建立通用GenericRepository<T>來負責進出資料庫;而此處將IRepository<T>建立於商業邏輯層是希望利用控制反轉(IoC)特性保持抽換資料存取層的彈性,讓Service不直接相依任何實體物件,僅相依於介面。最後搭配Unity負責各層相依物件的注入(Injection)工作及特定物件生命週期的管控。架構示意圖如下所示。
測試概念
當要對Service進行單元測試時,由於在Service邏輯中可能會使用GerericRepository<T>實體來進出資料庫,但在單元測試時的關注點會是在邏輯本身的正確性,並非DB資料是否可以順利獲得;因此我們可利用 NSubstitute 來產出 MockRepository<T> 實體作為 GerericRepository<T> 測試分身,並可指定分身被Service叫用的方法回傳特定資料(模擬DB資料),以利後續測試邏輯的正確性。最後將GerericRepository<T>之測試分身MockRepository<T> 注入待測Service後,即可開始進行測試。
實例說明
舉個簡單的例子來演練一下上述想法。首先使用者的需求是這樣,當銀行在作業時常常都是以工作天來計算日期,例如以比價日 + N個工作天來取得該期交割日;因此就必須建立一個Holiday資料表來存放各國假日,好讓我們在計算工作日時可以排除掉國定假日。
先簡單規劃Service需提供2個功能方法如下:
1. 依國家別來取得輸入期間中的假日清單(不包含六日)
IEnumerable<DateTime> GetHolidays(Country country, DateTime startDate, DateTime endDate)
2. 依國家別來取得輸入日期的下個工作日之日期
DateTime GetNextWorkingDate(Country country, DateTime date)
實作開始
首先列出共用類別 IRepository<T> 與 GenericRepository<T> 示意代碼供參考
public interface IRepository<T> where T : class, new()
{
IQueryable<T> GetAll();
// ... 略 ...
}
public class GenericRepository<T> : IRepository<T> where T : class, new()
{
// Fields
internal DbContext Context;
internal DbSet<T> DbSet;
// Constructors
public GenericRepository(DbContext context)
{
#region Contract
Guard.ArgumentNotNull(context, "context");
#endregion
Context = context;
DbSet = context.Set<T>();
}
// Methods
public IQueryable<T> GetAll()
{
return DbSet;
}
// ... 略 ...
}
再來就是定義國定假日Holiday類別 (以Country區別假日所屬國家)
public class Holiday
{
// Properties
[Key]
public int HolidayId { get; set; }
[Required]
[MaxLength(100)]
public string Name { get; set; }
[DataType(DataType.Date)]
public DateTime Date { get; set; }
public Country Country { get; set; }
}
public enum Country
{
Taiwan,
Japan,
USA
}
由於筆者使用CodeFirst來開發,因此DbContext要加上Holiday來建立DB實體資料表
public class GekerDbContext : DbContext
{
// Coustructors
public GekerDbContext()
: base("GekerDbContext")
{
}
// Properties
public DbSet<Holiday> Holidays { get; set; }
}
再來就是依照使用者的需求來建立Service的"殼"
IHolidayService介面如下,提供兩個介面方法,分別為取得輸入期間之假日清單方法介面GetHolidays,以及取得輸入日期的下個工作日方法介面GetNextWorkingDate,參考程式碼如下。
/// </summary>
/// Holiday之相關服務介面
/// </summary>
public interface IHolidayService
{
/// <summary>
/// 取得Holidays
/// </summary>
/// <param name="country">國家</param>
/// <param name="startDate">開始日</param>
/// <param name="endDate">結束日</param>
/// <returns>區間假期</returns>
IEnumerable<DateTime> GetHolidays(Country country, DateTime startDate, DateTime endDate);
/// <summary>
/// 取得下一個工作日
/// </summary>
/// <param name="country">國家</param>
/// <param name="date">判斷日期</param>
/// <returns>下個工作日</returns>
DateTime GetNextWorkingDate(Country country, DateTime date);
}
實作IHolidayService介面的HolidayService服務如下,在此步驟先不急著實作出GetHolidays及GetNextWorkingDate方法,留下空殼讓編譯可以順利通過即可;筆者會接著先把單元測試規格做好,後續再回來實作時,就可以直接透過單元測試來驗證該方法邏輯是否正確無誤。
public class HolidayService : BaseService, IHolidayService
{
// Fields
IRepository<Holiday> _holidayRepository;
// Constructors (DI)
public HolidayService(IRepository<Holiday> holidayRepository)
{
#region Contract
Guard.ArgumentNotNull(holidayRepository, "holidayRepository");
#endregion
_holidayRepository = holidayRepository;
}
// Methods
/// <summary>
/// 取得Holidays
/// </summary>
/// <param name="country">國家</param>
/// <param name="startDate">開始日</param>
/// <param name="endDate">結束日</param>
/// <returns>區間假期</returns>
public IEnumerable<DateTime> GetHolidays(Country country, DateTime startDate, DateTime endDate)
{
// TODO: 建立單元測試規格後再進行實作
throw new NotImplementedException();
}
/// <summary>
/// 取得下一個工作日
/// </summary>
/// <param name="country">國家</param>
/// <param name="date">判斷日期</param>
/// <returns>下個工作日</returns>
public DateTime GetNextWorkingDate(Country country, DateTime date)
{
// TODO: 建立單元測試規格後再進行實作
throw new NotImplementedException();
}
}
建立單元測試
首先,利用NuGet下載安裝 NSubstitute 套件
單元測試 1
建立單元測試方法 GetHolidaysTest 來驗證 GetHolidays是否正確,而NSubstitute提供之功能如下:
(1) 依照介面(IRepository<Holiday>)來建立測試替身(holidayRepository)實體
(2) 指定測試替身之特定方法回傳測試資料, 於此例中指定GetAll()方法回傳預設假期做為DB資料分身。
GetHolidaysTest 主要在驗證是否能正確取出指定期間的假日清單(不含六日)
條件如下 :
-
國別(country) : 台灣 (Country.Taiwan)
-
起始日(startDate) : 2015年04月25日
-
結束日(endDate) : 2015年05月10日
預期結果 :
假日清單(expectedHolidays) : 2015年05月01日 (勞動節)
測試代碼如下
public void GetHolidaysTest()
{
// Create substitute for repository
IRepository<Holiday> holidayRepository = Substitute.For<IRepository<Holiday>>();
// Inject repository into service
IHolidayService holidayService = new HolidayService(holidayRepository);
// Redefine GetAll() to simulate Holiday db data
holidayRepository.GetAll().Returns(
new List<Holiday>() {
new Holiday() {Name="勞動節", Date = new DateTime(2015,05,01).Date, Country=Country.Taiwan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,04,29).Date, Country=Country.Japan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,04,30).Date, Country=Country.Japan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,05,01).Date, Country=Country.Japan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,05,04).Date, Country=Country.Japan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,05,05).Date, Country=Country.Japan}
}.AsQueryable()
);
// Arrange inputs
var country = Country.Taiwan;
var startDate = new DateTime(2015, 04, 25);
var endDate = new DateTime(2015, 05, 10);
// Act
var actualHolidays = holidayService.GetHolidays(country, startDate, endDate);
// Expected value
var expectedHolidays = new List<DateTime> { new DateTime(2015, 05, 01).Date };
// Assert(by FluentAssertions)
actualHolidays.Should().Equal(expectedHolidays);
}
單元測試 2
建立 GetNextWorkingDateTest 來驗證是否能正確取出指定日期的下個工作日
條件如下 :
-
國別(country) : 台灣 (Country.Taiwan)
-
基準日(baseDate) : 2015年04月30日
預期結果 :
下個工作日(expectedNextWorkingDate) : 2015年05月04日
測試代碼如下
public void GetNextWorkingDateTest()
{
// Create substitute for repository
IRepository<Holiday> holidayRepository = Substitute.For<IRepository<Holiday>>();
// Inject repository into service
IHolidayService holidayService = new HolidayService(holidayRepository);
// Redefine GetAll() to simulate Holiday db data
holidayRepository.GetAll().Returns(
new List<Holiday>() {
new Holiday() {Name="勞動節", Date = new DateTime(2015,05,01).Date, Country=Country.Taiwan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,04,29).Date, Country=Country.Japan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,04,30).Date, Country=Country.Japan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,05,01).Date, Country=Country.Japan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,05,04).Date, Country=Country.Japan},
new Holiday() {Name="黃金周", Date = new DateTime(2015,05,05).Date, Country=Country.Japan}
}.AsQueryable()
);
// Arrange inputs
var country = Country.Taiwan;
var baseDate = new DateTime(2015, 04, 30);
// Act
var actualNextWorkingDate = holidayService.GetNextWorkingDate(country, baseDate);
// Expected value
var expectedNextWorkingDate = new DateTime(2015, 05, 04).Date;
// Assert(by FluentAssertions)
actualNextWorkingDate.Should().Be(expectedNextWorkingDate);
}
實作Service功能邏輯
單元測試方法實作完成後,接著就可以著手進行Service方法的實作。建議在撈取資料時都可以先呼叫GetAll()後再對資料進行篩選,讓我們在測試時只需調整GetAll()來回傳所須測試資料即可;另外,由於GetAll()方法是回傳IQueryable介面,因此不會立即對資料源進行查詢,不會有效能上的疑慮。
public class HolidayService : BaseService, IHolidayService
{
// Fields
IRepository<Holiday> _holidayRepository;
// Constructors (DI)
public HolidayService(IRepository<Holiday> holidayRepository)
{
#region Contract
Guard.ArgumentNotNull(holidayRepository, "holidayRepository");
#endregion
_holidayRepository = holidayRepository;
}
// Methods
/// <summary>
/// 取得Holidays
/// </summary>
/// <param name="country">國家</param>
/// <param name="startDate">開始日</param>
/// <param name="endDate">結束日</param>
/// <returns>區間假期</returns>
public IEnumerable<DateTime> GetHolidays(Country country, DateTime startDate, DateTime endDate)
{
return _holidayRepository.GetAll()
.Where(h => h.Country == country)
.Where(h => h.Date >= startDate.Date && h.Date <= endDate.Date)
.Select(h => h.Date);
}
/// <summary>
/// 取得下一個工作日
/// </summary>
/// <param name="country">國家</param>
/// <param name="date">判斷日期</param>
/// <returns>下個工作日</returns>
public DateTime GetNextWorkingDate(Country country, DateTime date)
{
DateTime? nextWorkDay = null;
while (nextWorkDay == null)
{
// get next date
date = date.AddDays(1);
// check if working date
if (IsWorkingDay(country, date))
{ nextWorkDay = date; }
}
return nextWorkDay.Value.Date;
}
/// <summary>
/// 是否為工作日
/// </summary>
/// <param name="country">國家</param>
/// <param name="date">判斷日期</param>
/// <returns>True:工作日, False:非工作日</returns>
private bool IsWorkingDay(Country country, DateTime date)
{
var isWorkingDay = false;
// check if saturday or sunday
if (date.DayOfWeek != DayOfWeek.Saturday && date.DayOfWeek != DayOfWeek.Sunday)
{
// check if holiday
var isHoliday = _holidayRepository.GetAll()
.Where(h => h.Country == country)
.Where(h => h.Date == date).Any();
isWorkingDay = !isHoliday;
}
return isWorkingDay;
}
}
執行單元測試
GetHolidaysTest與GetNextWorkingDateTest皆通過表示實作正確無誤,可以結束此次需求的開發工作。
參考資訊
http://www.cnblogs.com/gaochundong/archive/2013/05/21/nsubstitute_get_started.html
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !