2016年10月25日 星期二

Repository 測試使用 LocalDB - Part.2

接續上一篇「Repository 測試使用 LocalDB - Part.1 」的內容,這一篇將會介紹如何透過 Entity Framework 的方法建立與移除 LocalDB。

 


這功能相當重要,有嘗試要做資料存取層程式單元測試的人應該有相同的感受,尤其是當你的開發團隊是無法使用 Entity Framework 或其他 ORM Solution,而只能操作使用 ADO.NET 與 T-SQL 的團隊,資料存取層的單元測試就應該要重視。

我這兩年多來對於資料存取層程式的單元測試有相當多的感觸,而讓我對於這部分的測試有著一定程度的偏執,因為我所接觸、維護、重構的程式大多是長年累積下來的程式,這些大量的舊程式有著開發人員長時間共同努力的心血與眼淚,但也帶著相當嚴重的問題與弊病,顯示邏輯、商業邏輯、資料存取邏輯相互交雜,職責不清、混亂,意味不明的補丁程式,錯誤物件導向觀念的程式。

種種的問題都造成接手的開發人員維護上的困難,於是我就開始將重構接手的程式,首先就是將不同層級的邏輯給分離出去,第一步就是將資料存取邏輯的程式作切割,將近一半的資料存取邏輯都是很單純的資料存取處理,例如取得全部資料、取得單一資料、新增、更新、刪除資料,這些程式相對單純,但另外一半的程式還參雜了邏輯判斷,像是某些情境要取哪些資料或排除哪些資料等,甚至有些程式是為了要依據一堆的條件透過複雜的邏輯判斷去組合成一段 T-SQL,而且這些組合的 T-SQL 通常都是在做很重要的功能,所以重構時就必須要確保重構之後必須要跟原本的輸出一樣,而且還要挑出錯誤與重整邏輯,為了要確保重構後的程式品質,資料存取層的單元測試就真的很重要。

 

TestResources 專案

好了,前面廢話了一大堆,馬上進入主題。

以下是在 TestResources 專案 DB 目錄裡有關建立單元測試用 LocalDB 的一些程式,

image

記得要在 TestResources 專案裡透過 NuGet 安裝 Entity Framework 6.1.3

image

在測試程式裡,我會將一些固定的字串變數給集中起來,例如 Database Name、Table Name 與 Test DbConnection 等,在測試程式裡使用就會比較方便。

 

DatabaseName.cs

如果專案裡有使用多個 Database,就增加有用到的 Database 名稱

using System;
 
namespace NorthwindRepository.TestResources.DB
{
    public class DatabaseName
    {
        public const string Default = "master";
 
        public const string Northwind = "Northwind";
    }
}

 

TableNames.cs

依據 Table 所存在的 Database,分門別類的去將 Table Name 建立在各個 Database 名稱的類別下

using System;
 
namespace NorthwindRepository.TestResources.DB
{
    public class TableNames
    {
        public class Northwind
        {
            public const string Customers = "Customers";
        }
    }
}

 

TestDbConnection.cs

這個類別要特別說明的是 Default,這是預設的 LocalDB 資料庫連線字串,相當重要,因為要建立 LocalDB 的一些指令必須要透過這個連線字串的 DbConnection 去執行才可以。

LocalDbConnectionString 這是提供給不同資料庫連線的 ConnectionString Template,開發者可以自行在 TestDbConnection 裡自行去增加不同資料庫的連線字串。

using System;
 
namespace NorthwindRepository.TestResources.DB
{
    public class TestDbConnection
    {
        public class LocalDb
        {
            public const string LocalDbConnectionString =
                    @"Data Source=(LocalDB)\MSSQLLocalDB;Initial Catalog={0};Integrated Security=True;MultipleActiveResultSets=True";
 
            public static string Default =>
                    @"Data Source=(LocalDB)\MSSQLLocalDB;Initial Catalog=master;Integrated Security=True";
 
            public static string Northwind =>
                    string.Format(LocalDbConnectionString, DatabaseName.Northwind);
        }
    }
}

上面的資料庫連線都是以使用 LocalDB v12.0 的版本,所以 Data Source 是使用 (LocalDB)\MSSQLLocalDB,如果開發者的環境或 CI 環境還是使用 LocalDB v11.0 的話,就請自己改成 (LocalDB)\v11.0

延伸閱讀:KingKong Bruce記事: 升級舊專案中SQLLocalDb v11.0至v12.0

 

TestDatabase.cs

這個類別是這篇文章的重點,不過我要老實說,這裡面的程式是我參考別人的程式,不過有大幅改寫許多的地方,以符合專案與開發團隊的使用情境,但是… 我並不是要刻意隱瞞或是隱蔽參考的來源,而是我真的忘了參考來源,所以還麻煩大家可以幫我找找,如果有找到的話,在底下的留言將連結貼上,謝謝。

using System;
using System.Configuration;
using System.Data.Entity;
using System.Data.SqlClient;
using System.IO;
using System.Reflection;
using System.Text;
 
namespace NorthwindRepository.TestResources.DB
{
    public class TestDatabase
    {
        private readonly string _testConnectionString = 
            string.Concat(
                TestDbConnection.LocalDb.LocalDbConnectionString,
                ";AttachDBFilename={0}.mdf");
 
        private string DatabaseName { get; set; }
 
        private string ConnectionString { get; set; }
 
        public TestDatabase(string databaseName)
        {
            this.DatabaseName = databaseName;
            this.ConnectionString = string.Format(_testConnectionString, databaseName);
        }
 
        //-----------------------------------------------------------------------------------------
 
        /// <summary>
        /// 使用 EntityFramework 的 Database 類別 Exists 方法,確認 LocalDB 是否存在.
        /// </summary>
        /// <returns><c>true</c> if [is local database exists] [the specified connection string]; otherwise, <c>false</c>.</returns>
        public bool IsLocalDbExists()
        {
            using (var connection = new SqlConnection(this.ConnectionString))
            {
                return Database.Exists(connection);
            }
        }
 
        /// <summary>
        /// 使用 EntityFramework 的 Database 類別 Delete 方法,確認 LocalDB 存在後再移除.
        /// </summary>
        /// <param name="connectionString">The connection string.</param>
        public void DeleteLocalDb(string connectionString = "")
        {
            string currentConnectionString = this.ConnectionString;
 
            if (!string.IsNullOrWhiteSpace(connectionString))
            {
                currentConnectionString = connectionString;
            }
 
            if (!currentConnectionString.ToLower().Contains("localdb"))
            {
                return;
            }
 
            using (var connection = new SqlConnection(currentConnectionString))
            {
                if (Database.Exists(connection))
                {
                    Database.Delete(connection);
                }
            }
        }
 
        //-----------------------------------------------------------------------------------------
 
        /// <summary>
        /// Creates the database.
        /// </summary>
        public void CreateDatabase()
        {
            this.DetachDatabase();
 
            var fileName = this.CleanupDatabase();
 
            using (var connection = new SqlConnection(TestDbConnection.LocalDb.Default))
            {
                var commandText = new StringBuilder();
                commandText.AppendFormat(
                    "CREATE DATABASE {0} ON (NAME = N'{0}', FILENAME = '{1}.mdf');",
                    this.DatabaseName,
                    fileName);
 
                connection.Open();
                var cmd = connection.CreateCommand();
                cmd.CommandText = commandText.ToString();
                cmd.ExecuteNonQuery();
            }
        }
 
        /// <summary>
        /// Initializes the connection string.
        /// </summary>
        /// <param name="connectionStringName">Name of the connection string.</param>
        public void InitConnectionString(string connectionStringName)
        {
            var connectionString = string.Format(
                _testConnectionString,
                this.DatabaseName,
                this.DatabaseFilePath());
 
            var config = ConfigurationManager.OpenExeConfiguration(
                Assembly.GetCallingAssembly().Location);
 
            var settings = config.ConnectionStrings.ConnectionStrings[connectionStringName];
            if (settings == null)
            {
                settings = new ConnectionStringSettings(
                    connectionStringName,
                    connectionString,
                    "System.Data.SqlClient");
 
                config.ConnectionStrings.ConnectionStrings.Add(settings);
            }
            settings.ConnectionString = connectionString;
            config.Save();
            ConfigurationManager.RefreshSection("connectionStrings");
        }
 
