在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),在空白處按右鍵選 加入程式碼產生項目。
2.選擇線上範本後選擇 ADO.NET Mocking Context Generator
註:圖片中第一個範本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下載。
什麼!!你不知道什麼是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都有差異,要轉成對物件操作,可能太複雜。