2016年10月24日 星期一

Repository 測試使用 LocalDB - Part.1

去年有寫過一篇關於在測試專案裡使用 LocalDB 的文章「測試專案使用 LocalDB - 使用 Entity Framework 的情境」,的確是一個可以執行的做法,但是在多人開發的專案或是多分支開發的專案裡,這樣的做法會帶來一些問題,另外在一些環境裡執行單元測試時會遇到權限不足的問題(例如登入使用者並不具有 Administrator 權限時)會造成測試執行失敗,以致於最後我不在使用那種方式。

既然不用了也還是必須要有替代的方式去完成 Repository 的單元測試,於是我之後嘗試使用指令碼的方式在執行 Repository 測試前建立 LocalDB、執行測試後再移除 LocalDB,這樣的做法也真的可行,不過卻相當不穩定,當測試全部都執行正確時是不會有問題,但是一旦當一個錯誤發生問題時,建立的 LocalDB instance 就會被鎖住而無法正確釋放、移除,這樣的問題一直困擾著我很久。

接下來的幾篇文章將會介紹一個穩定而且不會發生測試失敗就把 LocalDB 鎖住的方式,藉助 Entity Framework 所提供的類別與方法來完成 Repository 測試的 LocalDB 建立與移除,讓我們能夠以更為簡易的方式來完成 Repositoy 的單元測試。

 


接著幾篇文章將會使用到過去以來一直到前不久的文章裡面的內容,所以請各位務必要先熟悉,我就不再重複的交代,如下:

 

程式說明

先直接看要測試的專案與程式內容,

image

這是一個很簡單的程式,使用的資料庫是「Northwind」,建立一個 CustomerRepository 類別,透過 Dapper 去讀取 Customers 資料庫表格裡的資料,

CustomerModel

namespace NorthwindRepository.Models
{
    public class CustomerModel
    {
        public string CustomerID { get; set; }
 
        public string CompanyName { get; set; }
 
        public string ContactName { get; set; }
 
        public string ContactTitle { get; set; }
 
        public string Address { get; set; }
 
        public string City { get; set; }
 
        public string Region { get; set; }
 
        public string PostalCode { get; set; }
 
        public string Country { get; set; }
 
        public string Phone { get; set; }
 
        public string Fax { get; set; }
    }
}


介面 ICustomerRepository

using System.Collections.Generic;
using NorthwindRepository.Models;
 
namespace NorthwindRepository.Interface
{
    public interface ICustomerRepository
    {
        List<CustomerModel> GetAll();
 
        CustomerModel Get(string customerId);
    }
}

實作 CustomerRepository

using System;
using System.Collections.Generic;
using System.Linq;
using Dapper;
using NorthwindRepository.Database;
using NorthwindRepository.Interface;
using NorthwindRepository.Models;
 
namespace NorthwindRepository.Implements
{
    /// <summary>
    /// CustomerRepository
    /// </summary>
    public class CustomerRepository : ICustomerRepository
    {
        private IDatabaseConnectionFactory DatabaseConnectionFactory { get; }
 
        public CustomerRepository(IDatabaseConnectionFactory factory)
        {
            this.DatabaseConnectionFactory = factory;
        }
 
        /// <summary>
        /// 取得全部資料
        /// </summary>
        /// <returns></returns>
        public List<CustomerModel> GetAll()
        {
            var dbConnection = this.DatabaseConnectionFactory.Create();
            using (var conn = dbConnection)
            {
                var sqlCommand = "select * from dbo.Customers";
                var result = conn.Query<CustomerModel>(sqlCommand);
                return result.ToList();
            }
        }
 
        /// <summary>
        /// 以 CustomerID 取得指定資料
        /// </summary>
        /// <param name="customerId"></param>
        /// <returns></returns>
        public CustomerModel Get(string customerId)
        {
            if (string.IsNullOrWhiteSpace(customerId))
            {
                throw new ArgumentNullException(nameof(customerId));
            }
 
            var dbConnection = this.DatabaseConnectionFactory.Create();
 
            using (var conn = dbConnection)
            {
                var sqlCommand = "select * from dbo.Customers where CustomerID = @CustomerID";
 
                var result = conn.QueryFirstOrDefault<CustomerModel>(
                    sqlCommand,
                    new
                    {
                        CustomerID = customerId
                    });
 
                return result;
            }
        }
    }
}

 

一般寫 Repository 程式內容時,不管是使用傳統的 ADO.NET 或 Dapper,都不應該在程式裡直接去建立 DbConnection,我這邊的做法是建立一個 DatabaseConnectionFactory,程式要取得 DbConnection 就必須要透過 DatabaseConnectionFactory 建立。

