[Testing] 使用NSubstitute建立測試替身

使用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  主要在驗證是否能正確取出指定期間的假日清單(不含六日)

條件如下 :

  1. 國別(country) : 台灣 (Country.Taiwan)

  2. 起始日(startDate) :  2015年04月25日

  3. 結束日(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  來驗證是否能正確取出指定日期的下個工作日

條件如下 :

  1. 國別(country) : 台灣 (Country.Taiwan)

  2. 基準日(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


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !