Repository 測試使用 LocalDB - Part.3

接續上一篇「Repository 測試使用 LocalDB - Part.2 」的內容,在上一篇已經完成使用 Entity Framework 建立與移除 LocalDB 的類別和方法,在第一篇「Repository 測試使用 LocalDB - Part.1 」也說明了要受測試的目標類別與方法,另外也建立了要在單元測試裡所使用的測試資料。

這一篇就要完成 Repository 的單元測試,會用到前面兩篇所建立的類別與資料,所以請各位要仔細看清楚囉。

RepositoryTests 專案內容

image

RepositoryTests 專案會參考原本的 Repository 專案,另外也必須要參考 TestResources 專案

SNAGHTML4e98b17

透過 NuGet 安裝以下的幾個套件

image

 

TestHook.cs

首先來說明這一個檔案,這是相當重要的一個單元測試類別,因為這邊會使用到 AssemblyInitialize 和 AssemblyCleanup 這兩個很少會使用到的特性方法,標上這兩個特性的靜態方法會在該單元測試專案組件裡的所有單元測試前與單元測試執行後會去執行方法的內容。

MSDN - AssemblyInitializeAttribute 類別 (Microsoft.VisualStudio.TestTools.UnitTesting)
MSDN - AssemblyCleanupAttribute 類別 (Microsoft.VisualStudio.TestTools.UnitTesting)

using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NorthwindRepository.TestResources.DB;
 
namespace NorthwindRepositoryTests
{
    [TestClass]
    public class TestHook
    {
        [AssemblyInitialize]
        public static void AssemblyInitialize(TestContext context)
        {
            // Northwind
            var northwindDatabase = new TestDatabase(DatabaseName.Northwind);
            if (northwindDatabase.IsLocalDbExists())
            {
                northwindDatabase.DeleteLocalDb();
            }
            northwindDatabase.CreateDatabase();
        }
 
        [AssemblyCleanup]
        public static void AssemblyCleanup()
        {
            var defaultDatabase = new TestDatabase(DatabaseName.Default);
            defaultDatabase.DeleteLocalDb(TestDbConnection.LocalDb.Northwind);
        }
    }
}

TestHook 類別最主要的工作就是在所有單元測試執行前去建立 LocalDB,在所有單元測試執行後再去移除 LocalDB。

 

必要的處理

在第一篇「Repository 測試使用 LocalDB - Part.1」裡有做了匯出測試資料到 CSV 檔案的處理,要記得需要去修改 CSV 檔案的屬性,建置動作為「內容」,複製到輸出目錄為「一律複製

image

還有一個處理要做,就是要在方案層級裡去新增「測試設定」的檔案

image

MSDN - 測試設定 - 如何:部署測試的檔案

「 在執行測試之前,必須先將測試和應用程式連同其參考的其他組件,一併複製到它們可以執行的位置。 測試通常還需要其他檔案,例如測試資料、組態檔、資料庫和明確載入的組件。 若要讓測試能夠使用這些額外的檔案,您必須指定要部署這些檔案。 」

如果是將測試資料的 CSV 檔案放在 RepositoryTests 專案裡是不需要用到測試設定,因為單元測試執行是可以取得測試資料檔案(建置動作與複製到輸出目錄的屬性還是要修改),但因為我是將測試資料放在 TestResources 專案裡,所以會用到「測試設定」其中的「部署」功能,

SNAGHTML50a15a8

部署可以用加入檔案的方式,或是加入目錄的方式,加入目錄的話就會將目錄下的所有檔案在執行測試時都部署到單元測試專案的 bin\debug 資料夾裡。

上面的屬性修改與測試設定檔案的加入和啟用部署功能可不要忘記啦。

 

CustomerRepositoryTests.cs

這是對 Repository 專案裡的 CustomerRepository 類別進行測試的單元測試類別,這邊會使用到測試資料的 CSV 檔案,所以要記得在單元測試類別上使用 DeploymentItem 特性。

