-----------------------
絕對原創!版權所有,轉發需經過作者同意。
在談到特性的使用場景時,還有一個絕對離不開的就是
按飛哥的定義,單元測試是開發人員自己用代碼實作的測試 。注意這個定義,其核心在于:
主體是“開發人員”,不是測試人員。
途徑是“通過代碼實作”,不是通過手工測試。
實質是一種“測試”,不是代碼調試。
暫時還有點抽象,同學們記着這個概念,我們先用一個
NUnit項目
來看一看單元測試長個什麼樣。
在solution上右鍵添加項目,選擇Test中的NUnit Test Project,輸入項目名稱,點選OK:

Visual Studio直接內建了NUnit說明微軟在開源和社群支援的路上确實是一路狂奔,因為NUnit是一個由社群支援的、完全開源的、和微軟自己的MSTest Test和Unit Test直接競争的單元測試架構。微軟确實已經從“什麼都要自己有”向“借用(不僅是借鑒)乃至大力支援一切優質開源項目”華麗轉身。
建立的單元測試項目包含一個預設的類檔案:UnitTest1.cs,其中首先使用了using:
因為NUnit的所有成員(類和方法等)都在NUnit.Framework命名空間之下。
然後有一個類:
你發現這個項目和Console Project不同,它沒有沒有Main()函數作為入口,怎麼運作呢?就算我知道它可以由NUnit調用,但NUnit怎麼調用呢?這就需要用到 反射 了:NUnit會在整個程式集(項目)中周遊,找到帶有特定标簽(特性)的類和方法,予以相應的處理。
注意這個類裡面的兩個方法都被貼上了特性:
SetUp:被标記的方法将會在每一個測試方法被調用前調用
Test:被标記的方法會被依次調用
NUnit是依據特性而不是方法名來确定如何調用這些方法的,是以Tests的類名和其中的方法名都可以修改。
那麼如何啟動測試呢?快捷鍵Ctrl+E+T,或者在VS的菜單欄上,依次:Test-Windows-Test Explore打開測試視窗即可:
然後在Test1上點選右鍵,就可以Run(運作)或者Debug(調試)這個測試方法了。
示範:
測試方法中現在可以使用
Assert(斷言)
調用各種方法,最常用的是Assert.AreEqual(),比較傳入的兩個參數:
前面一個參數代表你期望獲得的值,後面一個參數代表實際獲得的值。如果兩個值相等,測試通過;否則會抛出AssertException異常。
一個方法裡可以有多條Assert語句,隻有方法裡所有Assert語句全部通過,方法才算通過測試。方法通過,用綠色√表示;否則,用紅色×辨別。
點選未通過的方法,可以看到其詳細資訊:
尤其是StackTrace,是我們定位未通過Assert的有力工具。
當然上面的示範是沒有實際作用的,3+2=5這是在測試C#的運算能力呢,^_^。我們要測試的,是我們自己寫的代碼(通常是方法)。比如,Student類(學生)有一個執行個體方法Grow(),每調用一次該方法,這個學生的年齡就增長一歲。
是以我們應該怎麼做?先實作這個方法吧……注意,注意,注意!标準(推薦)的做法不是這樣的,而應該是:先測試,再開發 。
啥?一臉懵逼,(黑人問号.jpg
這就不得不提到大名鼎鼎的:
其全稱是Test-Driven Development(測試驅動開發),其核心是:在開發功能代碼之前,先編寫單元測試用例代碼。具體來說,它要求的開發流程是這樣的:
寫一個未實作的開發代碼。比如定義一個方法,但沒有方法實作
為其編寫單元測試。确定方法應該實作的功能
測試,無法通過。^_^,因為沒有方法實作嘛。但這一步必不可少,以免單元測試的代碼有誤,無論是否正确實作方法功能測試都可以通過
實作開發代碼。比如在方法中完成方法體。
再次測試。如果通過,Over;否則,查找原因,修複,直到通過。
以上述Student.Grow()的需求為例:
首先,在Student中定義該方法但不要有真正的實作,是以可以是這樣的:
然後,為該方法編寫一個單元測試:
注意我們是在一個新項目中測試另外一個項目,一個項目使用另外一個項目的代碼,必須要添加引用。
示範:接下來,不要忘了要跑一遍這個測試,當然這個測試是無法通過的。
再然後,才去完成方法Grow():
再跑一遍測試,通過!收工,^_^
為什麼要這麼做呢?為了避免你的開發代碼影響了你的測試思路。
同學們注意調試和測試的差別:調試是為了實作功能修複bug,而測試是為了找到bug!換言之,測試就是要get到你開發沒有get到的點上去。如果你先寫了開發代碼,腦子裡已經有了實作的細節,那就很容易出現:寫的測試代碼,無非就是把開發代碼再“翻譯”一遍,這樣的測試幾乎沒有意義。
你說,我其實也沒看出來你上面這個單元測試有啥意義,^_^
Wonderful!這說明你是帶着腦子在聽課的。
為了表現出單元測試的意義,我們來完成這樣一個功能:
大家看我們一起幫的文章單頁,每一篇底部都有一個“上一篇”和“下一篇”
對應到文章對象,是不是它裡面就應該包含兩個屬性:Previous(上一篇)和Next(下一篇)。我們再把它進一步的抽象,不局限于文章,就可以得到這樣一個資料結構對象:
因為每一個對象都有,就可以串成一串,這就是所謂的雙向連結清單。用圖表示:
雙向連結清單是有頭(Head)和尾(Tail)的,頭前面沒有節點,尾後面沒有節點。用代碼表示就是:
注意:DoubleLinked既可以看成是雙向連結清單中的一個節點,也可以看成是雙向連結清單本身——因為從這個節點出發,向前(Previous)向後(Next)就能夠獲得全部的節點;即使是雙向連結清單,也不會存儲所有節點,而是存儲一個頭或/和尾即可。這裡為了簡便,就直接使用DoubleLinked進行各種操作了。
現在我們來實作雙向連結清單中最
基本的操作
,插入一個節點,如下圖所示,把節點5查入2和3之間。
方法很簡單:
把2的下一個指向5
把5的下一個指向3
把3的上一個指向5
把5的上一個指向2
但代碼怎麼實作?你先想一想,^_^
首先,轉變思路,把“查入2和3之間”轉變成“插入2之後(InsertAfter(2))”,這樣是不是就簡單多了?
然後,你得想想,還需要指明“把誰”插入節點2之後?是不是要在InsertAfter()中再添加一個參數?
最後,InsertAfter()這個方法放哪裡?靜态的還是執行個體的?
通過前面的學習和作業練習,我們知道了兩個原則:
能夠執行個體就不要靜态
盡可能的減少方法參數個數
是以,我們應該定義這樣的一個執行個體方法:
OK,方法有了,你馬上就撸柚子準備實作了……停停停!我們要先寫單元測試。事情沒有你想象的那麼簡單,你要不信這個邪呢,我們後面還有作業,你可以直接試一試。
趁我們現在頭腦還清醒的時候,先想想測試的事。
首先我們要添加一個InsertAfterTest()方法,注意不要忘記在這個方法上添加[Test]特性,否則它不會被當做測試方法被NUnit調用運作:
為了測試,我們是不是首先要建構一個連結清單?然後才能往裡面插入啊,怎麼建構呢?隻有手工,在InsertAfterTest()中添加:
然後,再建立一個inserted節點,将其插入節點2之後:
OK,完成插入過後,應該是怎麼樣的一個情形?我們用代碼表示:
跑一跑測試,當然是跑不過的,因為InsertAfterTest()根本沒實作嘛。
好了,讓我們去實作InsertAfterTest()方法吧……停停停!别慌,測試是為了找到bug,什麼情況容易出bug,
極端情況
下就容易出bug啊!什麼是極端情況,想一想,有了:如果是在連結清單的尾部插入呢?是不是也應該測一測?
這時候我們有兩種選擇:
繼續在InsertAfterTest()中添加Assert行
新開一個方法InsertAfterTailTest()
我們就用第2種吧,看上去更規範更清晰一些。
這時候就會有一個問題,是不是要在InsertAfterTailTest()中把建構連結清單的代碼再寫一遍?你說不用,我可以複制粘貼!你真是個機靈鬼,記住:程式員憎恨ctrl+c加ctrl+v。
我們的單元測試類還是一個類,這個類裡面一樣可以有各種類成員,比如字段方法屬性等等。既然這些連結清單節點可以反複使用,我們為什麼不把他們定義為字段呢?再回想一下我們的[Setup]特性,它是會在每一個測試方法被調用前運作一次的。我們可以在這裡面完成節點的連結:
于是,InsertAfterTailTest()裡面的代碼就非常簡單了:
(InsertAfterTest()方法一樣按此精簡,此處略過)
那還有沒有其他“極端情況”?有,但飛哥不告訴你,接下來做作業的時候自己去想!^_^
終于,我們可以實作InsertAfter()并運作單元測試了……
示範:稍有不慎就無法通過測試,按下葫蘆浮起瓢:
這裡有一個小技巧:先專注于通過最正常的InsertAfterTest(),然後再想辦法同時通過InsertAfterTest()和InsertAfterTailTest()。
好了,一路改,千辛萬苦通過了這個單元測試,如下所示:
然後,你看這if...else裡面好像有一些重複代碼,比如:
這不是重複代碼麼?可不可以提出來?進行
其實飛哥之前給同學們進行作業點評。如果你的代碼沒有錯誤,但我還是給你改了,這就是在做重構:
在不改變代碼運作結果的前提下,優化代碼品質(安全、性能和可讀性)。
不知道大家有沒有聽說過一句話:
好代碼都是改出來的。
很少有人一次性的寫出非常完美的代碼——尤其是代碼會随着業務邏輯不斷變化的時候,你根本就不可能一次性的完成代碼,一定是不斷的修修補補。但是,實際開發中,你會發現“修修補補”就會把代碼慢慢地變成了“屎山”。最有越改越爛,哪有什麼“千錘百煉”?!
可以想象的一個場景:你滿懷激情地正準備要重構,被你項目經理一把撲倒在地,“小子,不要命啦!?”
為什麼?
你試試重構一下我們剛才的代碼,按照我們想的:
看起來代碼是整潔多了!然而,就在你沾沾自喜的時候,跑一下單元測試試試?
這就是為什麼不能重構的原因:
沒有單元測試做保證,你的重構風險太大!
其實添加新的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相關的,有那麼一點點意思,先發到園子裡試試水,如果覺得可以的話,别忘記點個贊。以後有好的,我也都發到園子裡來,^_^
點選連結加入群聊【一起幫·源棧·星光計劃】:QQ群:222132940