2017年4月24日 星期一

【Unit Test】Day 7 - Assert


Demo檔案 : Git傳送門
今天要來談談驗證,還記得之前的PTX範例都只驗證名稱嗎?
   //assert
   Assert.AreEqual(actual.Name, expected);
因為有說如果是參考型別的話,Assert.AreEqual可能會驗證失敗
        [TestMethod]
        public void 透過Assert驗證參考型別()
        {
            //arrange
            Guid ID = Guid.NewGuid();
            var expected = new Member
            {
                ID = ID,
                Name = "Name"
            };

            //act
            var actual = new Member
            {
                ID = ID,
                Name = "Name"
            };

            //assert
            Assert.AreEqual(expected,actual);
        }
結果
試試看改一下寫法
        [TestMethod]
        public void 透過Assert驗證參考型別2()
        {
            //arrange
            Guid ID = Guid.NewGuid();
            var expected = new Member
            {
                ID = ID,
                Name = "Name"
            };

            //act
            var actual = expected;

            //assert
            Assert.AreEqual(expected, actual);
        }


原來Assert.AreEqual當碰到參考型別的時候是驗證記憶體位置,但其實像我是寫商業邏輯的,常常在乎的是透過方法執行完後得到的DTO(Data Transfer Object)是否符合預期,這樣其實Assert對我來說不是那麼方便。
MSDN: Assert 類別
如果我真的只想驗證執行完的DTO內容是否符合預期,該怎麼做呢?來看看FluentAssertions


在單元測試專案用Nuget安裝完成後,來看看範例寫法
        [TestMethod]
        public void 透過FluentAssertions驗證參考型別()
        {
            //arrange
            Guid ID = Guid.NewGuid();
            var expected = new Member
            {
                ID = ID,
                Name = "Name"
            };

            //act
            var actual = new Member
            {
                ID = ID,
                Name = "Name"
            };

            //assert
            actual.ShouldBeEquivalentTo(expected);

        }
就這麼簡單,它就會幫你驗證內容的值而不是記憶體位置,而且可讀性也很高,再舉幾個範例

是否為Null
        
        [TestMethod]
        public void 透過FluentAssertions驗證不為Null()
        {
            //arrange
            
            //act
            var actual = new Member
            {
                ID = Guid.NewGuid(),
                Name = "Name"
            };

            //assert
            actual.Should().NotBeNull();
        }

        [TestMethod]
        public void 透過FluentAssertions驗證為Null()
        {
            //arrange

            //act
            Member actual = null;

            //assert
            actual.Should().BeNull();
        }



驗證數字
        [TestMethod]
        public void 透過FluentAssertions驗證數字()
        {
            //arrange

            //act
            int actual = 123;

            //assert
            actual.Should().Be(123);
        }

驗證趨近於
倍精準相加時會有些微誤差
        [TestMethod]
        public void 透過FluentAssertions驗證趨近於()
        {
            //arrange
            var a = 1.3;
            var b = 0.1;
            //act
            double actual = a + b ;

            //assert
            actual.Should().Be(1.4);
        }

驗證地方可改成
//assert
//趨近於1.4 且如果誤差小於0.00001時視為一樣
actual.Should().BeApproximately(1.4,0.00001);

驗證排序
        [TestMethod]
        public void 透過FluentAssertions驗證升冪排序()
        {
            //arrange
            var parameter = new List<int> { 4, 7, 1, 3, 8 };
            //act
            var actual = parameter.OrderBy(x=>x);

            //assert
            actual.Should().BeInAscendingOrder();
        }

        [TestMethod]
        public void 透過FluentAssertions驗證降冪排序()
        {
            //arrange
            var parameter = new List<int> { 4, 7, 1, 3, 8 };
            //act
            var actual = parameter.OrderByDescending(x => x);

            //assert
            actual.Should().BeInDescendingOrder();
        }

結論
FluentAssertions是個閱讀性高且豐富的驗證套件,以上提供一些我常常使用到的斷言方法,還有很多可以參考官網文件,那今天就先到這了!!

2017年4月23日 星期日

【Unit Test】Day 6 - 單元測試初始化與清除


Demo檔案 : Git傳送門
今天要來看看單元測試的初始化設定與清除

圖片出處:https://www.codeproject.com/Articles/1165536/Unit-Test-Initialization-and-Cleanup
上圖是單元測試在執行時的生命週期,知道了這特性的時候我們就可以依據測試的需要來加入適當的初始化設定。


以下範例來說明執行順序
這邊準備了一個Static Class,裡面有個Get方法很簡單的回傳Now這個屬性值
    public static class Demo
    {
        public static int Now { get; set; }

        public static int Get()
        {
            return Now;
        }
    }

然後為它建立單元測試

先加上一個TestHook的檔案

    [TestClass]
    public class TestHook
    {
        [AssemblyInitialize]
        public static void AssemblyInit(TestContext context)
        {
            Console.WriteLine(string.Format("{0} - AssemblyInitialize", Demo.Get()));
            Demo.Now += 1;
        }

        [AssemblyCleanup]
        public static void AssemblyCleanUp()
        {
            Console.WriteLine(string.Format("{0} - AssemblyCleanup", Demo.Get()));
            Demo.Now += 1;
        }
    }