MSDN - DeploymentItemAttribute 類別 (Microsoft.VisualStudio.TestTools.UnitTesting)

指定應該在執行測試之前的組件一起部署的檔案或目錄。
image

 

因為是使用單元測試用的 LocalDB,所以就要修改 CustomerRepository 所使用到資料庫連線字串,所以這邊會在 TestInitialize 方法裡透過 NSubstititute 去讓 DatabaseConnectionFactory 的 Create 方法回傳 LocalDB 資料庫連線的 SqlConnection。

MSDN - TestInitializeAttribute 類別 (Microsoft.VisualStudio.TestTools.UnitTesting)

方法要在測試之前執行,以便配置和設定測試類別中所有測試所需的資源。

image

 

匯入測試資料到 LocalDB

要將 CSV 測試資料匯入到 LocalDB 裡,首先要先在 LocalDB 裡建立 Customers 表格

image

建立好 Customers 表格之後,就是要將 CSV 測試資料給匯入到 LocalDB 裡,會使用到 CsvHelper 與 Dapper,

image

參考資料:Dapper 練習題 - 新增多筆或大量資料

前陣子我看到同事在做這部分測試資料的使用方式與我當初分享给他們時的做法有很大的出入,他們是把匯入測試資料的程式給寫在另外一個類別裡,並沒有正確的使用 DeployItem Attribute,而是在該方法裡去使用相對路徑的方式讀取 CSV 檔案,以致於測試執行時出現了某些單元測試無法正確讀取 CSV 檔案的狀況,以下是我對於該狀況的看法:

單元測試的測試資料,如果是匯出為 Excel, CSV 或 SQL Script 檔案,必須要在單元測試類別正確的使用 DeployItem Attribute 將檔案給載入,而且不要直接載入整個放測試資料檔案的目錄

我的習慣做法是該單元測試類別有用到哪些檔案就使用多少個 DeployItem 去載入這些資料檔案,而不會去建立所謂的共用類別,因為沒有必要這麼做,如果去做了所謂的共用方法是會發生載入檔案的路徑錯亂的問題

單元測試類別的共用方法,除非是真的很一般性的輔助類,不然盡量少去建立,將測試情境的上下脈絡給單純化,減少共用所帶來的複雜性所衍生的問題,很多問題是不當共用而產生的,單元測試就盡量單純,不要老想怎麼少寫程式碼,該寫的程式碼還是要乖乖的去寫呀

 

移除測試用的 Table

不要想說在單元測試用的 LocalDB 已經建立好 Table 也匯入了資料,其他的單元測試類別可能也會用到,所以就不要每次都要除新建立與匯入資料,所以也就不用移除…

為了保持單元測試之間的隔離與不會相互影響,所以該移除或是該清空表格與資料就必須要做。

image

有些情境會需要在每個單元測試方法執行完畢之後就要重置表格裡的資料,執行單元測試方法前要重新匯入測試資料,並且在單元測試方法執行完畢後去清空表格,不過這是要看受測目標方法、類別的需求而定。

上面的「建立 Table、匯入測試資料」「移除表格」的方法分別使用了 TestInitialize Attribute 與 TestCleanup Attribute,這是在執行單元測試類別裡的所有單元測試方法前與執行後會去執行的內容。

MSDN - TestInitializeAttribute 類別 (Microsoft.VisualStudio.TestTools.UnitTesting)

此方法要在測試之前執行,以便配置和設定測試類別中所有測試所需的資源。

MSDN - TestCleanupAttribute 類別 (Microsoft.VisualStudio.TestTools.UnitTesting)

該方法所包含的程式碼必須於測試完成執行之後使用,以便釋放此測試類別中所有測試所佔用的資源。

 

CustomerRepositoryTests.cs

完整程式內容如下:

using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.IO;
using CsvHelper;
using Dapper;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NorthwindRepository.Database;
using NorthwindRepository.Implements;
using NorthwindRepository.Models;
using NorthwindRepository.TestResources.DB;
using NorthwindRepository.TestResources.SourceClass;
using NorthwindRepository.TestResources.TableSchemas;
using NSubstitute;
using ExpectedObjects;
 
