2016年7月29日 星期五

【Unit Test】針對Repository做單元測試 (二)


曾上一篇,開始在我們的Production專案寫一段跟DB要資料的程式吧。(註 : 這邊不考慮分層與物件導向問題,一切專注在單元測試上)

首先先在專案中加入EntityFramework,並把Northwind的Employees Table加進來








接著寫一段程式,讓我們帶入ID能順利取回該筆員工資料




public class EmployeesRepository :IDisposable
    {
        NorthwindEntities DBContext;
        bool disposedValue;
        public EmployeesRepository()
        {
            DBContext = new NorthwindEntities();
            disposedValue = false;
        }

        public Employees Get(int id)
        {
            return DBContext.Employees.FirstOrDefault(x => x.EmployeeID == id);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (!disposedValue)
            {
                if (DBContext != null)
                    DBContext.Dispose();

                disposedValue = true;
            }
        }

        public void Dispose()
        {
            Dispose(disposedValue);
             GC.SuppressFinalize(this);
        }
    }


改寫HomeController的Index Action,依據帶入的ID取回員工,並顯示於葉面上


View的部分(一切從簡 XDD)


來執行看看這段Code有沒有用!!!





所以這段Code的確可以正常運作,確定之後我們來寫單元測試驗證這件事情。

首先在單元測試專案建立EmployeesRepositoryTests



上一篇有提到,這邊做的單元測試是每個測試前將準備好的CSV資料匯入LocalDB,做完測試後把資料全部砍掉,所以我們得先寫一段TestInitial讓每次測試之前先跑匯入資料的部分,先在單元測試專把CSVHelper安裝起來。



接著寫程式把資料從CSV讀出來,並寫入LocalDB,但首先先將測試專案安裝EntityFramwork套件







然後記得把連線字串加到單元測試的專案之中


<connectionStrings>
      <add name="NorthwindEntities" connectionString="metadata=res://*/Models.Northwind.csdl|res://*/Models.Northwind.ssdl|res://*/Models.Northwind.msl;provider=System.Data.SqlClient;provider connection string=&quot;data source=(LocalDB)\MSSQLLocalDB;attachdbfilename=|DataDirectory|\TestDB.mdf;integrated security=True;MultipleActiveResultSets=True;App=EntityFramework&quot;" providerName="System.Data.EntityClient" />
</connectionStrings>





寫以下程式




using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WebApplication4.Models;
using System.IO;
using CsvHelper;
using System.Linq;
using System.Reflection;
using System.Linq;
namespace WebApplication4.Tests
{
    [TestClass]
    public class EmployeesRepositoryTests
    {
        
        [TestInitialize]
        public void Initial()
        {
            //讀取檔案
            using (StreamReader reader = new StreamReader(
                string.Concat(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"\CSVs\Employees.csv"), 
                new UTF8Encoding()))
            using (var csvReader = new CsvReader(reader))
            {
                csvReader.Configuration.WillThrowOnMissingField = false;
                var Employees = csvReader.GetRecords<Employees>().ToList();

                //將資料寫入DB
                var DBContext = new NorthwindEntities();
                DBContext.Employees.AddRange(Employees);
                DBContext.SaveChanges();
            }
        }

        [TestMethod]
        public void Get_帶入ID_應取回該ID的Employee()
        {
            //arrange
            var ID = 0;
            var Sut = new EmployeesRepository();

            var Expected = "Nancy";
            //act
            var actual = Sut.Get(ID);

            //assert
            Assert.AreEqual(Expected, actual.FirstName);
        }
    }
}




執行看看會發現爆掉了!!!!!! 果然事情不是憨人我想的那麼簡單


原因出在於Employee這個Table的ID欄位為流水自動編號,透過EF是無法寫入的,這時候只好透過Dapper下指令解決了,先安裝Dapper



將剛剛從CSVHelper的資料用Dapper塞進去,而且要將Identity Insert打開,先補上要給Dapper用的連線字串

<add name="NorthwindString" connectionString="Data Source=(LocalDB)\MSSQLLocalDB;attachdbfilename=|DataDirectory|\TestDB.mdf;Persist Security Info=True;" providerName="System.Data.SqlClient" />

把程式改成如下

using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WebApplication4.Models;
using System.IO;
using CsvHelper;
using System.Linq;
using System.Reflection;
using System.Data.SqlClient;
using Dapper;
namespace WebApplication4.Tests
{
    [TestClass]
    public class EmployeesRepositoryTests
    {
        