接著撰寫DemoTests.cs
    [TestClass]
    public class DemoTests
    {
        [ClassInitialize]
        public static void ClassInit(TestContext context)
        {
            Console.WriteLine(string.Format("{0} - ClassInitialize", Demo.Get()));
            Demo.Now += 1;
        }

        [TestInitialize]
        public void TestInit()
        {
            Console.WriteLine(string.Format("{0} - TestInitialize", Demo.Get()));
            Demo.Now += 1;
        }

        [TestMethod]
        public void GetTest()
        {
            Console.WriteLine(string.Format("{0} - Test1", Demo.Get()));
            Demo.Now += 1;
        }

        [TestCleanup]
        public void TestCleanUp()
        {
            Console.WriteLine(string.Format("{0} - TestCleanup", Demo.Get()));
            Demo.Now += 1;
        }

        [ClassCleanup]
        public static void ClassCleanUp()
        {
            Console.WriteLine(string.Format("{0} - ClassCleanup", Demo.Get()));
            Demo.Now += 1;
        }
    }

這裡目標很間單,測試的內容不是重點,而是希望透過輸出來看到執行的順序
可能有人會注意到為何有些方法例如ClassCleanup是需要Static,有些又需要帶入TestContext的參數?
這其實是規定,如果不按照規則撰寫的話,執行單元測試時就會得到以下錯誤訊息


接著執行單元測試看看輸出,點擊下面的輸出按鈕



這樣看就很明顯知道順序了!!


讓我們加上第二個測試
        [TestMethod]
        public void GetTest2()
        {
            Console.WriteLine(string.Format("{0} - Test2", Demo.Get()));
            Demo.Now += 1;
        }
全部測試執行然後看看結果




可以發現TestInitializeTestCleanUp是每個測試在執行前/後都會執行一次的,而且要特別強調一下,每個單元測試執行的順序是不固定的,所以如果在單元測試中有些初始化的動作,切記不要依賴每個測試之間的順序關係,否則可能會不定時的產生錯誤,請把每個測試都當成是獨立且無相依關係!!

從這個範例也可以看得到一個結論,如果在測試中有用到Production Code中Static的參數或屬性,請記得養成良好習慣,要在CleanUp的方法中恢復它的預設值,否則Static是共用的,如果其他測試有用到同一個Static屬性,而互相改來改去不初始化,就很可能發生單元測試時好時壞的問題,這是切身之痛請勿以身試法XDD

2017年4月20日 星期四

【Unit Test】Day 5 - 透過InternalsVisibleTo來達成單元測試的外部注入


Demo檔案 : Git傳送門
請參照UnitTest_Day5的Branch
延續昨日的進度,我們將呼叫API的RestSharp獨立出來,並且用依賴介面及外部注入的方式將IRestSharp注入到PTX來達成隔離,並且做到可測試性。



但面臨令一個問題,如果今天開發的是共用套件類的專案,這樣變成要使用物件都必須知道該用什麼東西注入才可使用,這往往會造成使用者困擾,封裝性也不佳,因外部注入的IRestSharp是每個人都可以另外實作的。 甚至更深一層去想,究竟IRestSharp是否需要用Public讓外部使用者都知道有這個東西呢? 是不是反而因為要達到可測試性而讓封裝這件事做得更差?

來看看另一種方式

假設我認為使用這個套件的人只要簡單的建立PTX後即可使用,至於內部如何呼叫API的實作使用者並不需要關心,那麼我們先將IRestSharp從建構子拿掉吧,並且做一個專門產生IRestSharp實體的Factory來滿足需求。

撰寫IRestSharpFactory
    
    /// <summary>
    /// 製作IRestSharp的工廠
    /// </summary>
    internal class IRestSharpFactory
    {
        //這邊特別注意存取修飾子是用Internal
        //原因是我並不希望專案之外的人使用且知道有這東西
        //而internal剛好能滿足這需求
        internal static IRestSharp Generate()
        {
            //產生實體
            return new MyRestSharp();
        }
    }

接著修改原本的PTX.cs
    public class PTX
    {
        IRestSharp _MyRestSharp
        {
            get
            {
                //這邊改成用工廠建立MyRestSharp實體
                return IRestSharpFactory.Generate();
            }
        }

        /// <summary>
        /// Construct
        /// </summary>
        public PTX()
        {
            //建構子參數移除
        }

        以下省略.....
    }

這邊馬上面臨到一個問題,那我們怎麼Mock IRestSharp,昨天是放在建構子中並透過Nsubstitute來Mock
封裝性變好了,但也變得難以介入模擬外部行為

從IRestSharpFactory著手吧!!
想辦法讓IRestSharpFactory可以讓我們注入Mock的假物件
    
    /// <summary>
    /// 製作IRestSharp的工廠
    /// </summary>
    internal class IRestSharpFactory
    {
        /// <summary>
        /// 此屬性只供UnitTest注入
        /// </summary>
        internal static IRestSharp _IRestSharpForUnitTest;

        internal static IRestSharp Generate()
        {
            //如果這個值不為Null,則表示單元測試所注入,直接回傳
            if (_IRestSharpForUnitTest != null)
            {
                return _IRestSharpForUnitTest;
            }

            //產生實體
            return new MyRestSharp();
        }
    }

這邊開了一個_IRestSharpForUnitTest屬性,讓外部能夠注入它,接著在Generate的方法中判斷,如果當_IRestSharpForUnitTest不為Null時,直接回傳(通常會特別在這個屬性寫上說明僅供單元測試使用,正常Production Code禁止使用!!)

想辦法在單元測試中注入_IRestSharpForUnitTest
你可能會發現在單元測試中看不到IRestSharpFactory....

原因是我們宣告成Internal,如果要能在專案之外看到就只能開成Public了,但回到最一開始討論的,不就是為了封裝才把他宣告成Internal嗎?如果又改回Public那我們這段工不就白費了,還好還有別的方法可以達成。


告訴UnitTestDay3這個專案,除了它自己之外,還有誰能看到它宣告成Internal的類別與屬性方法

