[控制反轉]以Mock進行Unit Test為例

大家寫程式寫一陣子以後就會開始聽到一些名詞

控制反轉(Inversion of Control,縮寫為IoC)

相依性注入(Dependency Injection,簡稱DI)

也許再加上很久以前也許就沒認真搞懂的介面 (Interface)

關於這些東西到底是什麼意思 我想中文解釋大家都會背

但我可能就是搞不懂 到底把程式搞這麼複雜有什麼好處?

原本的程式也跑得很好 為什麼大家總是說得這樣寫才好?

 

我想用最簡單的例子來做個說明

 

假設我們的程式碼分三層

DB → (1).Repository(2).Service(3).實際程式

也就是說 (3).實際程式 不能跳過 (2).Service 直接取 (1).Repository

更不可能跳過 (2). Service + (1).Repository 直接碰DB

(1). Repository 實際提供資料的一層

      =>不管你是要寫SQL、Stored Procedure、ORM...... 反正你資料怎取出就是在這層

(2). Service 商業邏輯

      => 我隨便假設一個例子 我的真實姓名叫'陳大寶' (存在DB裡) 但因為個資法的關係 我對外只能顯示 '陳**

(3). 實際程式

      => 這不用解釋吧...

 

我們的題目如下

DB裡有一張會員基本資料表  裡面存了會員的個資

其中名子的部份 我希望只顯示姓** (兩個星號)

 

為了講解方便

我把DB、(1). Repository、(2). Service、(3). 實際程式,通通放在同一個檔案,並且通通都設public

實際上他們通常會分散在不同cs檔、或不同專案中、並且不會通通是public,這點請特別注意

 

我們很快的可以完成第一版的程式碼

using System;
using System.Collections.Generic;
using System.Linq;

namespace MySample.Console
{
    public class Sample
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime Birthday { get; set; }
    }

    /// <summary>
    /// 實際存取DB
    /// </summary>
    public class Repository
    {
        public List<Sample> GetSample(int param)
        {
            //為了方便講解 (否則這些資料應該是從 DB 取出)
            var source = new List<Sample>
            {
                new Sample { Id = 1, Name = "陳大寶", Birthday = DateTime.Parse("2001/1/1") },
                new Sample { Id = 2, Name = "林小寶", Birthday = DateTime.Parse("2001/2/1") },
                new Sample { Id = 3, Name = "王聰明", Birthday = DateTime.Parse("2001/3/1") },
            };

            return source.Where(q => q.Id == param).ToList();
        }
    }

    /// <summary>
    /// 透過Repository從DB取資料後 做一些商業邏輯
    /// </summary>
    public class Service
    {
        Repository repo = new Repository();

        public List<Sample> GetSample(int param)
        {
            var samples = repo.GetSample(param);

            //假設有一個商業邏輯是大家不能知道會員的全名 只能知道 姓氏 + **
            foreach (var item in samples)
            {
                if (item.Name != null && item.Name.Length >=1 )
                {
                    item.Name = item.Name.Substring(0, 1) + "**";
                }
            }

            return samples;
        }
    }

    /// <summary>
    /// DB → Repository → Service → 實際程式
    /// </summary>
    class Program
    {
        static void Main(string[] args)
        {
            Service service = new Service();
            var result = service.GetSample(1);
        }
    }
}

 

這程式碼很完美的解決了我們的問題

 

但我們從程式碼中發現

1. 實際程式 相依於 Service ... 第一行我就 new Service()

2. Service 相依於 Repository ... 第一行我就 new Repository()

這程式碼肯定不是什麼控制反轉 也不會是什麼相依性注入  但那又如何 我的程式依舊跑的很好 沒造成困擾

 

而故事的進行肯定會有些轉折

某天有人反應 你的程式是不是寫錯了?

說好要顯示陳 ** 怎麼昨天從半夜開始 頁面通通變成陳大寶了 !?!?!?!?

 

oh 那可不得了

秀出完整姓名等於違反個資法 我得馬上測一下這段程式有沒有問題

 

但你觀察了一下這段程式 你該從何測起?

1. Repository 相依於 DB

    => 這倒簡單 我從config裡 改一改連線參數 指到我的測試DB 再手動塞一些假資料即可

2. Service 相依於 Repository

    => 嗯... 好像暫時想不到該怎麼測... 但沒關係 反正現在問題也不在這裡 並不是輸入'陳大寶' 卻取出 '林小寶' 的資料

3. 實際程式 相依於 Service

    => 這就是出問題的地方!!!  把名子變成**的程式碼就在這!! 

 

雖然暫時還沒辦法想出所有答案 但至少現在我們已經有解決的辦法了

解法如下:

1. 到測試DB做一筆假資料  ( Id=1、Name=陳大寶) 然後把config的連線字串改成測試DB

2. 從實際程式輸入參數 Id=1、跑一次Service.GetSample()

便可知道是哪裡出錯!

 

但為什麼這樣可以讓測試成立呢?

原來 Repository 相依於 DB

但 Repository 是透過 連線字串 去開DB

故注入 Repository 的物體 其實就是 連線字串(放在config中)

所以當我修改了連線字串 => 傳進Repository裡的東西不一樣了 => 讓正式資料庫 變成 測試資料庫

即 Repository→連線字串 (可人為操作)→DB ...是因為這樣的設計讓我們可以操控 config 進行測試

 

