1. 程式人生 > >C#-面向物件:爭議TDD(測試驅動開發)

C#-面向物件:爭議TDD(測試驅動開發)

-----------------------

絕對原創!版權所有,轉發需經過作者同意。

-----------------------

 

在談到特性的使用場景時,還有一個絕對離不開的就是

單元測試

按飛哥的定義,單元測試是開發人員自己用程式碼實現的測試 。注意這個定義,其核心在於:

  • 主體是“開發人員”,不是測試人員。
  • 途徑是“通過程式碼實現”,不是通過手工測試。
  • 實質是一種“測試”,不是程式碼除錯。

暫時還有點抽象,同學們記著這個概念,我們先用一個

 

NUnit專案

來看一看單元測試長個什麼樣。

在solution上右鍵新增專案,選擇Test中的NUnit Test Project,輸入專案名稱,點選OK:

Visual Studio直接集成了NUnit說明微軟在開源和社群支援的路上確實是一路狂奔,因為NUnit是一個由社群支援的、完全開源的、和微軟自己的MSTest Test和Unit Test直接競爭的單元測試框架。微軟確實已經從“什麼都要自己有”向“借用(不僅是借鑑)乃至大力支援一切優質開源專案”華麗轉身。

新建的單元測試專案包含一個預設的類檔案:UnitTest1.cs,其中首先使用了using:

using NUnit.Framework;

因為NUnit的所有成員(類和方法等)都在NUnit.Framework名稱空間之下。

然後有一個類:

    public class Tests
    {
        [SetUp]
        public void Setup()
        {
        }

        [Test]
        public void Test1()
        {
            Assert.Pass();
        }
    }

你發現這個專案和Console Project不同,它沒有沒有Main()函式作為入口,怎麼執行呢?就算我知道它可以由NUnit呼叫,但NUnit怎麼呼叫呢?這就需要用到 反射 了:NUnit會在整個程式集(專案)中遍歷,找到帶有特定標籤(特性)的類和方法,予以相應的處理。

注意這個類裡面的兩個方法都被貼上了特性:

  • SetUp:被標記的方法將會在每一個測試方法被呼叫前呼叫
  • Test:被標記的方法會被依次呼叫

NUnit是依據特性而不是方法名來確定如何呼叫這些方法的,所以Tests的類名和其中的方法名都可以修改。

那麼如何啟動測試呢?快捷鍵Ctrl+E+T,或者在VS的選單欄上,依次:Test-Windows-Test Explore開啟測試視窗即可:

然後在Test1上點選右鍵,就可以Run(執行)或者Debug(除錯)這個測試方法了。

演示:

 

測試方法中現在可以使用

Assert(斷言)

呼叫各種方法,最常用的是Assert.AreEqual(),比較傳入的兩個引數:

        [Test]
        public void Test1()
        {
            Assert.AreEqual(5, 3 + 2);
        }

        [Test]
        public void Test2()
        {
            Assert.AreEqual(8, 3 + 2);
        }

前面一個引數代表你期望獲得的值,後面一個引數代表實際獲得的值。如果兩個值相等,測試通過;否則會丟擲AssertException異常。

一個方法裡可以有多條Assert語句,只有方法裡所有Assert語句全部通過,方法才算通過測試。方法通過,用綠色√表示;否則,用紅色×標識。

點選未通過的方法,可以看到其詳細資訊:

尤其是StackTrace,是我們定位未通過Assert的有力工具。

 

當然上面的演示是沒有實際作用的,3+2=5這是在測試C#的運算能力呢,^_^。我們要測試的,是我們自己寫的程式碼(通常是方法)。比如,Student類(學生)有一個例項方法Grow(),每呼叫一次該方法,這個學生的年齡就增長一歲。

所以我們應該怎麼做?先實現這個方法吧……注意,注意,注意!標準(推薦)的做法不是這樣的,而應該是:先測試,再開發 。