讓我們先打開UnitTestDay3專案的AssemblyInfo
寫下這行

InternalsVisibleTo這邊是要填AssemblyName,透過這個Attribute告訴UnitTestDay3這個專案還有誰能看到它內部的Internal。
而AssemblyName怎麼看呢?在專案上右鍵 > 屬性


如果你有很多單元測試專案需要能看到,InternalsVisibleTo是可以很多組的。


接著在單元測試中就可以看到啦


這樣就達成我們想封裝起來的需求,卻也能讓單元測試進行注入,那今天就談到這吧!!

【Unit Test】Day 4 - Mock


Demo檔案 : Git傳送門
因為程式是昨天的延續,所以是同一個Repository切出UnitTest_Day4的Branch

讓繼續我們昨天的議題,節錄昨天最後的結論
...這個方法也不是百分之百沒有缺點,你應該也可以觀察到其中一段在這單元測試中無法被涵蓋到,
那就是組API Url那段,在這種透過繼承解耦合的方式中,因為回傳結果是自己設定的,
實際上組成的URL正確與否我們並不知道,換句話說,如果我們今天這樣寫測試可能會過,
但上線後才會發現錯誤,檢查之下發現原來是在串Url的時候錯字之類的。
身為一位專業有責任感的工程師,你一定想著「爾等豈能如此苟且偷生便宜行事草率」,所以今天就來談談另一種方法,Mock。
何謂Mock。
當測試時你關注與外部相依物件互動時其狀態的變化,並且驗證它,則此就是Mock物件。
很抽象對吧,沒關係讓我們來改改昨天的實作,重中瞭解Stub與Mock的差異。

先將呼叫API的地方徹底隔離出去
開一個NetTool的資料夾,建立一個IRestSharp的介面
該介面定義出呼叫API該傳入Url,回傳得到的結果
    public interface IRestSharp
    {
        /// <summary>
        /// 用Get的方法呼叫API
        /// </summary>
        /// <param name="url">Url</param>
        /// <returns>回傳的內容</returns>
        string Get(string url);
    }
實做這個介面

    public class MyRestSharp : IRestSharp
    {
        /// <summary>
        /// 用Get的方法呼叫API
        /// </summary>
        /// <param name="url">Url</param>
        /// <returns>回傳的內容</returns>
        public string Get(string url)
        {
            //Use RestSharp Call API
            var client = new RestClient(url);
            var request = new RestRequest(Method.GET);
            request.AddHeader("cache-control", "no-cache");
            IRestResponse response = client.Execute(request);

            if (response.StatusCode == HttpStatusCode.OK)
            {
                return response.Content;
            }

            return string.Empty;
        }
    }

修改PTX的程式
    public class PTX
    {
        IRestSharp _MyRestSharp;

        /// <summary>
        /// Construct
        /// </summary>
        /// <param name="myRestSharp">外部注入呼叫API的實體</param>
        public PTX(IRestSharp myRestSharp)
        {
            //改由外部注入呼叫API的實體
            this._MyRestSharp = myRestSharp;
        }

        /// <summary>
        /// 取得巴士路線資料
        /// </summary>
        /// <param name="city">縣市名稱</param>
        /// <param name="routeName">巴士路線名稱</param>
        /// <returns></returns>
        public BusRouteDTO Get(string city,string routeName)
        {
            BusRouteDTO Result = null;

            //要呼叫的API Url
            string Url = string.Format($"http://ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/{city}/{routeName}?%24top=1&%24format=JSON");

            var JsonResult = _MyRestSharp.Get(Url);

            if (!string.IsNullOrEmpty(JsonResult))
            {
                var APIResult = JsonConvert.DeserializeObject<List<PTXBusRouteResult>>(JsonResult);

                if (APIResult != null && APIResult.Count > 0)
                {
                    var Route = APIResult.First();
                    Result = new BusRouteDTO
                    {
                        Name = Route.RouteName.Zh_tw,
                        BusStops = new List<BusRouteDTO.BusStop>()
                    };

                    foreach (var stop in Route.Stops)
                    {
                        Result.BusStops.Add(new BusRouteDTO.BusStop
                        {
                            ID = stop.StopUID,
                            Name = stop.StopName.Zh_tw
                        });
                    }
                }
            }
    
            return Result;
        }
    }

修改Program.cs
    class Program
    {
        static void Main(string[] args)
        {
            //new PTX時變成要帶入呼叫API的實體
            var PTXFunction = new PTX(new MyRestSharp());

            var Result = PTXFunction.Get("Taipei","307");

            Console.Write(JsonConvert.SerializeObject(Result));

            Console.ReadKey();
        }
    }

執行後結果是對的!!



單元測試

到目前為止都沒有解釋太多為何要這樣改,不過這跟物件導向設計比較有關,即所謂的外部注入即依賴介面,但這比較偏設計的範疇這邊就不特別討論,不過之後應該會漸漸的發現,如果單元測試要能寫,很多地方都會必須要有物件導向的設計方式才有辦法。

讓我們回頭看單元測試,首先昨天做的PTXStub就沒用了,因為目前的PTX沒有將呼叫API封裝起來,也就沒有可以override的CallAPI方法了,把它砍掉了。

這時就會看到昨天的單元測試壞掉了,因為找不到PTXStub


開始做我們的假物件MyRestSharpMock吧


public class MyRestSharpMock : IRestSharp
    {
        public string Get(string url)
        {
            if (url == "http://ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/Taipei/307?%24top=1&%24format=JSON")
            {
                var sb = new System.Text.StringBuilder(12766);
                sb.AppendLine(@"[{""RouteUID"":""TPE16111"",""RouteID"":""16111"",""RouteName"":{""Zh_tw"":""307"",""En"":""307""},.....省略");


                return sb.ToString();
            }

            return string.Empty;
        }
    }