那如果很不幸的 今天你換到測試機以後 發現輸出結果真的是陳大寶 而非陳**

但該死的 這一個月來有10個PG 進了20個版本 程式碼變成10萬行

public class Repository
{
    public List<Sample> GetSample(int param)
    {
        //多了兩百行程式
        //好像又多開 4 張 Table
        //我也看不懂他到底在串什麼
        //該不會其實是這裡壞掉吧?

        return source.Where(q => q.Id == param).ToList();
    }
}
public List<Sample> GetSample(int param)
{
    //最近上版10次增加了500行!!!!
	
    var samples = repo.GetSample(param);

    //為什麼這裡又要去call一些我覺得根本用不到的函式?
	
    foreach (var item in samples)
    {
        if (item.Name != null && item.Name.Length >=1 )
        {
            item.Name = item.Name.Substring(0, 1) + "**";
            //做這些運算不是沒意義嗎? 為什麼這裡又多了50行???????
        }
    }
	
    //上次他們好像說有個專案要加新功能 所以這裡的100行是在搞那個功能?????
	
    return samples;
}

雖然我透過切換測試DB 確定程式真的被改壞了

但我只能確定問題不在DB (因為DB是我連到測試環境手動造假 ( Mock ) 的)

那問題到底是Repository最近上版被改壞? 還是Service被改壞? 

那我有沒有辦法像剛剛改連線字串一樣 先再排除一個可能?

我可以手動做一個已知的 肯定不會錯的 Repository層 嗎?

就像剛剛我自己手 key 測試 DB 一樣 讓我再排除一個可能的選項嗎?

 

於是我們來看看第二版的程式碼

先到Nuget下載Moq

 

改變的部份如下

Repository層

 

Service層

 

我的程式

 

測試程式

 

完整程式碼如下

using Moq;
using System;
using System.Collections.Generic;
using System.Linq;

namespace MySample.Console
{
    public class Sample
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime Birthday { get; set; }
    }

    public interface IRepository
    {
        List<Sample> GetSample(int param);
    }

    /// <summary>
    /// 實際存取DB
    /// </summary>
    public class Repository : IRepository
    {
        public List<Sample> GetSample(int param)
        {
            //為了方便講解 (否則這些資料應該是從 DB 取出)
            var source = new List<Sample>
            {
                new Sample { Id = 1, Name = "張小華", Birthday = DateTime.Parse("2001/1/1") },
                new Sample { Id = 2, Name = "林小寶", Birthday = DateTime.Parse("2001/2/1") },
                new Sample { Id = 3, Name = "王聰明", Birthday = DateTime.Parse("2001/3/1") },
            };

            return source.Where(q => q.Id == param).ToList();
        }
    }

    /// <summary>
    /// 透過Repository從DB取資料後 做一些商業邏輯
    /// </summary>
    public class Service
    {
        IRepository repo;

        public Service(IRepository pRepo)
        {
            repo = pRepo;
        }

        public List<Sample> GetSample(int param)
        {
            var samples = repo.GetSample(param);

            //假設有一個商業邏輯是大家不能知道會員的全名 只能知道 姓氏 + **
            foreach (var item in samples)
            {
                if (item.Name != null && item.Name.Length >=1 )
                {
                    item.Name = item.Name.Substring(0, 1) + "**";
                }
            }

            return samples;
        }
    }

    /// <summary>
    /// DB → Repository → Service → 實際程式
    /// </summary>
    class Program
    {
        static void Main(string[] args)
        {
            Service service = new Service(new Repository());
            var result = service.GetSample(1);
            var test = MyTest();

            System.Diagnostics.Debug.WriteLine("Hello World!");
        }

        /// <summary>
        /// 假設這是一個test case
        /// </summary>
        /// <returns></returns>
        public static bool MyTest()
        {
            //arrange
            var source = new List<Sample>
            {
                new Sample { Id = 1, Name = "陳大寶", Birthday = DateTime.Parse("2001/1/1") },
            };

            var mockRepo = new Mock<IRepository>();
            mockRepo.Setup(repo => repo.GetSample(It.IsAny<int>())).Returns(source);

            var service = new Service(mockRepo.Object);

            //act
            var actual = service.GetSample(1);

            //assert
            return actual.First().Name == "陳**";
        }
    }
}

 

最後我們整理了一下剛剛發生什麼事

連線字串config的發想

A. 若來源是可以被抽換的 ( 正式DB 與 測試DB ) => 控制反轉

B. 透過config的變更 => 相依性注入

C. 因為真假DB都是一模一樣的 所以能達到一行程式都不用改 也能驗證程式對不對

      =>正式DB 與 測試DB 的 Table Schema 一模一樣

      =>繼承同一介面 IRepo 的 真Repo (原程式) 與 假Repo (Mock) 結構也一模一樣

      =>所以程式才會一行都不用改

 

總結一下

在要測試的程式碼 (Service) 中 

會變的變因 (Repository) 都被我控制住了

所以當結果出錯時 ( 輸出 陳大寶 而非 陳** )

我便可確定 Service 有錯

因為 Repository 從頭到尾就根本沒跑 他是一個被我控制住的變因 是我自己造假結果 ( Mock ) 以後傳進去的

從頭到尾就只有Service在變 所以若結果出錯 則 Service 必然有問題

這也就是控制反轉、相依性注入之所以有意義的地方