        /// <summary>
        /// Cleanups the database.
        /// </summary>
        /// <returns>System.String.</returns>
        private string CleanupDatabase()
        {
            var fileName = this.DatabaseFilePath();
            try
            {
                var mdfPath = string.Concat(fileName, ".mdf");
                var ldfPath = string.Concat(fileName, "_log.ldf");
 
                var mdfExists = File.Exists(mdfPath);
                var ldfExists = File.Exists(ldfPath);
 
                if (mdfExists) File.Delete(mdfPath);
                if (ldfExists) File.Delete(ldfPath);
            }
            catch
            {
                Console.WriteLine("Could not delete the files (open in Visual Studio?)");
            }
            return fileName;
        }
 
        /// <summary>
        /// Detaches the database.
        /// </summary>
        /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
        private void DetachDatabase()
        {
            using (var connection = new SqlConnection(TestDbConnection.LocalDb.Default))
            {
                connection.Open();
                var cmd = connection.CreateCommand();
                cmd.CommandText = $"exec sp_detach_db '{this.DatabaseName}'";
                try
                {
                    cmd.ExecuteNonQuery();
                }
                catch
                {
                    Console.WriteLine("Could not detach");
                }
            }
        }
 
        /// <summary>
        /// Databases the file path.
        /// </summary>
        /// <returns>System.String.</returns>
        private string DatabaseFilePath()
        {
            return Path.Combine(
                Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location),
                this.DatabaseName);
        }
 
    }
}

 

MSDN - Database 類別 (System.Data.Entity)
https://msdn.microsoft.com/zh-tw/library/system.data.entity.database(v=vs.113).aspx

 


這篇就先到這裡,怎麼在 RepositoryTests 專案裡使用上面的程式去建立單元測試用的 LocalDB 呢?就留到下一篇文章裡做說明。

資料存取層的單元測試使用 LocalDB,這看起來似乎可以解決測試隔離的問題,因為很多開發人員在做Repository 單元測試時都無法避免去連接實際的資料庫,資料庫雖然不是正式環境的 Database Server,但無論是開發用或是 UAT 測試用的資料庫都會是許多開發人員在共用,所以裡頭的資料就會隨時的被更改,以致於單元測試案例的不穩定,另外開發者在自己的環境裡去建立一個固定的 LocalDB 當作單元測試的測試資料庫,這也只能解決開發環境執行單元測試的問題,一旦專案是多人開發時就會面臨到開發人員要先手動去建立本機 LocalDB 的情況,這並不是一個聰明的做法。而且當你有做 CI (持續整合)時,你不可能為了讓測試都要通過而在 CI 環境裡去建立一個固定的 LocalDB,所以透過 Entity Framework 和指令的方式去動態建立與移除單元測試用的 LocalDB 就是必須,而且也是相當關鍵的一環。

截至目前為止,我開發的專案裡最多的單元測試就是 Repository 的測試,因為有太多的資料存取邏輯還是從舊有程式過來的,經過改寫與重構就會再加上單元測試,可見過去程式的驚人程度。

 

以上

4 則留言:

  1. Kevin大大:
    我有個疑問,為何不使用dbContext.Database.Delete(),是有啥特殊的情況會發生嗎?

    using (var dbContext = new TestDbContext())
    {
    if (dbContext.Database.Exists())
    {
    dbContext.Database.Delete();
    }
    }

    回覆刪除

提醒

千萬不要使用 Google Talk (Hangouts) 或 Facebook 及時通訊與我聯繫、提問,因為會掉訊息甚至我是過了好幾天之後才發現到你曾經傳給我訊息過,請多多使用「詢問與建議」(在左邊,就在左邊),另外比較深入的問題討論,或是有牽涉到你實作程式碼的內容,不適合在留言板裡留言討論,請務必使用「詢問與建議」功能(可以夾帶檔案),謝謝。