這邊特別注意一下if那邊,依據昨天的單元測試,最後網址應該要組成這樣才是正確的,所以我們MyRestSharpMock特地加上這段判斷,已經不像昨天override一樣,不關心它組的網址為何,一律依照我們設定的結果回傳,即便你其實Url組的是錯誤的。

修改PTXTest.cs
    [TestClass()]
    public class PTXTests
    {
        [TestMethod()]
        public void GetTest_傳入縣市和公車路線名稱_如果查的到資料_應回傳該路線的BusRouteDTO()
        {
            //arrange
            var RestSharpMock = new MyRestSharpMock();
            var Sut = new PTX(RestSharpMock); //注入我們模擬的假物件MyRestSharpMock
            var City = "Taipei";
            var RouteName = "307";

            var expected = "307";
            //act
            var actual = Sut.Get(City, RouteName);

            //assert
            Assert.AreEqual(actual.Name, expected);
        }
    }
我們注入的是自己Mock的物件,他不會實際去呼叫API,但它關注我們傳入的值對不對,執行單元測試看看結果。


可能到這邊會有一個疑問是,「廢話,Mock的物件是我們自己做的,我們想怎麼樣都馬可以!!」,一開始學到這段的時候也有這個疑問,所以容我解釋一下,還記得先前提到的觀念嗎?
單元測試關注本身的邏輯,而非外部的關聯
如果今天上線後發現錯誤,而發生錯誤的地方是在呼叫API的程式,那該修改的是誰?當然是MyRestSharp.cs,也絕對不是PTX這支程式,因為它職責掌管的邏輯全部都是正確的。而我們現在單元測試以及MyRestSharpMock都是在做什麼? 都是在輔助我們驗證PTX所有包含到的邏輯,我們並不關注外部其他程式到底幹了什麼,退一萬步說(好啦其實退一步就可以了)(你好煩)今天IRestSharp這個Interface你的夥伴忘記去實作導致程式上線壞了,那也不會是改你的PTX吧,要改可以,over my dead body先,所以希望這囉說的補充可以幫助釐清這件事情。


好的,上面這邊如果沒問題的話,那下一步應該心裡就會冒出一個OS :我的好天鵝啊!! 如果外部相依的物件很多,每個都自己做Mock物件,做完都老了,單元測試沒寫完Deadline就到了,搞毛啊....
看官稍安勿躁,你的心聲有人聽到了,所以接下來介紹一個可以省略掉剛剛一堆繁瑣的動作,登登登登,為您隆重介紹NSubstitute,怎麼用呢?讓我們在UnitTestDay3Tests這個專案透過Nuget安裝這個套件


怎麼用呢?先Using
using NSubstitute;
然後...
    [TestClass()]
    public class PTXTests
    {
        [TestMethod()]
        public void GetTest_傳入縣市和公車路線名稱_如果查的到資料_應回傳該路線的BusRouteDTO()
        {
            //arrange
            //透過NSubstitute跟它說你想Mock實作的Interface
            var NsubRestSharpMock = Substitute.For<IRestSharp>();
            var Sut = new PTX(NsubRestSharpMock); //注入

            //期望API回傳的Json 
            var sb = new System.Text.StringBuilder(12766);
            sb.AppendLine(@"[{""RouteUID"":""TPE16111"",""RouteID"":""16111"",""RouteName"":...後略");

            //這邊的意思是,當這個透過NSubstitute Mock的物件
            //被呼叫Get的方法時,且帶入的參數是我們這邊寫的字串
            //就回傳Returns那裡面我們設定的字串
            NsubRestSharpMock.Get("http://ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/Taipei/307?%24top=1&%24format=JSON")
                             .Returns(sb.ToString());
            var City = "Taipei";
            var RouteName = "307";

            var expected = "307";
            //act
            var actual = Sut.Get(City, RouteName);

            //assert
            Assert.AreEqual(actual.Name, expected);
        }
    }

因為NSubstitute Mock出來實作IRestSharp的物件也會有Get的方法,他還可以設定帶入參數為何才回傳值,如果比對的Url不符合預期,則就會回傳String的預設值Null,是不是超方便的!!

當然如果你的方法是要回傳物件,一樣往Returns擺進去就對了,且它驗證參數的方法還有很多種而且很彈性,例如帶入如果是數字,你可以說大於100才回傳結果之類的,因為太多有需要的話還是直接參考他的文件比較快。

那今天的部分就談到這邊,能幫助我們做到Mock的套件還有很多,這邊只是其中一種我使用的而已,不一定要一樣,但只要了解原來Mock是這麼回事即可。

2017年4月19日 星期三

【Unit Test】Day 3 - 專注於邏輯,隔離與外部的關聯


Demo檔案 : Git傳送門
昨天有提到對於外部有關聯時該如何寫單元測試,例如API就是一個典型的例子,當在沒有網路環境時,對於要拿到API的結果做後續處理,這時候該怎麼辦?今天要來實作並且落實昨天提到的重要特性
單元測試應該是隨時隨地都要能正確執行,只要它本身的邏輯是正確的!!

來看看今天的情境範例
我們有一個需求,它要能夠讓我們帶入台北市的公車路線名稱,並取回該路線的站點資料,該資料來源從公共運輸整合資訊流通服務平台的API取得。
測試API : http://ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/Taipei/307?$top=1&$format=JSON
從呼叫API看到的呼叫結果大致如下

