我們開發者有責任與義務寫測試來證明自己寫的東西沒有問題,但實際撰寫測試的時候,不會像教科書中的範例只有簡單的資料進出,測試資料的初始與還原,非常的麻煩,會讓人想放棄寫測試,後來因為NHibernate的彈性與Sqlite的輕量,把問題都簡化了,會讓人又重新愛上了測試,本分享會說明我當初遇到的問題,與後來NHibernate與Sqlite怎麼幫我決解這些問題。
我們開發者有責任與義務寫測試來證明自己寫的東西沒有問題,但實際撰寫測試的時候,不會像教科書中的範例只有簡單的資料進出,測試資料的初始與還原,非常的麻煩,會讓人想放棄寫測試,後來因為NHibernate的彈性與Sqlite的輕量,把問題都簡化了,會讓人又重新愛上了測試,本分享會說明我當初遇到的問題,與後來NHibernate與Sqlite怎麼幫我決解這些問題。
NHibernate+Sqlite測試實戰經驗分享
大綱
- 簡單介紹單元測試
- 單元測試困難的地方,與操作經驗
- NHibernate+Sqlite幫我決解那些問題
簡單介紹單元測試
什麼是單元測試?
因為我們會寫一小段程式去測試一個小行為,如使用User Id取得User資料,這種小行為,測試不會去處理使用者的註冊、是不是合法使用者,不會對整個商業流程測試,因為測試的粒度很小,所以稱為單元測試。
為什麼要寫單元測試?
單元測試是保證軟體品質中很重要的一環,我們Developer有責任與義務對自己寫的東西提出保證,但口說無憑,還是必需要有實際的測試報告,來證明自己寫的東西沒有問題。
什麼是回歸測試?
當單元測試寫到一定的量的時候,以後每次的修改都可以執行所有的單元測試,確保這次的修改不會影響的原有的功能,確保系統的完整性。
單元測試無法測試?
單元測試通常不會用在UI的測試與商業邏輯是否正確或是整體效能
單元測試主要用途是測試程式邏輯是否與符合 Developer 預期,如1 + 1 = 2 ,但是如果有個Developer認為1+1=3,他寫程式也寫成這樣,Assert也是3,單元測試無法測出Developer本身的錯誤,以上的種種測試都會交給專業的QA去測。
本次分享主要不是在介紹單元測試,所以只會簡單的提一下什麼是單元測試。
單元測試困難的地方?
程式的耦合度不能太高
如果寫程式沒有分層,沒有使用Interface,就很難在測試時抽換零件,會讓單元測試的難度變高。
每一個Method處理的事務不能太大
如果一個Method粒度太大,要考慮與要準備的東西太多,也會讓單元測試的難度變高。
想改善這二個問題,馬上就開始寫單元測試,寫過幾次,綁手綁腳,有一些經驗,就會發現怎麼寫程式,比較容易測試。
測試資料的準備
最麻煩的我覺得是測試資料的準備,因為實際執行專案的時候,不會像教科書中的範例只有簡單的資料進出,以購物車的的折扣計算為例子,必需要有不少的資料測試才能完整,準備這些資料有時反而比開發程式還耗時,也是本次分享要講的重點。
曾經嘗試過那些方法
為了方便測含有資料庫操作的Method做單元測試單元測驗,曾經嘗試過這些方法,向各位談談其中的甘苦。
受測Method資料來源完全使用參數傳遞
這是一個很好的解決方式,從設定面就決解了資料來源的問題,但是有一個缺點,寫專案8成以上的Method,都會取存資料庫,為了可以單元測試解耦合解到很累,而且可以測試的Method就不多了,除非每一個Method都寫2個,一個是讀資料,一個是接收資料做運算。
為了方便說明我在Method前加上ABCD的代號,為了讓成績計算可以不依賴資料庫,A__成績計算,裡面又拆成多個Method,B__取得答題記錄,C__成績計算Core等等,讓C Method不用讀取資料庫,可以順利的單元測試。
但是明明B,D的粒度也不大,但是就因為扯到資料庫,就不能寫單元測試,但是這幾個Method有不測,怕有問題,對自己寫的東西的信心程度有些下降,所以又嘗試了一些方式讓B,D也可以測。
自己寫DAO
要使用這個方法程式就要有基本的分層,測試為了與實際資料庫解耦合,在測試時會使用IoC等方法切換資料存取層
這個範例 資料存取層使用DataSet做為儲存媒體,使測試可以測到有資料存取的Method,在每一個測試一開始時,載入測試資料到DataSet,每一個測試結束DataSet重置,讓每一次測試的環境與資料都是相同。
這一種方法很累,要寫2種DAO,而處理2種Query,在一個專案使用這種方式測試後就不在使用了。
Entity Framework Mock Context
後來改使用ORM,一開始我是使用LinqToSql那時找不到單元測試的方法,後來改用Entity Framework,就有人分享單元測試的技巧,如同自己寫DAO。
但這時有已經有強大的Linq支援,不需要自己處理Query,只要換掉Context就可以了,Context是Entity Framework存放物件的Class,原本的Context會轉成Sql存取資料庫,現在改用MockContext,使用Collection來存放資料。
因為Linq有多種Provider,如Linq to Database、Linq to Object,一樣的Query使用不同的Provider可以有不同的行為,不用像自己寫DAO般要寫2種Query省了很多事。
Entity Framework 的Mock Context可以參考:使用ContextMock以不連結資料庫的方式做單元測試
但是以上的三種方法都沒有真的對資料庫操作,沒有辦法知道,實際存取資料庫會不會出問題,雖然單元測試的學者會都說,單元測試要Focus只測一個功能,有外部資源如資料庫要到整合測試來測,有時覺得太學術,因為單元測試時略過大量的存取資料庫,在整合測試時,發生問題不好追,因為整合測試要測的Method太多,也不容易追出在那一個環結發生問題,所以後來還是想把真實資料庫納進單元測試中。
使用測試資料庫,建snapshot
為了在單元測試時將資料庫也納入,必需有幾個要注意事情,因為單元測試是可以重覆被使用,但是因為資料庫系統的ACID中持久性特性,每一次的測試變動的資料會被保留下來,如果不知這些資料還原,可能會因為測試資料不同,如新增User的單元測試,因為User Name重覆而無法第二次自動化測試,或同一個User的購物車資料過多等等,導致測試的計算結果有誤,所以要將重置測試的環境,我選擇使用snapshot。
RESTORE DATABASE TestExamDb FROM DATABASE_SNAPSHOT = ‘ExamDb_SS_1‘;
不同的資料組合,會建立不同的Snapshot,不過用Snapshot不適合單元測試,因為一個大專案會好幾百個單元測試,每一個單元測試都執行一次Restore,很容易就Lock了,而且開發成員多的時候很麻煩,同時間測試會重突,在開發者的電腦都裝Sql Server 開發版,會因為Schema變動造成一些麻煩,又找不到好的方法,有一段時間我放棄對資料庫做單元測試,直到我接觸了Code First的設計方式才有改變,不過在整合測試時SnapShot還滿好用的。
Code First
ORM的Model有幾種設計方式
Database First
從現有資料庫產生 Class
Model First
使用圖型化工具產生Class與Database
Code First
與一般建立Class的方式一樣,人工建立Class,與資料庫的對應是使用Map或自動產生Map,可以從Map產生資料庫
而Code First的方式在
Entity Framework 4.1 +
NHibernate 3.2 +
才有這些功能,NHibernate當然在3.2之前也有Code First,不過是第3方組件支援,到3.2才有官方支援
我比較喜歡Code First,因為圖型化工具能做到的事情有限,自己寫Map彈性才大,或是我會混用,先用圖型化工具產生大概,再自己客製細節,而Model/Code First都有一個特性,一開始都沒有Database, Database是動態產生的,所以可以每一次測試都產生新增Database與新增測試資料。
用NHibernate+Sqlite的好處
NHibernate
有那麼多的ORM我特別喜歡NHibernate,雖然他的效能比EF差,但他有太多功能是EF或其他ORM比不上的。
支援的DB多
MS SQL、Oracle、My Sql、Sqlite、DB2等等數10種資料庫引擎
Map彈性大
NHibernate支援很多種Map方式,甚至可以在專案上線,不改任何一行Code,以外掛XML Map檔的方式改變Map。
Event
NHibernate有50個事件可以訂閱,可以依需要做一些事件改變SQL,如Insert與Update時增加 建立時間或更時時間,與建立者或更新者。
Cache
可以使用多種Cache如MemCache比其他ORM也好太多了,而且可以多種快取策略,不是只有開跟關而已。
Log
NHibernate的Log非常多,SQL的執行,快取的載入,ORM的處理等等,有一百多項Log。
HQL與SQL支援
如果覺得ORM產生的SQL太爛,可以自己下SQL或HQL取代,加快效能HQL是類似SQL,但是為了解決不同資料庫的SQL差異而產生的,使用HQL在真正執行時會依設定轉成真正的SQL
這些零零種種的東西功能,都是其他ORM有用,所以我比較喜歡NHibernate。
詳細說明可以參考跟著Wade學習ASP.NET MVC + NHibernate中NHibernate的部分。
Sqlite
是檔案格式的資料庫,不需要安裝任何的軟體,在.Net只要加入System.Data.Sqlite.dll一個參考就可以執行了,支援SQL92,支援交易與ACID,支援Index與View,我自己有一個小專案用Sqlite,全部50萬筆資料,簡單的查尋不到1秒就Select出來,小專案用效能也不會太差。
對測試的便利性。
我會用NHibernate的支援多種DB的特色,正試上線或整合測試時用Oracle或MS SQL,但單元測試時用Sqlite,每一次測試重建一次資料庫,一百個Method重建一百次,我公司的電腦還不錯,執行下來1分鐘左右可以執行完成,加上Log,可以看產生的SQL,可以加上其他工具對SQL做Turning。
二種策略
產生很多*.db檔,執行時複製檔案
—適合資料量大
—缺點:Schema或測試資料異動時很麻煩
使用Create Schema與初始化資料
—Schema或測試資料異動時較方便
—缺點:資料量大時測試時間會加長
範例
NhibernateTestHelper.cs (註:僅為了快速Demo,Code不完整)
{
public static ISessionFactory SessionFactory { get; set; }
public static void Copy(string fileName)
{
SessionFactory = Initialize(SchemaAutoAction.Create); //不重建資料庫
string filePath = Path.GetFullPath(fileName);
File.Copy(fileName, Path.GetFullPath("Test.db"), true);
}
public static void CreateSchema(params IDataInitialize[] dataInis)
{
SessionFactory = Initialize(SchemaAutoAction.Recreate); //使用重建,每次建立Factory都會重建資料庫
using (var session = SessionFactory.OpenSession())
{
foreach (var dataIni in dataInis)
{
dataIni.Initialize(session); //新增資料
session.Flush();
}
}
}
private static ISessionFactory Initialize(SchemaAutoAction action)
{
//因為要快速Demo,使用ByCode的方式設定Config
var config = new NHibernate.Cfg.Configuration();
config.Proxy(proxy => proxy.ProxyFactoryFactory<DefaultProxyFactoryFactory>());
config.DataBaseIntegration(db =>
{
db.Dialect<SQLiteDialect>();
db.ConnectionString = "Data Source=Test.db";
db.Driver<SQLite20Driver>();
db.SchemaAction = action;
db.KeywordsAutoImport = Hbm2DDLKeyWords.AutoQuote;
});
var mapper = new ModelMapper();
mapper.AddMapping<ExamRecordMap>();
mapper.AddMapping<ExamRecordAnswerMap>();
var maps = mapper.CompileMappingForAllExplicitlyAddedEntities();
config.AddDeserializedMapping(maps, "Models");
var factory = config.BuildSessionFactory();
return factory;
}
}
IDataInitialize.cs
{
void Initialize(ISession session);
}
使用重建Schema與初始化資料的策略
在建立NHibernate的SessionFactory時設定SchemaAutoAction.Recreate,讓每一次BuildSessionFactory時都Drop Schema與Create Schema並執行DataInitialize的Initialize,讓每一次在執行測試時都是全新的資料。
準備的2組DataInitialize
ExamRecordTestDataInitializeA.cs
{
public void Initialize(NHibernate.ISession session)
{
var examRecord = new ExamRecord() { };
session.Save(examRecord);
for (int i = 0; i < 31; i++)
{
session.Save(new ExamRecordAnswer() { Parent = examRecord, IsRight = true });
}
}
}
ExamRecordTestDataInitializeB.cs
{
public void Initialize(NHibernate.ISession session)
{
var examRecord = new ExamRecord() { };
session.Save(examRecord);
for (int i = 0; i < 50; i++)
{
session.Save(new ExamRecordAnswer() { Parent = examRecord, IsRight = Convert.ToBoolean((i % 2)) });
}
}
}
抱歉我有點懶,A跟B 2個DataInitialize只有迴圈數不一樣,所以得到的成績因題目數不同而不同。
執行測試
public void U__ExamService__計算成績__使用資料初始化A()
{
NhibernateTestHelper.CreateSchema(new ExamRecordTestDataInitializeA()); //重建Schema
ExamService target = new ExamService();
int examRcoredId = 1;
double score = 62;
using (var session = NhibernateTestHelper.SessionFactory.OpenSession())
{
ExamRecord record = session.Get<ExamRecord>(examRcoredId);
Assert.AreEqual(score, target.CalculateResult(record.Answers));
}
}
[TestMethod()]
public void U__ExamService__計算成績__使用資料初始化B()
{
NhibernateTestHelper.CreateSchema(new ExamRecordTestDataInitializeB()); //重建Schema
ExamService target = new ExamService();
int examRcoredId = 1;
double score = 50;
using (var session = NhibernateTestHelper.SessionFactory.OpenSession())
{
ExamRecord record = session.Get<ExamRecord>(examRcoredId);
Assert.AreEqual(score, target.CalculateResult(record.Answers));
}
}
執行時複製檔案
這方法就很簡單,只是事前準備好Sqlite的檔案,複製成新的檔案,因為一直是對複製的檔案做存取,原檔可以一直重複使用
怎麼樣處理單元測試時簡單的部署檔案可以參考這一篇:Visual Studio 測試系列 : 部署測試需要用的檔案
或是不部署*.db檔,使用絶對路徑不要產生2次複製,為什麼會2次複製呢?因為每一次執行測試,使用部署測試工具會將檔案複製到TestResult的Out資料夾,每一次執行TestMethod,又會將部署檔複製成Test.db檔。
執行測試
public void U__ExamService__計算成績__複制資料庫A_1()
{
NhibernateTestHelper.Copy("Test1.db"); //複制DB
ExamService target = new ExamService();
int examRcoredId = 1; //Record1
double score = 86;
using (var session = NhibernateTestHelper.SessionFactory.OpenSession())
{
ExamRecord record = session.Get<ExamRecord>(examRcoredId);
Assert.AreEqual(score, target.CalculateResult(record.Answers));
}
}
[TestMethod()]
public void U__ExamService__計算成績__複制資料庫A_2()
{
NhibernateTestHelper.Copy("Test1.db"); //複制DB
ExamService target = new ExamService();
int examRcoredId = 2; //Record2 Test1.db有準備2組資料
double score = 98;
using (var session = NhibernateTestHelper.SessionFactory.OpenSession())
{
ExamRecord record = session.Get<ExamRecord>(examRcoredId);
Assert.AreEqual(score, target.CalculateResult(record.Answers));
}
}
[TestMethod()]
public void U__ExamService__計算成績__複制資料庫B()
{
NhibernateTestHelper.Copy("Test2.db"); //複制DB
ExamService target = new ExamService();
int examRcoredId = 1;
double score = 90;
using (var session = NhibernateTestHelper.SessionFactory.OpenSession())
{
ExamRecord record = session.Get<ExamRecord>(examRcoredId);
Assert.AreEqual(score, target.CalculateResult(record.Answers));
}
}
Q And A
Q: 這個方法會不會很耗資源?
A: 會,開發的電腦不好,沒有8G+的記憶體、沒有SSD、沒有開RemDisk可能執行一次可能耗的時間會很久,平常可以一次測試只執行一、二個TestMethod,寫到一個段落可以執行全部測試後,就可以去喝啡咖了
Q: 如果已經有舊有資料庫要怎麼處理?
A: 可以將部份資料轉成sqlite,不需要全部資料,可以準備幾組,當作單元測試的測試資料。
Q: 如果一個Method真實環境中有多個資料庫要怎麼處理?
A: 整成一個Sqlite,單元測試不是要在真實環境中執行測試,而是要測程式的邏輯有沒有問題,其他的交給QA做。
參考資料