通過在編寫代碼之前編寫測試使一切簡單
最近 50 年來,測試一直被視為項目結(jié)束時要做的事。當然,可以在項目進行之中結(jié)合測試,測試通常并不是在 所有編碼工作結(jié)束后才開始,而是一般在稍后階段進行測試。然而,XP 的提倡者建議完全逆轉(zhuǎn)這個模型。作為一名程序員,應該在編寫代碼 之前編寫測試,然后只編寫足以讓測試通過的代碼即可。這樣做將有助于使您的系統(tǒng)盡可能的簡單。
XP 涉及兩種測試: 程序員測試和 客戶測試。測試驅(qū)動的編程(也稱為 測試為先編程)最常指第一種測試,至少我使用這個術語時是這樣。測試驅(qū)動的編程是讓 程序員測試(即單元測試 ― 重申一下,只是換用一個術語)決定您所編寫的代碼。這意味著您必須在編寫代碼之前進行測試。測試指出您 需要編寫的代碼,從而也 決定了您要編寫的代碼。您只需編寫足夠通過測試的代碼即可 ― 不用多,也不用少。XP 規(guī)則很簡單:如果不進行程序員測試,則您不知道要編寫什么代碼,所以您不會去編寫任何代碼。
整個理論很棒,但如何 先編寫測試呢?首先,我推薦您閱讀 Kent Beck 撰寫的 Test-Driven Development: By Example(請參閱
參考資料)一書,里面列舉了一個詳盡的貫穿于整本書的示例。該書不僅講述了如何編寫測試和讓這些測試來驅(qū)動您的代碼的原理,而且還講述了測試驅(qū)動的編程為什么是一種好的編程方法。這里我將舉一個簡單的例子,讓您體會一下我正在講什么。
我喜歡用“測試驅(qū)動”這個術語,而不喜歡用“先測試”這個術語,因為先測試強調(diào)了在編寫代碼前編寫程序員測試這個原理。這些原理很重要,但真正的力量在于測試驅(qū)動所隱含的想法和編程習慣的改變。“測試驅(qū)動的編程”這一更貼切的術語包含兩種測試,它指出 XP 團隊強調(diào)讓測試驅(qū)動他們所要做的一切這一方式。
假定我正在編寫包含 Person 對象的系統(tǒng)。我希望在我問每個 Person 時,他/她能告訴我其年齡(作為整數(shù))。即使我還沒有編寫一丁點代碼,但也該編寫測試了。“什么?”,您可能會說,“我甚至不知道在測試什么,怎么編寫測試?”答案很簡單,您 的確知道您在測試什么,只是不 知道您所了解的內(nèi)容,因為您不習慣按這樣的方式進行思考。這就是我的意思。
您確實還沒有任何代碼,但您腦海中應有 Person 對象的雛形。 Person 對象上應該有一個方法,該方法可以用整數(shù)形式返回年齡。因為我最常使用 Java 語言,所以我用 JUnit 來編寫程序員測試。清單 1 顯示了我為 Person 對象編寫的 JUnit 測試:
package com.roywmiller.testexample; import junit.framework.TestCase; public class TC_Person extends TestCase { protected Person person; public TC_Person(String name) { super(name); } protected void setUp() throws Exception { person = new Person(); } public void testGetAge() { int actual = person.getAge(); assertEquals(0, actual); } protected void tearDown() throws Exception { } }
首先,讓我向那些不熟悉 JUnit 的人講述一些淺顯的原理。 TestCase 類是您將最常使用的類。您只是寫了一個測試類(在該示例是 TC_Person ),它是 TestCase 的子類。(注:在 JUnit 3.8.1 中,可以有也可以沒有接受 String 的構(gòu)造函數(shù),但由于我?guī)缀跛械?Java 開發(fā)都在 Eclipse IDE(請參閱
參考資料)中完成,Eclipse IDE 免費向我提供了這個構(gòu)造函數(shù),所以我就把它保留在這里了。)一旦創(chuàng)建好測試類之后,測試方法中要有實際的動作。這些方法都恰如其分地用前綴 test 開頭(它們必須是 public ,并且返回 void )。當運行測試時,JUnit:
內(nèi)省測試類,并執(zhí)行每個以“test”開頭的方法 在執(zhí)行每個測試方法之前執(zhí)行 setUp() 方法 在執(zhí)行每個測試方法之后執(zhí)行 tearDown() 方法
在該示例中, setUp() 方法中沒有太多要執(zhí)行的語句。它只是實例化 Person (我用這個方法是讓您覺得這個測試案例看上去很“完整”)。這意味著,如果這里有 20 個測試方法,則每個測試方法都以一個新的 Person 實例開始。 tearDown() 中不做任何事情,所以現(xiàn)在它是空的。值得強調(diào)的一點,您不需要 setUp() 或 tearDown() ;我通常直到編寫第二個或第三個測試方法,并確定了這些方法都共享某些公共的設置或銷毀活動時,才創(chuàng)建它們。
有了這些原理之后,要注意,我在測試方法中制訂了一些設計決策。我假定,可以構(gòu)造一個 person,并且“缺省” Person 會返回值為 0 的 age。還假定 Person 對象有 getAge() 方法。即使那些假定不會一直都成立,但目前它們還適用。可以說,這是一個簡單的測試,讓我說明測試驅(qū)動的編程。有了這些假定之后,實例化 Person (在 setUp() 中實例化 Person 只是為了展示如何使用 setUp() 方法),接著調(diào)用測試方法中正在測試的方法,然后調(diào)用其中一種“斷言(assert)”方法。斷言方法測試事情是否為 true。換句話說,這些方法針對某件事做出一個斷言,該斷言告訴 JUnit 驗證該事是否為 true。表 1 列出了斷言的類別:
斷言方法 描述
assertEquals 比較兩件事物是否相等(基本類型或?qū)ο螅?div style="height:15px;">
assertTrue 對布爾值求值,看它是否為 true
assertFalse 對布爾值求值,看它是否為 false
assertNull 檢查對象是否為 null
assertNotNull 檢查對象是否不為 null
assertSame 檢查兩個對象是否為同一實例
assertNotSame 檢查兩個對象是否不為同一實例
在這里,我檢查 Person 實例的 age 是否為 0,新 Person 對象的缺省值為 0。
當然,這個測試甚至不能編譯。
顯然,我還沒有 Person 類,所以運行該測試會出現(xiàn)問題 — JUnit 給出了一個紅條。如果可以運行,并通過測試,則會顯示一個綠條。您的目標總是設法得到一個綠條。別忘了,JUnit 的座佑銘是“得到綠條,使代碼干凈”(有時抱怨是難免的)。
沒問題。我將創(chuàng)建 Person 類,如清單 2 所示:
package com.roywmiller.testexample; public class Person { public int getAge() { return 0; } }
現(xiàn)在,當運行這個測試時,測試通過,應該可以看到一個綠條。我必須從 getAge() 返回值,否則不會編譯它。這里碰巧 0 最方便,0 被認為是新的 Person 實例的缺省值,所以工作正常。再次重申,我只編寫了通過測試所需的代碼。
能夠使 Person 具有缺省的年齡值固然很好,但這對我的系統(tǒng)不會有太大幫助。 Person 需要比這更智能些。我真正所需要的是, Person 擁有其生日,并能回答其當前的年齡。這意味著 Person 對象的年齡會隨時間的推移而增長。在進行編碼前,將 testGetAge 重命名為 testGetDefaultAge (清楚地表明,我正在測試缺省的年齡),并為這個測試案例編寫另一個測試方法,如清單 3 所示:
public void testGetAge() { GregorianCalendar calendar = new GregorianCalendar(1971, 3, 23); person.setBirthDate(calendar.getTime()); int actual = person.getAge(); assertEquals(31, actual); }
還不能編譯這個測試(您注意到了其中的模式嗎?),因為 Person 內(nèi)沒有 setBirthDate() 方法。在創(chuàng)建了這個方法之后, Person 將類似于清單 4 所示:
package com.roywmiller.testexample; import java.util.Date; public class Person { protected Date birthdate; public int getAge() { return 0; } public void setBirthDate(Date aBirthDate) { this.birthdate = aBirthDate; } }
Person 中的 getAge() 仍然沒有什么變化,所以測試失敗。
生成的 AssertionFailedError 告訴我結(jié)果不是 31 而是 0。這個失敗在預料之中,因為我沒有改變 getAge() 方法來做某些不同的事?,F(xiàn)在僅僅編寫足夠使測試通過的代碼(這里有兩個測試)。我必須允許年齡的缺省值為 0,但我必須計算出生于 1971 年 3 月 23 日的人的年齡。一些程序員(包括 Kent Beck)建議在這一點上盡可能簡單,譬如檢查 birthdate ,看它是否為 null ― 如果為 null,則返回 0,否則返回 31 ― 然后編寫另一個測試使計算更智能。一小步一小步地思考問題這種方法是很好的技術,我們要采用這種技術,當您想回到上面提到的基本規(guī)程來使自己擺脫調(diào)試慣例時,那是再好不過。但這里我想使該示例略微簡單些,所以我僅僅試圖通過按我所希望的方式,用 Calendar 計算年齡,使該測試通過。清單 5 顯示了 Person 中我所編寫的代碼:
package com.roywmiller.testexample; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; public class Person { protected Date birthdate; public int getAge() { if (birthdate == null) return 0; else { int yearToday = Calendar.getInstance().get(Calendar.YEAR); Calendar calendar = new GregorianCalendar(); calendar.setTime(birthdate); int birthYear =calendar.get(Calendar.YEAR); return yearToday - birthYear; } } public void setBirthDate(Date aBirthDate) { this.birthdate = aBirthDate; } }
當我運行測試時,我失敗了,預期的結(jié)果為 31,但實際結(jié)果為 32。怎么了?唔,我知道問題一定出在剛才所寫的代碼中,沒有進一步考慮下去。在檢查完 else 子句之后,我明白我只是根據(jù)年來計算年齡。這不對。我現(xiàn)在 31 歲,但這個月再過幾天我要 32 歲了(但我寫該代碼時,是 3 月份),我的算法造成錯誤的結(jié)果。所以需要重新考慮 getAge() 。我用清單 6 中的代碼段糾正了這個錯誤:
else { int yearToday = Calendar.getInstance().get(Calendar.YEAR); Calendar calendar = new GregorianCalendar(); calendar.setTime(birthdate); int birthYear = calendar.get(Calendar.YEAR); if (yearToday == birthYear) return yearToday - birthYear; else return yearToday - birthYear - 1; }
綠條!在 Person 類中有一些重復的代碼,但我把它留給稍后的重構(gòu)練習。歡迎替我清理該代碼。您可以有信心地做這件事,因為可以運行測試來證實您沒有破壞任何事物。
這個示例使您體會到了測試驅(qū)動的編程類似于什么。我只在每步編寫足夠讓測試通過的代碼。作為一種理論,這在思想傾向上是一種挑戰(zhàn)。您必須習慣這種思想,在編寫代碼 之前,可以并應該編寫測試。在通過所有測試之后,就完成了工作。
在先編寫測試時,必須習慣故意只看眼前。清單 6 中的示例是一個十分簡單的情形。即使最簡單的編程問題,在實際當中通常要更復雜。這種方法有助于將問題分解成更可管理的部分,但您最終仍可能遇到一些復雜的令人頭疼的問題。在那些情況下,必須使自己不要考慮太遠,不要假定它的“普適性”有多高,也不要假定這種方法能處理某些尚未遇到的情形。僅僅編寫測試,使它通過。您需要采取一些較小的步驟,然后編寫迫使您要采取更多步驟的測試。請記住,您正在測試代碼的存在性,如果您以較小的步驟來編寫代碼,那您就做對了。
也許您不認為先編寫測試是一個好主意。它看上去似乎很奇怪,或者也許似乎沒有必要。我常從富有經(jīng)驗的程序員那聽到第二種原因。這些程序員很聰明,他們具有許多經(jīng)驗,他們說不需要先編寫測試,因為他們知道自己在做什么。我能領會,但我懷疑他們存在一個隱式的假定:他們沒能領會先編寫測試。恕我難以茍同。事實上,我認為采用先編寫測試方法有三個原因:
學習 新問題 信心
先編寫測試 ― 到后面再執(zhí)行這些測試 ― 是較佳的學習方式。它使您能將精力集中在所編寫代碼的接口部分。在編寫測試時,您假設正在使用的類已經(jīng)存在,然后按照您希望在系統(tǒng)其余部分中使用的方式來使用該類。稍后,當您忘記如何使用該類時,可以查看測試,看一個非常具體的示例。這是學習的很好方式。
關于先編寫測試最有趣的事情之一是,它有助于發(fā)現(xiàn)新問題。您正在使創(chuàng)建之中的系統(tǒng)“成長”起來。如果您正在使用 XP,則沒有預先設計整個事情 ― 而是一邊開發(fā)一邊設計。在您先編寫測試并通過測試這個過程中,您正在讓代碼告訴您它想要做什么,以及會成為什么。如果僅僅著手編碼工作,則您完全按照您的設想來行事了。越晚做決定,則越有可能發(fā)現(xiàn)新問題和新動向,這些可使您的系統(tǒng)更完善。
但是,我所喜歡的先編寫測試的好處是讓這些測試在稍后執(zhí)行。在我先編寫測試時,我有許多奇異的邏輯。我可能沒有涵蓋代碼的方方面面,但會包括其中許多方面。在任何情況下,我會有一套測試,這套測試比我曾參與過的大多數(shù)沒采用 XP 的項目要更好。我可以按一個按鈕就運行這些測試。幾秒種之后,我就知道代碼是否按我告訴它應該怎樣的方式來運行。這種可回歸的工具是很有價值的。我團隊中的任何人(或任何地方的任何人)可以在任何時候更改代碼,甚至在代碼發(fā)布的前一天也可以更改,因為如果有任何問題,測試會立即告訴他們。作為一名程序員,這給予了我信心 ― 比大多數(shù)程序員具有更大的信心。
一如既往,我熱忱邀請您就以后的專欄文章提出您的反饋意見,這樣有助于促進這個專欄。關于 XP,您存在的最大問題是什么?您認為是完全愚蠢的、不明智的、非專業(yè)的還是不可能的?最讓您感到迷惑的做法是什么?請在本文的
論壇提出您的建議,或者就直接
給我發(fā)電子郵件。
許多沒有先編寫測試的程序員甚至不知道還可以使用這種方法。如果他們知道,也可能對如何使用它感到迷惑,或者他們可能想知道為什么要這樣做。即使他們知道如何去做,并認為它是一種好的想法,但許多人仍然沒有先編寫測試。
先編寫測試需要遵循一定的規(guī)程。作為一名程序員,我認為,對于我正在開發(fā)的工作,不編寫測試可能會更容易些。有時確實如此,但通常只會在短期內(nèi)是這樣。如果我經(jīng)常不寫測試,那么不久會有一堆代碼沒有經(jīng)過測試。當編寫下一個系統(tǒng)功能部件時,可能會出現(xiàn)不正常現(xiàn)象,問題出在哪里?沒有測試,我無法胸有成竹地回答這個問題。即使一切似乎都工作良好,但我不能確保過去在系統(tǒng)中沒有出現(xiàn)的問題在以后還不會出現(xiàn)。這種惡性循環(huán)就是為什么大多數(shù)程序員討厭測試人員告訴他們代碼出現(xiàn)問題的原因。在沒有測試的前提下,跟蹤錯誤造成了加班加點以及對工作的不滿意。
在我用那種方式向大多數(shù)程序員說明這種情況時,他們認為測試驅(qū)動的編程是一個不錯的想法 ― 這之后,他們?nèi)匀徊皇褂眠@種方法。在編寫代碼前編寫測試這種作法意味著,在測試運行并失敗之前,不會做工作中真正有趣的部分。不要掉入這個陷阱,否則您以后會付出很多。
在人們開始編寫測試時,總是會遇到這樣一些情形:他們說,“只是沒有辦法進行測試”。XP 社區(qū)的一些人可能毫不含糊地說,不寫測試就永遠別寫代碼。您應該努力嘗試這么做,但以我個人的經(jīng)驗,有時我發(fā)現(xiàn)有些地方我也不能這么做。如果您發(fā)現(xiàn)自己處在這種情形,您應該放棄嗎?在一定程度上可以。我認為您可以做兩件事:
在編寫測試前編寫代碼 在很少情況下,根本不編寫測試,放到后面編寫
如果發(fā)現(xiàn)在嘗試先編寫測試之后,仍不能先編寫測試,那么回到測試中來。我仍然希望進行測試,這樣我可以從完整的回歸套件中獲得信心,但我必須先編寫一些代碼,然后編寫測試。有時我編寫了一點代碼,然后編寫一點測試,這樣兩者可以一起并進。在少數(shù)情況下,我恰好根本想不出如何編寫測試。在出現(xiàn)這種情形并且我的結(jié)對搭檔也想不出法子時,我就問問其他人(例如,另一對搭檔),看看他們是否什么聰明點的主意。有時這很管用。但還有一些時候,整個團隊都陷入了困境。在那些情況下,必須選擇可行性。我可能暫停編碼,陷入困境,或者在沒有測試的情形下,編寫一些代碼,到稍后再編寫測試。也許代碼中出現(xiàn)的第一個錯誤會使測試什么以及如何測試變得更為明晰。這些是可行的規(guī)則。
在這世上,幾乎每種語言都有一個 xUnit 庫。對于 Java 平臺,則由 JUnit 擔當此任。我個人使用 Eclipse IDE(請參閱
參考資料),它極好地集成了 JUnit。Eclipse 是開放源碼,有它自己的測試套件,您可以使用它。使用這個合適工具,您可以編寫大量好的測試。但有時最好有一些其它幫助。幸運的是,可以利用一些編碼技術來更方便地進行測試,甚至可以測試 看上去不可測試的事物??梢允褂玫囊恍┘夹g包括 ObjectMother模式、 模仿對象(Mock Object)和 偽(Sham)對象。
ObjectMother 模式實際是 Gang of Four Abstract Factory 模式(請參閱
參考資料)的實現(xiàn),它告訴您創(chuàng)建一個工廠對象來給出需要測試對象的實例。例如,假定您正在構(gòu)建一個處理客戶預訂講座的系統(tǒng)。您可能構(gòu)建一個 ObjectMother 對象,使 Seminar 對象具有不同種特征,您可以用這些特征來測試某些情況。在 Java 語言中,您可能創(chuàng)建 TF_Seminar 對象,它有幾個靜態(tài)工廠方法 ― 也許稱為 createSomething 或 newSomething 。用您對正在創(chuàng)建事物的一些描述代替“something”,譬如 newFullyLoaded ,用它來創(chuàng)建具有所有數(shù)據(jù)成員、并且這些數(shù)據(jù)成員都已填有已知數(shù)據(jù)的 Seminar 。這樣做使得測試數(shù)據(jù)放在一個地方,從而使代碼更干凈,更容易重構(gòu)。在代碼中,每當需要完全裝入的 Seminar 來進行測試時,可以象清單 7 那樣做:
Seminar seminar = TF_Seminar.newFullyLoaded; seminar.doSomething(); assertEquals("expectedValue", seminar.getValue());
模仿對象使您可以為測試而模仿對象(請參閱
參考資料)。給模仿對象一個接口,您希望實際組件具備這個接口,然后使用模仿對象,直到實際組件成形。但模仿對象不僅僅只是還未存在的組件的存根。可以評估代碼如何與模仿對象交互(譬如,驗證調(diào)用了某個方法多少次以及檢查狀態(tài)等)。最近模仿對象得到了大力推廣,但我認為它們被濫用了,它們太“重”以至于不切實際。
有時我所希望的是一個偽對象,它實現(xiàn)了與真實對象相同的接口,可以回答關于我在測試中如何與它交互這樣一些特定問題。這就是偽對象 ― 一種用來偽裝測試中對象的輕量型方式。偽對象可以是您所需要的任何對象。它是我曾使用過的最全面靈活的工具、模式和思考方式,我推薦您使用它。例如,我在前面創(chuàng)建的 Person 對象的偽對象類似于清單 8 中的樣子:
protected class ShamPerson extends Person { protected boolean getAgeWasCalled; public int getAge() { getAgeWasCalled = true; return 25; } }
如果可行,我總是試圖采用正在測試類的偽類(在這里是 ShamPerson )作為測試中的內(nèi)部類。通過這樣做,從而向不必需要偽類的其它事物隱藏了該偽類。
一旦有了偽對象,我就可以在不直接測試 Person ,而是測試其它代碼如何與 Person 實例進行交互的測試中用到它。我可以實例化 ShamPerson ,然后與它交互,然后斷言 getAgeWasCalled 為 true。
在編寫代碼前編寫測試極大地改變了我作為程序員的生活,它同樣也可以改變您的生活。我的代碼始終比先編寫測試之前所寫的代碼更簡單、更干凈以及更健壯。只要記住這條規(guī)程 ― 在編寫代碼之前考慮如何測試代碼 ― 就可以使代碼變得更好。如果每個軟件開發(fā)團隊不采用其它 XP 做法,并且只是先編寫測試,則軟件開發(fā)世界將會令人驚異地變得更好。采用這一點做法,任何程序員都可以先編寫測試。這些工具(JUnit 和 Eclipse 等)是免費的,只等您去實踐它。我已經(jīng)看到投資得到了及時的回報,我相信您也會這樣做的。