開始寫程式
首先我們準備了一個主控台專案,裡面有一個PTX的Class,希望他有個方法能帶入縣市公車路線名稱,然後回傳該路線所有站牌名稱與ID
    /// <summary>
    /// 巴士路線(該方法要回傳的結果)
    /// </summary>
    public class BusRouteDTO
    {
        /// <summary>
        /// 路線名稱
        /// </summary>
        public string Name { get; set; }

        /// <summary>
        /// 巴士站列表
        /// </summary>
        public List<BusStop> BusStops { get; set; }

        /// <summary>
        /// 巴士站
        /// </summary>
        public class BusStop
        {
            /// <summary>
            /// 站名
            /// </summary>
            public string Name { get; set; }

            public string ID { get; set; }
        }
    }
用來承接API回傳資料的DTO
    /// <summary>
    /// 取PTX BusRoute的結果
    /// </summary>
    public class PTXBusRouteResult
    {
        public NameDTO RouteName { get; set; }

        public List<StopDTO> Stops { get; set; }

        /// <summary>
        /// 巴士路線名稱
        /// </summary>
        public class NameDTO
        {
            public string Zh_tw { get; set; }
            public string En { get; set; }
        }

        /// <summary>
        /// 站點
        /// </summary>
        public class StopDTO
        {
            /// <summary>
            /// 站點ID
            /// </summary>
            public string StopUID { get; set; }

            /// <summary>
            /// 站點名稱
            /// </summary>
            public NameDTO StopName { get; set; }
            
            public class NameDTO
            {
                public string Zh_tw { get; set; }
                public string En { get; set; }
            }
        }
    }

PTX的程式
    
    public class PTX
    {
        /// <summary>
        /// 取得巴士路線資料
        /// </summary>
        /// <param name="city">縣市名稱</param>
        /// <param name="routeName">巴士路線名稱</param>
        /// <returns></returns>
        public BusRouteDTO Get(string city,string routeName)
        {
            BusRouteDTO Result = null;

            //Use RestSharp Call API
            var client = new RestClient($"http://ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/{city}/{routeName}?%24top=1&%24format=JSON");
            var request = new RestRequest(Method.GET);
            request.AddHeader("cache-control", "no-cache");
            IRestResponse response = client.Execute(request);

            if (response.StatusCode == HttpStatusCode.OK)
            {
                var APIResult = JsonConvert.DeserializeObject<List<PTXBusRouteResult>>(response.Content);

                if (APIResult != null && APIResult.Count > 0)
                {
                    var Route = APIResult.First();
                    Result = new BusRouteDTO
                    {
                        Name = Route.RouteName.Zh_tw,
                        BusStops = new List<BusRouteDTO.BusStop>()
                    };

                    foreach (var stop in Route.Stops)
                    {
                        Result.BusStops.Add(new BusRouteDTO.BusStop
                        {
                            ID = stop.StopUID,
                            Name = stop.StopName.Zh_tw
                        });
                    }
                }
            }
    
            return Result;
        }
    }

這邊呼叫API用的套件為RestSharp

將回傳的Json格式轉成DTO的套件是Newtonsoft.Json


OK,因為是範例程式,所以一些寫作風格或是是否會Null的問題我們就先擺一邊吧,還是專注在我們的怎麼寫單元測試上。

接著我們來執行看看程式是否依照我們預期的可以取回結果

   class Program
    {
        static void Main(string[] args)
        {
            var PTXFunction = new PTX();

            var Result = PTXFunction.Get("Taipei","307");

            Console.Write(JsonConvert.SerializeObject(Result));

            Console.ReadKey();
        }
    }



程式正確執行也取得回資料,第一階段搞定!!接下來開始寫單元測試了


單元測試

首先替它加上單元測試吧,老樣子在PTX的Get方法右鍵 > 建立單元測試


寫單元測試,這邊稍微解釋一下,因為還沒說怎麼驗證整個Class的內容比對,所以這邊先驗證回傳結果名稱就好,之後的章節會講怎麼驗證整個回傳內容的數值

    [TestClass()]
    public class PTXTests
    {
        [TestMethod()]
        public void GetTest_傳入縣市和公車路線名稱_如果查的到資料_應回傳該路線的BusRouteDTO()
        {
            //arrange
            var Sut = new PTX();
            var City = "Taipei";
            var RouteName = "307";

            var expected = "307";
            //act
            var actual = Sut.Get(City, RouteName);

            //assert
            Assert.AreEqual(actual.Name, expected);
        }
    }

執行看看,得到正確的結果!!


但這邊我們都知道,如果今天網路斷掉或是對方API暫時提供服務,則我們的單元測試就會壞掉,因為他呼叫不到真實的API,而這其實違反我們之前說的「單元測試應該關注的是它的邏輯,而非外部的關聯」。如果今天因為環境就出錯,單元測試一多,可能我們常常都要花很多時間去找目前狀況是什麼,更可能的是查到最後才發現原來你的程式沒錯....

所以回頭看看我們的PTX程式,它的外部關聯是什麼?它關注的邏輯又是什麼?

PTX.Get程式目前內部的邏輯如下
1.將帶入的參數組成API Url並進行呼叫
2.將取回的Json轉成DTO
3.判斷回傳的DTO是否有值?有則轉成我們要的結果回傳
3.1 否則直接回傳Null
從這邊我會把它拆成1.呼叫外部API為外部行為,因為如果今天網路斷掉或是對方API壞掉、甚至是對方的API還沒開發好,理論上都不是我們程式的問題,而是網路或是對方要依照規格來處理。