介面 IDatabaseConnectionFactory

using System.Data;
 
namespace NorthwindRepository.Database
{
    public interface IDatabaseConnectionFactory
    {
        IDbConnection Create();
    }
}

實作 DatabaseConnectionFactory

using System;
using System.Data;
using System.Data.SqlClient;
 
namespace NorthwindRepository.Database
{
    public class DatabaseConnectionFactory : IDatabaseConnectionFactory
    {
        private readonly string _connectionString;
 
        public DatabaseConnectionFactory(string connectionString)
        {
            if (string.IsNullOrWhiteSpace(connectionString))
            {
                throw new ArgumentNullException(nameof(connectionString));
            }
 
            this._connectionString = connectionString;
        }
 
        /// <summary>
        /// Create DbConnection
        /// </summary>
        /// <returns></returns>
        public IDbConnection Create()
        {
            var sqlConnection = new SqlConnection(_connectionString);
            return sqlConnection;
        }
    }
}

 

準備測試資料

這邊我先使用從原始資料庫匯出資料到 CSV 檔案的方式準備測試資料,至於怎麼準備、怎麼執行與匯出,就請先看過這篇文章的內容「輸出測試用資料的 CSV 檔案 - 使用 LINQPad, AutoMapper, CsvHelper

image

測試資料與相關測試資料庫的一些類別,我是另外建立一個「TestResources」類別庫專案來放置,與 RepositoryTests 單元測試專按做區隔,讓單元測試專案盡量只有單元測試類別而已。

 

SQL Scripts

建立 Table 的 Script 可以直接在 SSMS 裡去產生

image

而 Insert value Script 則是可以看「使用 LINQPad 快速產生 Table 的 Insert Script」這篇文章

程式內容

namespace NorthwindRepository.TestResources.TableSchemas
{
    public class Northwind_Tables
    {
        public static string Customers_Create()
        {
            var sqlCommand = new System.Text.StringBuilder();
            sqlCommand.AppendLine(@"IF OBJECT_ID('dbo.Customers', 'U') IS NOT NULL");
            sqlCommand.AppendLine(@"  DROP TABLE dbo.Customers; ");
            sqlCommand.AppendLine(@"");
            sqlCommand.AppendLine(@"CREATE TABLE [dbo].[Customers](");
            sqlCommand.AppendLine(@"    [CustomerID] [nchar](5) NOT NULL,");
            sqlCommand.AppendLine(@"    [CompanyName] [nvarchar](40) NOT NULL,");
            sqlCommand.AppendLine(@"    [ContactName] [nvarchar](30) NULL,");
            sqlCommand.AppendLine(@"    [ContactTitle] [nvarchar](30) NULL,");
            sqlCommand.AppendLine(@"    [Address] [nvarchar](60) NULL,");
            sqlCommand.AppendLine(@"    [City] [nvarchar](15) NULL,");
            sqlCommand.AppendLine(@"    [Region] [nvarchar](15) NULL,");
            sqlCommand.AppendLine(@"    [PostalCode] [nvarchar](10) NULL,");
            sqlCommand.AppendLine(@"    [Country] [nvarchar](15) NULL,");
            sqlCommand.AppendLine(@"    [Phone] [nvarchar](24) NULL,");
            sqlCommand.AppendLine(@"    [Fax] [nvarchar](24) NULL,");
            sqlCommand.AppendLine(@" CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED ");
            sqlCommand.AppendLine(@"(");
            sqlCommand.AppendLine(@"    [CustomerID] ASC");
            sqlCommand.AppendLine(@"));");
 
            return sqlCommand.ToString();
        }
 