        [TestInitialize]
        public void Initial()
        {
            //讀取檔案
            using (StreamReader reader = new StreamReader(
                string.Concat(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), @"\CSVs\Employees.csv"), 
                new UTF8Encoding()))
            using (var csvReader = new CsvReader(reader))
            {
                csvReader.Configuration.WillThrowOnMissingField = false;
                var Employees = csvReader.GetRecords<Employees>().ToList();

                //將資料寫入DB
                //var DBContext = new NorthwindEntities();
                //DBContext.Employees.AddRange(Employees);
                //DBContext.SaveChanges();
                using (var cn = new SqlConnection(System.Configuration.ConfigurationManager.ConnectionStrings["NorthwindString"].ConnectionString))
                {
                    cn.Execute(@"SET IDENTITY_INSERT Employees ON 
                            INSERT INTO Employees (EmployeeID,LastName,FirstName,Title,TitleOfCourtesy,BirthDate,HireDate,Address,City,Region,PostalCode,Country,HomePhone,Extension,Notes,ReportsTo,PhotoPath) 
                            VALUES (@EmployeeID,@LastName,@FirstName,@Title,@TitleOfCourtesy,@BirthDate,@HireDate,@Address,@City,@Region,@PostalCode,@Country,@HomePhone,@Extension,@Notes,@ReportsTo,@PhotoPath)
                            SET IDENTITY_INSERT Employees OFF",
                          Employees);
                }

            }
        }

        [TestMethod]
        public void Get_帶入ID_應取回該ID的Employee()
        {
            //arrange
            var ID = 1;
            var Sut = new EmployeesRepository();

            var Expected = "Nancy";
            //act
            var actual = Sut.Get(ID);

            //assert
            Assert.AreEqual(Expected, actual.FirstName);
        }
    }
}
執行看看,就會發現終於成功了.......




最後別忘了前面提到的,每個單元測試應該都是獨立的,所以既然每次都會寫資料進去,當然每次測試完也都要把資料砍掉啦

所以在單元測試的最後補上這個Method,讓他每個單元測試執行完畢後都會清掉資料
        [TestCleanup]
        public void CleanUp()
        {
            using (var cn = new SqlConnection(System.Configuration.ConfigurationManager.ConnectionStrings["NorthwindString"].ConnectionString))
            {
                cn.Execute(@"Delete Employees");
            }
        }


這樣就大功告成了!!!!

2016年7月28日 星期四

【Unit Test】針對Repository做單元測試 (一)


大部分的專案都會需要跟資料庫溝通,存取資料。雖然說開發的時候往往都有測試資料庫,但測試機料庫也可能因為很多人都在開發存取,導致資料可能會時常受到異動。

單元測試的一大重點就是不管任何人、時、地,都要能測試成功,如果今天用來測試的資料庫可能面臨資料在不確定何時何人會異動的情境下,這可能會讓我們的單元測試雖然邏輯正確,但最後驗證失敗。例如:原本預期A會員權限有效,但因為測試資料庫別人也在使用開發,所以被改成失效,這時候你如果去跑單元測試就會亮起紅燈失敗,直到你去查了後才發現原來是源頭資料被改動了。為了解決這樣的問題,我們最好能創造一個資料庫只給單元測試使用,且能跟著專案走的(不然別人從Git把專案抓下來,沒有資料庫的情況下單元測試全掛掉.....這該如何是好),所以LocalDB就是一個很好的選擇。

第一篇就紀錄一下我如何建立LocalDB、並準備測試資料,這邊大部分都參考
MRKT大師 : 測試專案使用 LocalDB - 使用 Entity Framework 的情境 
MRKT大師 : Dapper - 使用 LINQPad 快速產生相對映 SQL Command 查詢結果的類別
,以及一些公司同事提供的方便小工具,非自己原創XD



第一步、建立專案來擺放LocalDB

建立一般的Library Project



接下在專案底下新增LocalDB




第二步、幫LocalDB建立Schema (已NorthWind資料庫為例)

基本上SSMS操作差不多,在資料表右鍵 > 新增查詢,然後把Create Employees Table的Script貼過去執行



第三步、準備測試資料

因為每個測試之間應該都是獨立的,為了避免互相干擾,所以資料通常不會預先倒到LocalDB裡面去,而是在執行每次測試時,動態的將準備好的資料寫進去,每個測試完畢後砍掉資料,這樣不停的重複著。

而一個Table的資料可能會很多很多,如果用手寫成SQL Script應該寫完專案DeadLine也過了,
所以這邊打算將資料匯出到CSV檔案中,然後透過CSVHelper讀出來倒進DB,做法如下:

已下搭配LinqPad使用


void Main()
{
 // 這邊修改為你要執行的 SQL Command
 var sqlCommand = @"select top 1 * from Employees";

 // 第一個參數填入 SQL Command, 第二個參數輸入要產生的 Class 名稱
 this.Connection.DumpClass(sql: sqlCommand.ToString(), className: "Employees").Dump();
}