所以第一步是我們要把外部行為隔離出去,並且想辦法讓我們可以模擬它而不是真正去呼叫線上的API,當然這邊做法有很多種,我選擇將Call API的行為還是視為PTX這個物件的內部行為,所以沒有獨立到別的Class去處理,而是封裝起來
public class PTX
    {
        /// <summary>
        /// 取得巴士路線資料
        /// </summary>
        /// <param name="city">縣市名稱</param>
        /// <param name="routeName">巴士路線名稱</param>
        /// <returns></returns>
        public BusRouteDTO Get(string city,string routeName)
        {
            BusRouteDTO Result = null;
            
            var JsonResult = CallAPI(city, routeName);

            if (!string.IsNullOrEmpty(JsonResult))
            {
                var APIResult = JsonConvert.DeserializeObject<List<PTXBusRouteResult>>(JsonResult);

                if (APIResult != null && APIResult.Count > 0)
                {
                    var Route = APIResult.First();
                    Result = new BusRouteDTO
                    {
                        Name = Route.RouteName.Zh_tw,
                        BusStops = new List<BusRouteDTO.BusStop>()
                    };

                    foreach (var stop in Route.Stops)
                    {
                        Result.BusStops.Add(new BusRouteDTO.BusStop
                        {
                            ID = stop.StopUID,
                            Name = stop.StopName.Zh_tw
                        });
                    }
                }
            }
    
            return Result;
        }

        /// <summary>
        /// Call API
        /// </summary>
        /// <param name="city">縣市</param>
        /// <param name="routeName">巴士路線名稱</param>
        /// <returns></returns>
        private string CallAPI(string city, string routeName)
        {
            //Use RestSharp Call API
            var client = new RestClient($"http://ptx.transportdata.tw/MOTC/v2/Bus/StopOfRoute/City/{city}/{routeName}?%24top=1&%24format=JSON");
            var request = new RestRequest(Method.GET);
            request.AddHeader("cache-control", "no-cache");
            IRestResponse response = client.Execute(request);

            if (response.StatusCode == HttpStatusCode.OK)
            {
                return response.Content;
            }

            return string.Empty;
        }
    }

這樣就達到分離了Call API這個外部行為,但是接下來的問題是我們該如何模擬CallAPI的行為,因為目前的程式還是會去呼叫外部API。

讓我們把CallAPI從private改成protect吧,並且把它改成可以virtual,讓繼承它的子類別都可以改寫它

接著我們在單元測試專案中,然後開一個PTXStub的Class來繼承它並賦予他特殊屬性能改寫CallAPI的內部行為
這邊提到Stub,在單元測試中還有一種叫做Mock,詳情定義跟討論可以參考
stackoverflow - What's the difference between a mock & stub?


    public class PTXStub :PTX
    {
        /// <summary>
        /// 用來模擬API回傳的Json Result
        /// </summary>
        public string CallAPIResult;
        protected override string CallAPI(string city, string routeName)
        {
            if (!string.IsNullOrEmpty(CallAPIResult))
            {
                return CallAPIResult;
            }

            return  base.CallAPI(city, routeName);
        }
    }

我們將CallAPIResult的方法改寫掉,讓他回傳我們對外開放出來的CallAPIResult這個屬性,此屬性可以讓我們設定API應該回傳的結果。

接下就回頭改單元測試的部分
  [TestMethod()]
        public void GetTest_傳入縣市和公車路線名稱_如果查的到資料_應回傳該路線的BusRouteDTO()
        {
            //arrange
            var Sut = new PTXStub(); //改成PTXStub
            var City = "Taipei";
            var RouteName = "307";

            //API應該回傳的結果
            var sb = new System.Text.StringBuilder(12766);
            sb.AppendLine(@"[{""RouteUID"":""TPE16111"",""RouteID"":""16111"",""RouteName"":{""Zh_tw"":""307"",""En"":""307""},""KeyPattern"":false,""SubRouteUID"":""TPE157462"",""SubRouteID"":""157462"",""SubRouteName"":{""Zh_tw"":""307莒光經板橋前站"",""En"":""307""},""Direction"":0,""Stops"":[{""StopUID"":""TPE15294"",""StopID"":""15294"",""StopName"":{""Zh_tw"":""莊敬里"",]............");

            //設定CallAPI回傳結果
            Sut.CallAPIResult = sb.ToString();

            var expected = "307";
            //act
            var actual = Sut.Get(City, RouteName);

            //assert
            Assert.AreEqual(actual.Name, expected);
        }
    }

API應該回傳的結果那邊因為太長,所以省略部分回傳結果,但在實際程式是完整貼上,詳請看Git的Source Code。

這樣就可以模擬CallAPI這個跟外部連結的結果,但依然可以測試我們所關注的邏輯,是否可卻轉成我們要的結果,執行後可以發現單元測試還是顯示綠燈的正確無誤。

但這個方法也不是百分之百沒有缺點,你應該也可以觀察到其中一段在這單元測試中無法被涵蓋到,那就是組API Url那段,在這種透過繼承解耦合的方式中,因為回傳結果是自己設定的,實際上組成的URL正確與否我們並不知道,換句話說,如果我們今天這樣寫測試可能會過,但上線後才會發現錯誤,檢查之下發現原來是在串Url的時候錯字之類的。

這是這種測試方法的缺點,但今天就先談到這邊,還有別種方法可以解決這個問題,只是通常有一好沒兩好,各自都會有優缺點,就看實際專案情形自行判斷跟取捨了。

2017年4月18日 星期二

【Unit Test】Day 2 - 如何寫一個好的單元測試