        public static string Customers_Insert()
        {
            var sqlCommand = new System.Text.StringBuilder(371);
            sqlCommand.AppendLine(@"INSERT INTO [dbo].[Customers]");
            sqlCommand.AppendLine(@"(");
            sqlCommand.AppendLine(@"  [CustomerID],");
            sqlCommand.AppendLine(@"  [CompanyName],");
            sqlCommand.AppendLine(@"  [ContactName],");
            sqlCommand.AppendLine(@"  [ContactTitle],");
            sqlCommand.AppendLine(@"  [Address],");
            sqlCommand.AppendLine(@"  [City],");
            sqlCommand.AppendLine(@"  [Region],");
            sqlCommand.AppendLine(@"  [PostalCode],");
            sqlCommand.AppendLine(@"  [Country],");
            sqlCommand.AppendLine(@"  [Phone],");
            sqlCommand.AppendLine(@"  [Fax]");
            sqlCommand.AppendLine(@")");
            sqlCommand.AppendLine(@"VALUES");
            sqlCommand.AppendLine(@"(");
            sqlCommand.AppendLine(@"  @CustomerID,");
            sqlCommand.AppendLine(@"  @CompanyName,");
            sqlCommand.AppendLine(@"  @ContactName,");
            sqlCommand.AppendLine(@"  @ContactTitle,");
            sqlCommand.AppendLine(@"  @Address,");
            sqlCommand.AppendLine(@"  @City,");
            sqlCommand.AppendLine(@"  @Region,");
            sqlCommand.AppendLine(@"  @PostalCode,");
            sqlCommand.AppendLine(@"  @Country,");
            sqlCommand.AppendLine(@"  @Phone,");
            sqlCommand.AppendLine(@"  @Fax");
            sqlCommand.AppendLine(@");");
 
            return sqlCommand.ToString();
        }
    }
}

 

有建立 Table 與 Insert Value 的 Script 內容,還要另外準備移除 Table 以及清空 Table 內容的 Script,每個單元測試類別與單元測試方法都是獨立的,不應該有順序性或是相依性,所以在一個單元測試類別裡所建立且匯入測試資料的測試環境,都必須要在該單元測試類別完成後要做移除 Table 的處理。

千萬不要想其他單元測試類別會用到而去建立一個共用的測試環境,這相當不建議這麼做,還是那句話,你要確保單元測試的獨立與隔離,讓測試之間不會有影響與相依,否則測試程式都亂七八糟的,那麼這樣的測試還有什麼可信度呢?

移除與清空 Table 的 Script 程式

using System;
 
namespace NorthwindRepository.TestResources.TableSchemas
{
    /// <summary>
    /// Class TableCommands.
    /// </summary>
    public static class TableCommands
    {
        /// <summary>
        /// Drops the table.
        /// </summary>
        /// <param name="tableName">Name of the table.</param>
        /// <returns>System.String.</returns>
        /// <exception cref="ArgumentNullException">please input tableName.</exception>
        public static string DropTable(string tableName)
        {
            if (string.IsNullOrWhiteSpace(tableName))
            {
                throw new ArgumentNullException(nameof(tableName), "please input tableName.");
            }
 
            var sqlCommand = new System.Text.StringBuilder();
            sqlCommand.AppendLine($@"IF OBJECT_ID('dbo.{tableName}', 'U') IS NOT NULL");
            sqlCommand.AppendLine($@"  DROP TABLE dbo.{tableName}; ");
            return sqlCommand.ToString();
        }
 
        /// <summary>
        /// Truncates the table.
        /// </summary>
        /// <param name="tableName">Name of the table.</param>
        /// <returns>System.String.</returns>
        /// <exception cref="ArgumentNullException">please input tableName.</exception>
        public static string TruncateTable(string tableName)
        {
            if (string.IsNullOrWhiteSpace(tableName))
            {
                throw new ArgumentNullException(nameof(tableName), "please input tableName.");
            }
 
            var sqlCommand = new System.Text.StringBuilder();
            sqlCommand.AppendLine($@"IF OBJECT_ID('dbo.{tableName}', 'U') IS NOT NULL");
            sqlCommand.AppendLine($@"  TRUNCATE TABLE dbo.{tableName}; ");
            return sqlCommand.ToString();
        }
    }
 
}

 

測試相關專案使用的 NuGet Packages

在進入到下一篇說明怎麼透過 Entity Framework 建立與移除 LocalDB 之前,讓大家知道在 TestResources 與 RepositoryTests 專案裡所使用的 NuGet Packages,

TestResources 專案會需要安裝 Entity Framework 6.1.3

image

RepositoryTests 專案會安裝以下的 Packages

image

 


這一篇就先到這裡,因為接下來的篇幅會比較長一點,所以分成兩篇來做說明。

這次介紹的內容是我正在使用方式,同時也是我公司開發團隊也在使用的方式(這兩年多來所分享的文章內容都是有公司開發團隊的實證經驗),通常我都是自己先嘗試各種的方式,找出最適合的做法並且實際應用在我的專案裡,如果運行的好並且也有不錯的成效,接下來我就會在公司裡透過教育訓練的方式介紹給同事,然後開始在各開發團隊裡去導入使用,如果有任何的問題或反饋,我就會再做改進與調整,最後就會寫成文章分享出來。

 

以上

沒有留言:

張貼留言

提醒

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