2014年1月17日星期五

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

在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已經可以離線使用了。

1 //ContextMock的存取是使用在記憶體中的集合,可以把他想成DataSet,原理在第三章說明
2 IModel1 context = new Model1Mock();
3 context.Blogs.AddObject(new Blog() { Id = 1 });
4 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() { }

 

同如下範例:

104 <#=Accessibility.ForType(container)#> interface I<#=code.Escape(container)#> : System.IDisposable
105 {
106     int SaveChanges();
107 <#+
108     foreach (EntitySet entitySet in container.BaseEntitySets.OfType<EntitySet>())
109     {
110 #>
111     IObjectSet<<#=code.Escape(entitySet.ElementType)#>> <#=code.Escape(entitySet)#> { get; }
112 <#+
113     }
114 #>
115 }
116 <#+
117 }
118 #>
119  
120 <#+
121 void WriteMockContextBody( EntityContainer container, CodeGenerationTools code )
122 {
123 #>
124 /// <summary>
125 /// The concrete mock context object that implements the context's interface.
126 /// Provide an instance of this mock context class to client logic when testing,
127 /// instead of providing a functional context object.
128 /// </summary>
129 <#=Accessibility.ForType(container)#> partial class <#=code.Escape(container)#>Mock : I<#=code.Escape(container)#>
130 {
131     public int SaveChanges() { return 0; }
132     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來產生實例。

01 public class ShippingService
02 {
03     public void AddToCart(int custimeId, Product item)
04     {
05         //使用ServiceLocator來取得實例,一般情況使用正常的Context,在單元測試時使用MockContext
06         using (var context = ServiceLocator.Current.GetInstance<IShopModel>())
07         {
08             //訂算優惠
09             var productPremiums = context.Premiums.Where(x => x.ProductId == item.Id);
10             //.........
11  
12             context.Carts.AddObject(new Cart());
13             context.SaveChanges();
14         }
15     }
16 }

 

3.撰寫單元測試

01 [TestInitialize()]
02 public static void MyTestInitialize()
03 {
04     //測試資料的準備
05     IShopModel context = new ShopModelMock();
06     context.Products.AddObject(new Product()); //商品資料
07     context.Premiums.AddObject(new Premium()); //優惠資料
08  
09     //註冊實例,讓相關測試在ServiceLocator.Current.GetInstance時用同一個實例
10     ContainerBuilder builder = new ContainerBuilder();
11     builder.RegisterInstance<IShopModel>(context);
12     var provider = new AutofacServiceLocator(builder.Build());
13     ServiceLocator.SetLocatorProvider(() => provider);
14 }
15  
16 [TestMethod()]
17 public void ShippingService_AddToCartTest()
18 {
19     //測試時是使用,在MyClassInitialize所準備的資料,不會真的連資料庫
20     var shippingService = new ShippingService();
21     shippingService.AddToCart(1, new Product());
22  
23     //Assert Something
24 }

註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。

01 public partial class MockObjectSet<T> : IObjectSet <T>   where T : class
02 {
03     //使用List存儲資料
04     private readonly IList<T> m_container = new List<T>();
05  
06     //將List轉成Queryable讓它可以如同對Entity Framework般的操作
07     public System.Linq.Expressions.Expression Expression
08     {
09         get return m_container.AsQueryable<T>().Expression; }
10     }
11  
12     //將List轉成Queryable讓它可以如同對Entity Framework般的操作
13     public IQueryProvider Provider
14     {
15         get return m_container.AsQueryable<T>().Provider; }
16     }
17 }

你會發現儲存媒體是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都有差異,要轉成對物件操作,可能太複雜。


没有评论:

发表评论