啥?一臉懵逼,(黑人問號.jpg

這就不得不提到大名鼎鼎的:

TDD

其全稱是Test-Driven Development(測試驅動開發),其核心是:在開發功能程式碼之前,先編寫單元測試用例程式碼。具體來說,它要求的開發流程是這樣的:

  1. 寫一個未實現的開發程式碼。比如定義一個方法,但沒有方法實現
  2. 為其編寫單元測試。確定方法應該實現的功能
  3. 測試,無法通過。^_^,因為沒有方法實現嘛。但這一步必不可少,以免單元測試的程式碼有誤,無論是否正確實現方法功能測試都可以通過
  4. 實現開發程式碼。比如在方法中完成方法體。
  5. 再次測試。如果通過,Over;否則,查詢原因,修復,直到通過。

以上述Student.Grow()的需求為例:

首先,在Student中定義該方法但不要有真正的實現,所以可以是這樣的:

    public class Student
    {
        public int Age { get; set; }
        public void Grow()
        {
            //沒有方法實現
        }
    }

然後,為該方法編寫一個單元測試:

        [Test]
        public void Grow()
        {
            //測試準備:得到一個學生物件,其年齡為18歲
            Student student = new Student();
            student.Age = 18;

            //呼叫Grow()方法
            student.Grow();

            //檢查是否實現了預期的結果
            //該學生的年齡變成了19(=18+1)
            Assert.AreEqual(19, student.Age);
        }

注意我們是在一個新專案中測試另外一個專案,一個專案使用另外一個專案的程式碼,必須要新增引用。

演示:接下來,不要忘了要跑一遍這個測試,當然這個測試是無法通過的。

再然後,才去完成方法Grow():

        public void Grow()
        {
            Age++;
        }

再跑一遍測試,通過!收工,^_^

為什麼要這麼做呢?為了避免你的開發程式碼影響了你的測試思路

同學們注意除錯和測試的區別:除錯是為了實現功能修復bug,而測試是為了找到bug!換言之,測試就是要get到你開發沒有get到的點上去。如果你先寫了開發程式碼,腦子裡已經有了實現的細節,那就很容易出現:寫的測試程式碼,無非就是把開發程式碼再“翻譯”一遍,這樣的測試幾乎沒有意義。

你說,我其實也沒看出來你上面這個單元測試有啥意義,^_^

Wonderful!這說明你是帶著腦子在聽課的。

為了表現出單元測試的意義,我們來完成這樣一個功能:

雙向連結串列

大家看我們一起幫的文章單頁,每一篇底部都有一個“上一篇”和“下一篇”

對應到文章物件,是不是它裡面就應該包含兩個屬性:Previous(上一篇)和Next(下一篇)。我們再把它進一步的抽象,不侷限於文章,就可以得到這樣一個數據結構物件:

    public class DoubleLinked
    {
        public DoubleLinked Previous { get; set; }
        public DoubleLinked Next { get; set; }
        public int Value { get; set; }
    }

因為每一個物件都有,就可以串成一串,這就是所謂的雙向連結串列。用圖表示:

雙向連結串列是有頭(Head)和尾(Tail)的,頭前面沒有節點,尾後面沒有節點。用程式碼表示就是:

        public bool IsHead
        {
            get
            {
                return Previous == null;
            }
        }

        public bool IsTail
        {
            get
            {
                return Next == null;
            }
        }

注意:DoubleLinked既可以看成是雙向連結串列中的一個節點,也可以看成是雙向連結串列本身——因為從這個節點出發,向前(Previous)向後(Next)就能夠獲得全部的節點;即使是雙向連結串列,也不會儲存所有節點,而是儲存一個頭或/和尾即可。這裡為了簡便,就直接使用DoubleLinked進行各種操作了。

 

現在我們來實現雙向連結串列中最

基本的操作

,插入一個節點,如下圖所示,把節點5查入2和3之間。

方法很簡單:

  1. 把2的下一個指向5
  2. 把5的下一個指向3
  3. 把3的上一個指向5
  4. 把5的上一個指向2

但程式碼怎麼實現?你先想一想,^_^

  1. 首先,轉變思路,把“查入2和3之間”轉變成“插入2之後(InsertAfter(2))”,這樣是不是就簡單多了?
  2. 然後,你得想想,還需要指明“把誰”插入節點2之後?是不是要在InsertAfter()中再新增一個引數?
  3. 最後,InsertAfter()這個方法放哪裡?靜態的還是例項的?

通過前面的學習和作業練習,我們知道了兩個原則:

  • 能夠例項就不要靜態
  • 儘可能的減少方法引數個數

所以,我們應該定義這樣的一個例項方法:

        /// <summary>
        /// 在node之後插入當前節點
        /// </summary>
        /// <param name="node">在哪一個節點之後插入</param>
        public void InsertAfter(DoubleLinked node)
        {
        }

OK,方法有了,你馬上就擼柚子準備實現了……停停停!我們要先寫單元測試。事情沒有你想象的那麼簡單,你要不信這個邪呢,我們後面還有作業,你可以直接試一試。

趁我們現在頭腦還清醒的時候,先想想測試的事。

首先我們要新增一個InsertAfterTest()方法,注意不要忘記在這個方法上新增[Test]特性,否則它不會被當做測試方法被NUnit呼叫執行:

        [Test]   //不要忘記[Test]特性
        public void InsertAfterTest()  //測試方法也不需要任何返回值
        {
        }

為了測試,我們是不是首先要構建一個連結串列?然後才能往裡面插入啊,怎麼構建呢?只有手工,在InsertAfterTest()中新增:

            //在單元測試中,命名可以帶123等字尾區分
            DoubleLinked node1 = new DoubleLinked();
            DoubleLinked node2 = new DoubleLinked();
            DoubleLinked node3 = new DoubleLinked();
            DoubleLinked node4 = new DoubleLinked();

            node1.Next = node2;
            node2.Next = node3;
            node3.Next = node4;

            node4.Previous = node3;
            node3.Previous = node2;
            node2.Previous = node1;

然後,再新建一個inserted節點,將其插入節點2之後:

            DoubleLinked inserted = new DoubleLinked();
            inserted.InsertAfter(node2);

OK,完成插入過後,應該是怎麼樣的一個情形?我們用程式碼表示:

            Assert.AreEqual(inserted, node2.Next);
            Assert.AreEqual(inserted, node3.Previous);
            Assert.AreEqual(node2, inserted.Previous);
            Assert.AreEqual(node3, inserted.Next);

跑一跑測試,當然是跑不過的,因為InsertAfterTest()根本沒實現嘛。

好了,讓我們去實現InsertAfterTest()方法吧……停停停!別慌,測試是為了找到bug,什麼情況容易出bug,

 

極端情況

下就容易出bug啊!什麼是極端情況,想一想,有了:如果是在連結串列的尾部插入呢?是不是也應該測一測?

這時候我們有兩種選擇:

  1. 繼續在InsertAfterTest()中新增Assert行
  2. 新開一個方法InsertAfterTailTest()

我們就用第2種吧,看上去更規範更清晰一些。

這時候就會有一個問題,是不是要在InsertAfterTailTest()中把構建連結串列的程式碼再寫一遍?你說不用,我可以複製貼上!你真是個機靈鬼,記住:程式設計師憎恨ctrl+c加ctrl+v

我們的單元測試類還是一個類,這個類裡面一樣可以有各種類成員,比如欄位方法屬性等等。既然這些連結串列節點可以反覆使用,我們為什麼不把他們定義為欄位呢?再回想一下我們的[Setup]特性,它是會在每一個測試方法被呼叫前執行一次的。我們可以在這裡面完成節點的連結:

        //在單元測試中,命名可以帶123等字尾區分
        DoubleLinked node1, node2, node3, node4;

        [SetUp]
        public void Setup()
        {
            node1 = new DoubleLinked();
            node2 = new DoubleLinked();
            node3 = new DoubleLinked();
            node4 = new DoubleLinked();

            node1.Next = node2;
            node2.Next = node3;
            node3.Next = node4;

            node4.Previous = node3;
            node3.Previous = node2;
            node2.Previous = node1;
        }

於是,InsertAfterTailTest()裡面的程式碼就非常簡單了:

        [Test]
        public void InsertAfterTailTest()
        {
            DoubleLinked inserted = new DoubleLinked();
            inserted.InsertAfter(node4);

            Assert.AreEqual(inserted, node4.Next);
            Assert.AreEqual(node4, inserted.Previous);
            Assert.AreEqual(null, inserted.Next);
        }

(InsertAfterTest()方法一樣按此精簡,此處略過)

那還有沒有其他“極端情況”?有,但飛哥不告訴你,接下來做作業的時候自己去想!^_^

終於,我們可以實現InsertAfter()並執行單元測試了……

演示:稍有不慎就無法通過測試,按下葫蘆浮起瓢:

這裡有一個小技巧:先專注於通過最常規的InsertAfterTest(),然後再想辦法同時通過InsertAfterTest()和InsertAfterTailTest()。

好了,一路改,千辛萬苦通過了這個單元測試,如下所示:

        public void InsertAfter(DoubleLinked node)
        {
            if (node.Next == null)
            {
                node.Next = this;
                this.Previous = node;
            }
            else
            {
                this.Next = node.Next;
                this.Previous = node;
                node.Next = this;
                this.Next.Previous = this;
            }
        }

 

然後,你看這if...else裡面好像有一些重複程式碼,比如:

node.Next = this;
this.Previous = node;

這不是重複程式碼麼?可不可以提出來?進行

重構

其實飛哥之前給同學們進行作業點評。如果你的程式碼沒有錯誤,但我還是給你改了,這就是在做重構

在不改變程式碼執行結果的前提下,優化程式碼質量(安全、效能和可讀性)

不知道大家有沒有聽說過一句話:

好程式碼都是改出來的。

很少有人一次性的寫出非常完美的程式碼——尤其是程式碼會隨著業務邏輯不斷變化的時候,你根本就不可能一次性的完成程式碼,一定是不斷的修修補補。但是,實際開發中,你會發現“修修補補”就會把程式碼慢慢地變成了“屎山”。最有越改越爛,哪有什麼“千錘百煉”?!

可以想象的一個場景:你滿懷激情地正準備要重構,被你專案經理一把撲倒在地,“小子,不要命啦!?”

為什麼?

你試試重構一下我們剛才的程式碼,按照我們想的:

        public void InsertAfter(DoubleLinked node)
        {
            node.Next = this;
            this.Previous = node;

            if (node.Next != null)
            {
                this.Next = node.Next;
                this.Next.Previous = this;
            }
        }

看起來程式碼是整潔多了!然而,就在你沾沾自喜的時候,跑一下單元測試試試?

這就是為什麼不能重構的原因:

沒有單元測試做保證,你的重構風險太大

其實新增新的feature(功能),修復舊的bug也一樣,很容易對其他程式碼產生干擾,引入新的bug。而且這些bug可能很隱蔽,不一定能夠被及時發現——除非你有單元測試。有了單元測試,每次程式碼改動,把所有的(注意,是所有的!)單元測試跑一遍,都跑過了,就證明改動沒有影響現有程式碼。

所謂TDD,其實就是要求所有的開發程式碼都有對應的單元測試(因為你要先寫單元測試再寫開發程式碼嘛),用單元測試來保證程式碼的:

  • 正確性。理論上,TDD的程式碼bug率非常低——那得你單元測試和開發程式碼都有疏漏,且雙方的疏漏“相相容”才行。否則,開發程式碼的bug會被單元測試暴露出來;單元測試的bug也會被開發程式碼暴露出來。
  • 可維護性。這其實才是TDD最重要的價值。以後同學們會越來越多的體會到程式碼維護工作的難度和重要性。業界有一句非常著名的論斷:
一個專案,開發所需的時間要佔20%,而維護的時間要佔80%

同學們進入工作崗位,更大概率也是進行程式碼的維護工作(新增新feature,修復老bug等),而不是從頭開發。如果沒有單元測試覆蓋,很多時候維護工作就是“頭疼醫頭腳疼醫腳”,修復了舊的bug,帶來了新的bug。形象的比喻就是:

  • 這裡有個坑,我在旁邊挖點土填上,於是旁邊又有了一個坑;
  • 好醜的一坨屎,怎麼辦?再上面再拉一坨屎蓋住它!於是那些歷史遺留程式碼都被稱之為屎山。

目前來說,TDD是一個理論上能夠大幅度降低程式碼維護成本的方法。但注意飛哥用的“理論上”三個字,啥意思呢?實際上,開發過程真正做到TDD的不多,甚至可以說非常少。而TDD也從誕生之初的讚歎不止,變得越來越有爭議。

究其根本原因,飛哥認為,無他:

 

成本和收益

考量而已。最基本的事實,使用TDD開發,程式碼量至少翻番,值得麼?確實,TDD可以降低後期的維護成本;但是,降低多少呢?和現在的投入相比,收益如何呢?更重要更重要的一個問題:能這個專案有後期維護麼?99%的網際網路專案,根本就活不到後期維護好吧?

另外,單元測試不是那麼好寫的。尤其是涉及到資料庫,涉及到外部呼叫介面,專案變得越來越複雜耦合度越來越高的時候……,這些需要同學們以後逐漸體會。同學們目前只需要記住兩點:

  1. 能夠單元測試的程式碼,一定是(高質量的)非常容易解耦的程式碼。
  2. 能寫出高質量程式碼的程式設計師,工資一定是不低的

所以,歸根結底,還是成本問題。

就飛哥個人而言,更願意取一個折中:

僅為“核心”程式碼使用TDD,引入單元測試。

什麼是核心程式碼呢?大致來說,複雜的、被大量使用、被反覆修改的……,都可以算。但最終還是要靠開發人員根據實際情況具體掌握了。

 

作業:

  1. 為之前作業新增單元測試,包括但不限於:
    1. 陣列中找到最大值
    2. 找到100以內的所有質數
    3. 猜數字遊戲
    4. 二分查詢
    5. 棧的壓入彈出
  2. 繼續完成雙向連結串列的測試和開發,實現:
    1. InerstBefore():在某個節點前插入
    2. Delete():刪除某個節點
    3. Swap():互動某兩個節點
    4. FindBy():根據節點值查詢到某個節點

每日單詞:

 

-------------------------------

源棧第二期,飛哥開始編寫更優質的課程講義了。

太基礎的就沒有發到園子裡,但這一篇TDD相關的,有那麼一點點意思,先發到園子裡試試水,如果覺得可以的話,別忘記點個贊。以後有好的,我也都發到園子裡來,^_^