namespace NorthwindRepositoryTests.Implements
{
    [TestClass()]
    [DeploymentItem(@"SourceData\Customer_Data.csv")]
    public class CustomerRepositoryTests
    {
        private IDatabaseConnectionFactory DatabaseConnectionFactory { get; set; }
 
        [TestInitialize]
        public void TestInitialize()
        {
            this.DatabaseConnectionFactory = Substitute.For<IDatabaseConnectionFactory>();
 
            this.DatabaseConnectionFactory.Create()
                .Returns(new SqlConnection(TestDbConnection.LocalDb.Northwind));
        }
 
        private CustomerRepository GetSystemUnderTest()
        {
            var sut = new CustomerRepository(this.DatabaseConnectionFactory);
            return sut;
        }
 
        #region -- Prepare to Test --
 
        public TestContext TestContext { get; set; }
 
        [ClassInitialize()]
        public static void MyClassInitialize(TestContext testContext)
        {
            CreateTable();
            PrepareData();
        }
 
        private static void CreateTable()
        {
            using (var conn = new SqlConnection(TestDbConnection.LocalDb.Northwind))
            {
                conn.Open();
                string sqlCommand = Northwind_Tables.Customers_Create();
                conn.Execute(sqlCommand);
            }
        }
 
        private static void PrepareData()
        {
            List<Customer_Data> sourceData = new List<Customer_Data>();
            using (var sr = new StreamReader(@"Customer_Data.csv"))
            {
                using (var reader = new CsvReader(sr))
                {
                    var records = reader.GetRecords<Customer_Data>();
                    sourceData.AddRange(records);
                }
            }
 
            using (var conn = new SqlConnection(TestDbConnection.LocalDb.Northwind))
            {
                conn.Open();
                using (SqlTransaction trans = conn.BeginTransaction())
                {
                    var sqlCommand = Northwind_Tables.Customers_Insert();
                    conn.Execute(sqlCommand, sourceData, transaction: trans);
                    trans.Commit();
                }
            }
        }
 
        [ClassCleanup()]
        public static void TestClassCleanup()
        {
            using (var conn = new SqlConnection(TestDbConnection.LocalDb.Northwind))
            {
                conn.Open();
                string sqlCommand = TableCommands.DropTable(TableNames.Northwind.Customers);
                conn.Execute(sqlCommand);
            }
        }
 
        #endregion
 
        //-----------------------------------------------------------------------------------------
 
        [TestMethod()]
        public void GetAll_取得全部的Customer資料()
        {
            // arrange
            var sut = this.GetSystemUnderTest();
 
            // act
            var actual = sut.GetAll();
 
            // assert
            actual.Should().NotBeNull();
            actual.Count.Should().Be(91);
        }
 
        [TestMethod]
        public void Get_CustomerId輸入ALFKI_應回傳符合的資料()
        {
            // arrange
            var customerId = "ALFKI";
 
            CustomerModel expected = new CustomerModel
            {
                CustomerID = "ALFKI",
                CompanyName = "Alfreds Futterkiste",
                ContactName = "Maria Anders",
                ContactTitle = "Sales Representative",
                Address = "Obere Str. 57",
                City = "Berlin",
                Region = "",
                PostalCode = "12209",
                Country = "Germany",
                Phone = "030-0074321",
                Fax = "030-0076545"
            };
 
            var sut = this.GetSystemUnderTest();
 
            // assert
            var actual = sut.Get(customerId);
 
            // act
            actual.Should().NotBeNull();
            expected.ToExpectedObject().ShouldEqual(actual);
        }
    }
}

 

執行結果

Visual Studio 測試總管

image

ReSharper Unit Test Sessions

image

 


這幾篇文章所做的範例程式在之後我會分享到 Github 上,接著應該還有一篇的內容,所以先不用急…

 

以上

純粹是在寫興趣的,用寫程式、寫文章來抒解工作壓力