接著來討論一下單元測試究竟該怎麼寫,你可能也會有這樣的疑問,如果今天我的程式是要呼叫資料庫來取得資料,那是否真要接上一個資料庫去做單元測試呢?又公司的資料庫如果對外是連不到的,悲苦的工程師回家怎麼做單元測試呢? 那如果是外部API呢?網路不通時單元測試是不是就都壞掉了? 那該怎麼知道現在單元測試是真正的邏輯錯誤還是只是因為網路或是資料庫連不上導致的錯誤?是不是寫了單元測試後反而我要花大量的時間常常在檢查到底現在是錯哪邊啊.....。

其實我剛開始接觸單元測試的時候上面的問題也都想過一輪,但這也點出了單元測試一個很重要的特性

單元測試應該是隨時隨地都要能正確執行,只要它本身的邏輯是正確的!!
看完這句話你可能會默默響起OS,「阿鬼,你還是說中文吧~」。不過這段容我之後另外篇幅再做詳盡的解說,現在只要有這樣的觀念就好,單元測試不應該隨著你在的環境不同而有結果的落差,他關注的是邏輯不是與外部的關聯。


那好,假設我們做到了單元測試跟外部的關聯都斷開了,只專注在自己的邏輯上,這樣就稱得上是好的單元測試了嗎? 當然不是! 單元測試的命名也是一個很重要的課題
單元測試的標題需要具備好的可讀性、明確、標題與測試的內容精確吻合
還記得上一篇文章提到,撰寫良好的單元測試應該像是規格書一般,不僅要讓專案品質提高,更是要充當新接觸專案人員能透過閱讀單元測試對程式有基本認識的工具。

舉例來說:有一個單元測試標題這樣寫「public void GetTest_呼叫得到True()」,對於一個不看單元測試內容的人來說,這個標題一點意義都沒有,首先他不知道這個方法裡面是在幹嘛的,再來他究竟會做什麼事情導致他得到True,帶入的參數意義是什麼...等,這樣的單元測試反而是造成專案難以維護的幫兇。

所以比較好的單元測試標題應該詳盡,例:「public void GetTest_帶入會員ID_應回該ID搜尋到的會員資料DTO」,盡量符合
受測方法_傳入參數意義_期望得到的結果
比起第一種命名方式是否明確易懂多了。


你可能又會冒出一個問題,如果這個方法帶進的ID查不到會員時,我會回傳Null,但怎麼在一個單元測試表示?這邊點出另一個重點
一個測試只應該關注一件事情,如果受測目標有多種狀況,應該分成好幾個測試去涵蓋所有邏輯
順著上面的邏輯,這個方法應該就會有另一個測試為「public void GetTest_帶入會員ID_如搜尋不到該ID的會員_回傳Null」。

所以如果一個方法裡面的IF ELSE很多,導致程式邏輯複雜度提高,則單元測試可能就會有對應的很多方法來涵蓋所有可能,所以如果有寫單元測試,則職責分離就是一個重要的課題,如果你把很多職責都放在同一段程式中,你的單元測試可能是倍數成長之外,測試也會變得很難撰寫。

從上面的舉例也可以看出,每個單元測試都只關注一種邏輯,一個方法難免包含多種邏輯狀況,但當修改程式時如果單元測試錯誤,也能幫助你快速鎖定可能是哪一段邏輯錯了,減少除錯的時間。再者,因為每個單元測試名稱都很明確,執行的方法帶入的參數也都明確的情況下,讓人閱讀時可以很容易進入狀況,符合一開始提到的可以執行的規格書,對專案是非常有幫助的。


接著來探討單元測試的內文該如何撰寫,首先應該符合所謂的3A原則
Arrange = 準備受測物件、參數、預期結果
Act = 執行受測方法
Assert = 驗證執行結果與預測結果是否一致

拿昨天的單元測試來說明
        [TestMethod()]
        public void Method1Test_呼叫時應回傳結果為7()
        {
            //arrange
            var Sut = new EasyMethod();
            var expected = 7;

            //act
            var actual = Sut.Method1();

            //assert
            Assert.AreEqual(expected, actual);
        }
Arrange中Sut(System Under Test ),受測的目標為EasyMethod這跟Class,而這個單元測試預期的結果為7。

Act中actual是EasyMethod執行Method1這個方法得到的結果。

Assert中用MsTest提供的Assert.AreEqual方法驗證得到的結果與預期的結果是否一致。

如果每個單元測試都照著這樣的風格撰寫,則對於閱讀的人來說會很清楚歸類出每個區段各自的職責。


那今天就談到這邊,之後應該就會有比較多的實作了(擦汗)


2017年4月16日 星期日

【Unit Test】Day 1 - 為何要寫單元測試


Demo檔案 : Git傳送門
算一算開始寫單元測試也快兩年了,很感謝當時能有機會得到前輩的指導並接觸到這個技術,在實際運用到開發上面時,也真正的感受到它的好,為了將單元測試這好東西分享給更多人知道,所以有了寫這系列文章的想法。

這系列文章是希望在讀者讀完後,能讓你從一位不會寫單元測試的開發者,粗略的搞懂單元測試為何、如何撰寫、並且能透過實作中體會到它如何幫助專案更加穩固。

我不是什麼技術大牛,如果發現文章有疏漏的地方,還請不吝指正指導。 感謝!!

為何我要寫單元測試

不知你可曾遇過這樣的狀況....

情境一 :

接手了一個很舊的系統維護,關於規格與文件皆已不可考,連開發過的工程師們都已離職不幹了,現在PM跟你說他想改一個功能,並拍拍你肩說「這個很簡單,一下下就好」。而你卻不知道該從何下手....

單元測試能幫你做什麼?
優良的單元測試就像活著的規格書,不僅能幫助你了解那些不是你負責的功能,更是能夠執行的規格書

情境二:

是否曾經改發生過改好新功能,但舊功能就壞掉,修好舊功能,新功能又壞掉了呢?  假設下圖就是你的功能,而你希望它三個能同時選取時....

http://abcdefghijklmn-pqrstuvwxyz.com/en/you-cant-have-it-all/

單元測試能幫你做什麼?
它能告訴你目前的程式,是否執行都符合當時所要求的規格與產出結果,如果舊規格舊方法沒有改的情況下,單元測試壞了,那就表示你這次的改動絕對有影響到它,趕緊去修好它吧!!

情境三 :

一個你覺得很簡單的功能,直到你上線才發現原來功能是錯的無法正常執行,偵錯下去才發現,天殺的,原來是自己觀念上錯誤或不熟悉。例如Double a  = 1.1加上Double b = 1.2,而你以為它的結果會是2.3.... 

單元測試能幫你做什麼?
它能即時的驗證你的想法,而不是到上線時才賭人品,尤其是那些你覺得理所當然會對的功能中,魔鬼往往藏在細節裡

在你還沒寫單元測試之前,上述狀況是否都似曾相識呢?如果你想解決這些問題,那麼開始寫單元測試將是可以大幅度降低這些錯誤的有效方法,而且你一定會愛上它。


該如何開始寫單元測試

接著我們就來用實際案例重現上面提到了一些問題,並且透過撰寫單元測試來看看它如何幫我們避免這些狀況。

首先準備一個類別庫專案


寫下一段簡單的程式,而這段程式很簡單,就只是把基數加上2回傳回去即可


public class EasyMethod
{
   private int BaseNumber = 5;

   public int Method1()
   {
      return BaseNumber + 2;
   }   
}

而這段程式真的非常簡單,簡單到你一看就覺得它回傳是7,不過沒關係我們繼續完成單元測試,看它能幫助我們什麼


接著建立單元測試專案
在Method1的的方案下右鍵 > 建立單元測試
如果你是用VS2017,可能會發現沒有建立單元測試這個選項,請將VS更新到最新版就有了


接著就會看到單元測試專案已經幫你建立完成


讓我們來寫下第一個完整的單元測試
將那個自動建立的單元測試改成
        [TestMethod()]
        public void Method1Test_呼叫時應回傳結果為7()
        {
            //arrange
            var Sut = new EasyMethod();
            var expected = 7;

            //act
            var actual = Sut.Method1();

            //assert
            Assert.AreEqual(expected, actual);
        }

稍微檢視一下這個單元測試能帶給我們什麼?首先從標題上我們可以知道這個方法的目的跟應得到的結果,接著我們可以從單元測試的程式碼中看出這個方法該如何使用。

接著我們在測試總管中執行單元測試,知道目前這個方法符合我們需求跟得到預期的結果

當我們面臨需求的更改.....
接著我們知道日常狀況是,需求總是一直的在擴充及變動,所以我們有了一個新需求

我希望能有個新的方法,並且呼叫它時能回傳給我10這個答案,而且因應業務需求,基數需要改變成2
OK!!一切聽起來都不是太難,那就讓我們直接動手下去做吧
首先我先將基數改成2,並且新增一個Method2的方法,並且讓他加上基數後得到10這個結果回傳。

老方法,在Method2那邊按下右鍵 > 建立單元測試,並寫上新的方法的單元測試
namespace UnitTestDay1.Tests
{
    [TestClass()]
    public class EasyMethodTests
    {
        [TestMethod()]
        public void Method1Test_呼叫時應回傳結果為7()
        {
            //arrange
            var Sut = new EasyMethod();
            var expected = 7;

            //act
            var actual = Sut.Method1();

            //assert
            Assert.AreEqual(expected, actual);
        }

        [TestMethod()]
        public void Method2Test_呼叫時應回傳結果為10()
        {
            //arrange
            var Sut = new EasyMethod();
            var expected = 10;

            //act
            var actual = Sut.Method2();

            //assert
            Assert.AreEqual(expected, actual);
        }
    }
}
一切完美直到你執行單元測試時你會發現,原本的Method1壞掉啦!!
原來因為我們更改了基數所以造成了Method1回傳時不符合當時所制定的規格,這時候我們如果確定原需求沒有變動的情況下,那就是去修改原本的方法讓它能通過單元測試。這樣新舊需求就都確保正確的情況下更正完成了。



今日小結

從這個範例中我們可以整理出一些單元測試所能帶來的好處。

首先,因為在撰寫程式時,我們會在當下寫上單元測試,並且讓它通過,日後不管任何原因它壞掉了,我們都能從標題中或是案例來知道當時的需求與狀況,形成上面提到的所謂活著可執行的規格書,(當然前提是你的標題跟內容要寫得乾淨易懂,否則維護單元測試可能又是另一場災難),即便規格書遺失或是人員異動,對於程式本身的維護都有一定品質的保障。

接著是需求異動,雖然這個範例舉例的有點極端,但我只是想表達一種狀況,常常我們在改動共用方法或是核心功能時,往往不知道這樣改可能會造成哪邊的錯誤,甚至是編譯執行都是正常,但其實違反了當時所定義的程式或商業邏輯,而這往往是要上線的時候才會發現,但從這案例中,基數被改變時,雖然把Mehod2順利地完成的,但我們卻忽略了Method1可能導致它變動進而產生錯誤,有了單元測試或是整合測試,這樣的狀況可以有效地減少跟被控管(特別強調:非百分之百!!所以整合測試跟單元測試都一樣很重要,如果可以,兩個都做會是最好的選擇)

好的,希望第一篇文章可以淺顯易懂的讓大家感受單元測試的好處!!