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(測試驅動開發),其核心是:在開發功能程式碼之前,先編寫單元測試用例程式碼。具體來說,它要求的開發流程是這樣的:
- 寫一個未實現的開發程式碼。比如定義一個方法,但沒有方法實現
- 為其編寫單元測試。確定方法應該實現的功能
- 測試,無法通過。^_^,因為沒有方法實現嘛。但這一步必不可少,以免單元測試的程式碼有誤,無論是否正確實現方法功能測試都可以通過
- 實現開發程式碼。比如在方法中完成方法體。
- 再次測試。如果通過,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之間。
方法很簡單:
- 把2的下一個指向5
- 把5的下一個指向3
- 把3的上一個指向5
- 把5的上一個指向2
但程式碼怎麼實現?你先想一想,^_^
- 首先,轉變思路,把“查入2和3之間”轉變成“插入2之後(InsertAfter(2))”,這樣是不是就簡單多了?
- 然後,你得想想,還需要指明“把誰”插入節點2之後?是不是要在InsertAfter()中再新增一個引數?
- 最後,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啊!什麼是極端情況,想一想,有了:如果是在連結串列的尾部插入呢?是不是也應該測一測?
這時候我們有兩種選擇:
- 繼續在InsertAfterTest()中新增Assert行
- 新開一個方法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%的網際網路專案,根本就活不到後期維護好吧?
另外,單元測試不是那麼好寫的。尤其是涉及到資料庫,涉及到外部呼叫介面,專案變得越來越複雜耦合度越來越高的時候……,這些需要同學們以後逐漸體會。同學們目前只需要記住兩點:
- 能夠單元測試的程式碼,一定是(高質量的)非常容易解耦的程式碼。
- 能寫出高質量程式碼的程式設計師,工資一定是不低的
所以,歸根結底,還是成本問題。
就飛哥個人而言,更願意取一個折中:
僅為“核心”程式碼使用TDD,引入單元測試。
什麼是核心程式碼呢?大致來說,複雜的、被大量使用、被反覆修改的……,都可以算。但最終還是要靠開發人員根據實際情況具體掌握了。
作業:
- 為之前作業新增單元測試,包括但不限於:
- 陣列中找到最大值
- 找到100以內的所有質數
- 猜數字遊戲
- 二分查詢
- 棧的壓入彈出
- 繼續完成雙向連結串列的測試和開發,實現:
- InerstBefore():在某個節點前插入
- Delete():刪除某個節點
- Swap():互動某兩個節點
- FindBy():根據節點值查詢到某個節點
每日單詞:
-------------------------------
源棧第二期,飛哥開始編寫更優質的課程講義了。
太基礎的就沒有發到園子裡,但這一篇TDD相關的,有那麼一點點意思,先發到園子裡試試水,如果覺得可以的話,別忘記點個贊。以後有好的,我也都發到園子裡來,^_^