[Unit Test]使用ContextMock以不連結資料庫的方式做單元測試

在Unit Test時最讓我煩惱的是測試時的測試資料,為了讓每一次測試是一致的受測資料,且不被外在環境干擾,有試過很多方法,如在開始測試時新增測試資料到資料庫,測試時只用這些資料,結束測試時刪除測試資料,或是先準備好內有測試資料的mdb每次測試時複制mdb,不斷的掛載與卸離mdb,說真的這些方法都很蠢,不好維護且各個測試又容易互相影嚮(本測試寫了一筆資料,可能影嚮了下一個測試的結果),寫到最後我就放棄寫需要連資料庫的任何Unit Test,直到前陣子看到有人使用Entity Framework然後使用Mock ObjectContext,決解了測試時需要使用到資料庫這個外部資源,讓Unit Test更Unit,我又開始在新的專案中寫Unit Test。

在Unit Test時最讓我煩惱的是測試時的測試資料,為了讓每一次測試是一致的受測資料,且不被外在環境干擾,有試過很多方法,如在開始測試時新增測試資料到資料庫,測試時只用這些資料,結束測試時刪除測試資料,或是先準備好內有測試資料的mdb每次測試時複制mdb,不斷的掛載與卸離mdb,說真的這些方法都很蠢,不好維護且各個測試又容易互相影嚮(本測試寫了一筆資料,可能影嚮了下一個測試的結果),寫到最後我就放棄寫需要連資料庫的任何Unit Test,直到前陣子看到有人使用Entity Framework然後使用Mock ObjectContext,決解了測試時需要使用到資料庫這個外部資源,讓Unit Test更Unit,我又開始在新的專案中寫Unit Test。

 

一、增加ContextMock

在Entity Framework中建立ContextMock沒有多難,幾個按鍵就可以了。

註:因為使用edmx所產生的Model,都是繼承ObjectContext,而在Entity Framework 4.1中改繼承DbContext,所以我習慣叫它為Context。

 

1.打開Entity Framework的實體資料模型設計檔(edmx),在空白處按右鍵選 加入程式碼產生項目。

image

 

2.選擇線上範本後選擇 ADO.NET Mocking Context Generator

image

註:圖片中第一個範本ADO.NET Unit Testable Repository Generator也是會產生利於測試的Entity Framework,不過呢,使用這個範本要改變我對Repository的寫法,以及Entity變動時要維謢BaseRepositoryTest到瘋掉,而且我不喜歡正式專案中載入測試用的dll,這是它的官網,有興趣可自己玩玩。

 

這樣你的Entity Framework已經可以離線使用了。


//ContextMock的存取是使用在記憶體中的集合,可以把他想成DataSet,原理在第三章說明
IModel1 context = new Model1Mock();
context.Blogs.AddObject(new Blog() { Id = 1 });
context.Blogs.Where(x=>x.Id==1);            

3.不過呢,這個範本少了些東西,必需做一點點的小調整,才能真的拿到專案中使用。

請打開YourModel.Context.tt檔

在104行中加入

        : System.IDisposable

在106行中加入

        int SaveChanges();

在131行中插入

        public int SaveChanges() { return 0; }

        public void Dispose() { }

 

同如下範例:


<#=Accessibility.ForType(container)#> interface I<#=code.Escape(container)#> : System.IDisposable
{
    int SaveChanges();
<#+
    foreach (EntitySet entitySet in container.BaseEntitySets.OfType<EntitySet>())
    {
#>
    IObjectSet<<#=code.Escape(entitySet.ElementType)#>> <#=code.Escape(entitySet)#> { get; }
<#+
    }
#>
}
<#+
}
#>

