NHibernate+Sqlite測試實戰經驗分享

我們開發者有責任與義務寫測試來證明自己寫的東西沒有問題,但實際撰寫測試的時候,不會像教科書中的範例只有簡單的資料進出,測試資料的初始與還原,非常的麻煩,會讓人想放棄寫測試,後來因為NHibernate的彈性與Sqlite的輕量,把問題都簡化了,會讓人又重新愛上了測試,本分享會說明我當初遇到的問題,與後來NHiberna­te與Sqlite怎麼幫我決解這些問題。

我們開發者有責任與義務寫測試來證明自己寫的東西沒有問題,但實際撰寫測試的時候,不會像教科書中的範例只有簡單的資料進出,測試資料的初始與還原,非常的麻煩,會讓人想放棄寫測試,後來因為NHibernate的彈性與Sqlite的輕量,把問題都簡化了,會讓人又重新愛上了測試,本分享會說明我當初遇到的問題,與後來NHiberna­te與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個,一個是讀資料,一個是接收資料做運算。

image

為了方便說明我在Method前加上ABCD的代號,為了讓成績計算可以不依賴資料庫,A__成績計算,裡面又拆成多個Method,B__取得答題記錄,C__成績計算Core等等,讓C Method不用讀取資料庫,可以順利的單元測試。

image

但是明明B,D的粒度也不大,但是就因為扯到資料庫,就不能寫單元測試,但是這幾個Method有不測,怕有問題,對自己寫的東西的信心程度有些下降,所以又嘗試了一些方式讓B,D也可以測。

 


自己寫DAO

image

要使用這個方法程式就要有基本的分層,測試為了與實際資料庫解耦合,在測試時會使用IoC等方法切換資料存取層
image

 

這個範例 資料存取層使用DataSet做為儲存媒體,使測試可以測到有資料存取的Method,在每一個測試一開始時,載入測試資料到DataSet,每一個測試結束DataSet重置,讓每一次測試的環境與資料都是相同。

image

這一種方法很累,要寫2種DAO,而處理2種Query,在一個專案使用這種方式測試後就不在使用了。

 


Entity Framework Mock Context

後來改使用ORM,一開始我是使用LinqToSql那時找不到單元測試的方法,後來改用Entity Framework,就有人分享單元測試的技巧,如同自己寫DAO。

image

但這時有已經有強大的Linq支援,不需要自己處理Query,只要換掉Context就可以了,Context是Entity Framework存放物件的Class,原本的Context會轉成Sql存取資料庫,現在改用MockContext,使用Collection來存放資料。

image

因為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的檔案,複製成新的檔案,因為一直是對複製的檔案做存取,原檔可以一直重複使用

image

怎麼樣處理單元測試時簡單的部署檔案可以參考這一篇: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做。

 


參考資料