public static class LINQPadExtensions
{
 private static readonly Dictionary<Type, string> TypeAliases = new Dictionary<Type, string>
 {
  { typeof(int), "int" },
  { typeof(short), "short" },
  { typeof(byte), "byte" },
  { typeof(byte[]), "byte[]" },
  { typeof(long), "long" },
  { typeof(double), "double" },
  { typeof(decimal), "decimal" },
  { typeof(float), "float" },
  { typeof(bool), "bool" },
  { typeof(string), "string" }
 };

 private static readonly HashSet<Type> NullableTypes = new HashSet<Type>
 {
  typeof(int),
  typeof(short),
  typeof(long),
  typeof(double),
  typeof(decimal),
  typeof(float),
  typeof(bool),
  typeof(DateTime)
 };

 public static string DumpClass(this IDbConnection connection, string sql, string className = "Info")
 {
  if (connection.State != ConnectionState.Open)
  {
   connection.Open();
  }

  var cmd = connection.CreateCommand();
  cmd.CommandText = sql;
  var reader = cmd.ExecuteReader();

  var builder = new StringBuilder();
  do
  {
   if (reader.FieldCount <= 1) continue;

   builder.AppendFormat("public class {0}{1}", className, Environment.NewLine);
   builder.AppendLine("{");
   var schema = reader.GetSchemaTable();

   foreach (DataRow row in schema.Rows)
   {
    var type = (Type)row["DataType"];
    var name = TypeAliases.ContainsKey(type) ? TypeAliases[type] : type.Name;
    var isNullable = (bool)row["AllowDBNull"] && NullableTypes.Contains(type);
    var collumnName = (string)row["ColumnName"];

    builder.AppendLine(string.Format("\tpublic {0}{1} {2} {{ get; set; }}", name, isNullable ? "?" : string.Empty, collumnName));
    builder.AppendLine();
   }

   builder.AppendLine("}");
   builder.AppendLine();
  }
  while (reader.NextResult());

  return builder.ToString();
 }
}


這支程式的目的是先做出符合Employees對應的Class,方便支後匯出跟匯入資料,執行後應該會看到產出的Class文字檔


接著用第一支程式產出的Class貼到第二支程式底下

void Main()
{
    //修改匯出CSV資料存放的位置
 using (var sw = new StreamWriter(@"D:\Employees.csv"))
 using (var writer = new CsvWriter(sw))
 {
  var result = new List<Employees>();
  var connectionString = this.Connection.ConnectionString;

  using (var conn = new SqlConnection(connectionString))
  {
   conn.Open();

   var sqlCommand = new StringBuilder();
   //要匯出哪些資料的SQL SCript
   sqlCommand.AppendLine(@"select top 10 * from Employees");
   result = conn.Query<Employees>(sqlCommand.ToString())
       .ToList();
  }
  writer.WriteRecords(result);
 }
}
//將第一支程式產出的Class貼在這邊!!!

public class Employees
{
 public int EmployeeID { get; set; }

 public string LastName { get; set; }

 public string FirstName { get; set; }

 public string Title { get; set; }

 public string TitleOfCourtesy { get; set; }

 public DateTime? BirthDate { get; set; }

 public DateTime? HireDate { 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 HomePhone { get; set; }

 public string Extension { get; set; }

 public byte[] Photo { get; set; }

 public string Notes { get; set; }

 public int? ReportsTo { get; set; }

 public string PhotoPath { get; set; }

}

執行之後,依照你寫的路徑去找到產出的CSV檔,並把他貼到測試專案之中

將檔案屬性改成如下

將測試專案參考放LocalDB的Resource專案,基本上基礎的設置就大功告成了






下一篇接著寫Production Code,並針對那段Code去做測試

2016年7月5日 星期二

[Config] 幫自訂的Config新增Release與Debug版本


一般開發的時候通常會有測試機、正式機、或是Release前的機器...等,而在發佈到每台機器都要去做調整Config確實是很惱人的事情,感覺一個分神可能就會把測試機的連線貼到正式機之類的,這可能就是一場大災難。


還好Visual Studio很貼心的為WebConfig分出了Release版本跟Debug版本,並依據發佈時你所選擇的組態檔,去做對應的修改。(註:如果需要更多版本,請到建置 > 組態管理員去新增)。

Release版本跟Debug版本


之前有寫篇文章【擴充WebConfig】有提到如何在專案中加掛Config檔案,讓設定檔能做簡單的歸類整理。

原本以為Visual Studio應該會有相關的功能,可以對這類自訂的Config分割出對應版本,結果東找西找都找不到方法去做對應,原來VS似乎沒有提供這個功能(?)
還好在網路上找到了解決的套件跟方法
How to add config transformations for a custom config file in Visual Studio?


簡單說就是先去下載VS擴充套件(註:目前似乎只支援到VS 2013)
SlowCheetah - XML Transforms

安裝完後,用VS 2013開啟專案,並且對著想要分割版本的Config檔案按右鍵,選擇Add Transfom即可


搞定!!!! 之後再來寫篇如何透過Release Config檔在發佈時置換屬性值好了~