<#+
void WriteMockContextBody( EntityContainer container, CodeGenerationTools code )
{
#>
/// <summary>
/// The concrete mock context object that implements the context's interface.
/// Provide an instance of this mock context class to client logic when testing, 
/// instead of providing a functional context object.
/// </summary>
<#=Accessibility.ForType(container)#> partial class <#=code.Escape(container)#>Mock : I<#=code.Escape(container)#>
{
    public int SaveChanges() { return 0; }
    public void Dispose() { }

註:tt檔是Text Template Transformation Toolkit(俗稱T4)的範本檔,主要用途是可以用範本來產生Code,Visual Studio 2010 中有T4的套件,如Visual T4,可以比較方便編輯tt檔。

 

再搭配IoC就可以很方便的使用ContextMock做單元測試了,怎麼做呢,請看下一章。

 

二、使用ContextMock

1.在這個範例中IoC,小弟是使用AutoFac加上CommonServiceLocator(可參考IoC的中繼器:CommonServiceLocator),當然也可以用別的Ioc套件如Unity。

這二個套件都可以用NuGet下載。

image

什麼!!你不知道什麼是NuGet,那你真要花點時間去了解,NuGet可是比本篇更實用的東西。

請參考黑大的:還在揮汗徒手安裝程式庫? 試試NuGet吧

 

2.再來可能要改變一點點寫法,請將所有原本使用的Context,改用介面,請在前面加上I,並使用CommonServiceLocator來產生實例。


public class ShippingService
{
    public void AddToCart(int custimeId, Product item)
    {
        //使用ServiceLocator來取得實例,一般情況使用正常的Context,在單元測試時使用MockContext
        using (var context = ServiceLocator.Current.GetInstance<IShopModel>())
        {
            //訂算優惠
            var productPremiums = context.Premiums.Where(x => x.ProductId == item.Id);
            //.........

            context.Carts.AddObject(new Cart());
            context.SaveChanges();
        }
    }
}

 

3.撰寫單元測試


[TestInitialize()]
public static void MyTestInitialize()
{
    //測試資料的準備
    IShopModel context = new ShopModelMock();
    context.Products.AddObject(new Product()); //商品資料
    context.Premiums.AddObject(new Premium()); //優惠資料

    //註冊實例,讓相關測試在ServiceLocator.Current.GetInstance時用同一個實例
    ContainerBuilder builder = new ContainerBuilder();
    builder.RegisterInstance<IShopModel>(context);
    var provider = new AutofacServiceLocator(builder.Build());
    ServiceLocator.SetLocatorProvider(() => provider);
}

[TestMethod()]
public void ShippingService_AddToCartTest()
{
    //測試時是使用,在MyClassInitialize所準備的資料,不會真的連資料庫
    var shippingService = new ShippingService();
    shippingService.AddToCart(1, new Product());

    //Assert Something
}

註1:別忘了在正式環境中也要加上IoC的註冊,把上面的範例中的

builder.RegisterInstance<IShopModel>(context);

改成如下就可以了

builder.RegisterType<ShopModel>().As<IShopModel>();

 
註2:這個範例是在同一個Class下的所有測試,共用同樣的測試資料,如果你每一個單元測試都使用不同的測試資料,可以改在每個單元測試中新增測試資料,且建議改用Unity,因為重新註冊在AutoFac中比較麻煩。

 

三、ContextMock的原理

目前看到的二種Mock的方式,原理都是一樣,產生的Code都很簡單,都是將原本的Context的Set額外定義到Interface,讓原本的Context與ContextMock都是繼承此Interface,我們在撰寫程式時,都是對Interface操作,在搭配IoC讓不同的時機,使用不同的Class,這一點寫過物件導向的人應該不莫生,這就是物件導向的抽象與繼承的應用嘛,而比較麻煩的是要讓一樣的語法,可以對資料庫或集合操作,這部分難的地方Linq幫我們處理掉了,打開YourModelMock.ObjectSet.cs。


public partial class MockObjectSet<T> : IObjectSet <T>   where T : class
{
    //使用List存儲資料
    private readonly IList<T> m_container = new List<T>();

    //將List轉成Queryable讓它可以如同對Entity Framework般的操作
    public System.Linq.Expressions.Expression Expression
    {
        get { return m_container.AsQueryable<T>().Expression; }
    }

    //將List轉成Queryable讓它可以如同對Entity Framework般的操作
    public IQueryProvider Provider
    {
        get { return m_container.AsQueryable<T>().Provider; }
    }
}

你會發現儲存媒體是List,那為什麼List與Entity Framwork的Linq語法可以共用呢?這裡使用了Linq的Queryable與Expression等相關技術,每當使用Linq對Queryable物件操作時,是將語法(如Where)都會轉成Expression,在GetEnumerator時才會使用QueryProvider去真的執行,而List轉成Queryable後QueryProvider是EnumerableQuery<T>,執行時以Linq To Object的方式操作,而Entity Framework的QueryProvider是ObjectQueryProvider,在執行時會剖析Expression轉成Sql對資料庫做操作(小弟有做過雷同的事,請參考Entity Framework批次Update與Delete),這部分的詳細說明小弟會整理到[下一篇IEnumable與IQueryable有什麼不同]。

 

可以看出這個技巧,不限定只能用在Entity Framework,也可以用在其他ORM的套件中(如nhibernate),只要它支援Linq,只是其他ORM可能就沒有那麼好命有相關的工具幫忙,像小弟最近的一個專案ORM是使用Entity Framework 4.1的Code First,完全沒有工具一切自己手打。

註:請不要問我,使用傳統的ADO.NET方式可以套用此方法嗎?我很久沒用傳統ADO.NET了,很久沒有花心思研究它了,不過我猜是不行,因為要解析Sql,每家的Sql都有差異,要轉成對物件操作,可能太複雜。