重構遺留程式碼(4):第一個單元測試

EluQ發表於2014-11-25

舊程式碼,醜陋的程式碼,複雜的程式碼,義大利麵條似的程式碼,鬼話廢話……就是四個字:遺留程式碼。這是一個系列文章,將有助於你處理並解決它。

重構一段完整的遺留程式碼的關鍵時機之一是,當我們開始抽出它的小片段,並且開始針對那些片段開始寫的單元測試的時候。但這會相當難,特別是當你這麼一段有缺失的程式碼時,是很難編譯或者執行的。我們無法安全地在我們還不太瞭解程式碼上做大規模的調查,並且只有一個金牌大師測試程式讓我們完全得打破這段程式碼。幸運的是,有些技術可以幫助我們。

 

什麼是單元測試?

縱觀自動化測試的歷史,過去的二十年左右,單元測試這個術語通過許多方式定義。最初,它是關於測試中程式碼執行的範圍的。單元測試時一個測試程式,用來測試特定程式語言可能的最小單元的。

在這個意義上,對於我們的PHP程式碼,單元測試是測試執行單個的功能或方法。當我們在物件導向程式設計風格下,其功能是組織在類中的。所有與單一類相關的測試通常稱為測試用例。

還有大約25個其它的對於單元測試的定義,所以我們不每一個都看了。而這些定義是完全不同的,但它們都有兩個共同點。這將使我們找到可能最公認的定義。

單元測試是毫秒級執行並且對程式碼塊隔離測試的測試程式。

我們必須注意兩個關鍵詞的定義:毫秒級 – 我們的測試程式必須快速執行,非常快;隔離 – 我們必須儘可能地隔離測試程式碼。這兩個關鍵詞需同時滿足,因為為了使測試程式更快我們必須縮小其範圍。資料庫,網路通訊,使用者介面,以這種方式測試的話,它們只是太慢了。我們需要找到並且隔離足夠小的程式碼塊,以便我們能夠大約在毫秒級編譯(如果需要)並且執行程式碼,換句話說,不到10毫秒,因為這真是百分之一秒。我們的測試程式碼將在程式碼的純執行時間上增加一個輕微的開銷,但它可忽略不計。

 

識別需要進行單元測試的程式碼

尋找隔離的方法

如果程式碼的結構允許,建議首先編寫測試程式碼,無論我們實際能測試的程式是什麼樣。這將幫助我們開始建立覆蓋率並且也會迫使我們專注並理解小塊程式碼。記住,我們是在重構,我們不想改變程式碼行為。事實上,如果可能在這最初的階段我們完全不想改變我們的生產程式碼。

我們需要分析三個檔案,看看什麼我們能測什麼不能。

GameRunner.php已經基本沒邏輯。我們建立它僅作為一個委託。我們可以測試它嗎?當然可以。我們應該測試它嗎?不,我們不應該。即使有些方法技術上來說可以被測試,但如果它們沒有邏輯我們可能不希望測試它們。

RunnerFunctions.php是不同的情況。它有兩個方法。run()是一個大方法,負責系統整體執行。這不是我們能容易測試的。並且它也沒有返回值,它僅僅輸出到螢幕,所以我們需要捕捉輸出並且比較字串。對於單元測試來說它不是很典型。另一方面,isCurrentAnswerCorrect()基於一些條件返回簡單的true或false。我們可以測試它嗎?

我們已經理解了這段程式碼產生一個隨機數並且將它和wrongAnswerId進行比較。

步驟1 – 開啟GoldenMasterTest.php並且標記所有的測試為跳過。我們暫時不想執行它們。當我們建立單元測試的時候,我們會更少地執行金牌大師程式。當我們寫新的測試程式並且我們沒有修改產品程式碼的時候,快速反饋是更重要的。

步驟2 – 在我們的Test目錄下建立一個新的測試程式RunnerFuntionsTest.php,和GoldenMasterTest.php放在一起。現在考慮下你能夠寫出的盡簡單的測試程式碼。讓它最起碼能執行起來的程式碼什麼樣?好吧,像這樣:

我們引入RunnerFunctions.php檔案,以便我們測試時它被引入而不產生錯誤。餘下的程式碼是純模板,只是一個類骨架和一個空的測試方法。但,現在做什麼?我們接下來做什麼?你知道我們怎樣讓rand()方法返回我們想要的?我還不知道。那麼讓我們現在來調查它是怎麼工作的。

我們知道如何給隨機生成器種子,那麼我們試著用一些數字給它播種,會有效嗎?我們可以在測試程式中寫一些程式碼來弄明白它是如何工作的。

我們也知道我們的問題ID是介於0和9之間的。這會產生下面的輸出。

好吧,那看起來並不明顯。事實上,我看沒什麼邏輯,我們怎麼決定rand()方法將產生的值。我們需要修改產品程式碼,以便能夠插入一些我們需要的值。

 

依懶性和依賴注入

當許多人談論“依賴性”的時候,他們考慮的是類之間的關聯。這是最普遍的例項,特別是物件導向程式設計中。那麼如果我們推廣這個術語一點。忘掉類,忘掉物件,只專注於“依賴性”的含義。我們的rand(min,max)方法依賴於什麼?它依賴兩個值。一個min和一個max。

我們可以通過這兩個引數來控制rand()方法嗎?如果min和max相同,可以預見的rand()不將返回相同的數字嗎?讓我們來看看。

如果我們是對的,那麼每一行將以預期的方式轉儲從0到4的數字。

對我來說這看起來是可預見的。通過給rand()方法引數min和max傳遞相同的數字,我們可以確定產生我們期待的數字。現在,對於我們的方法我們怎麼做到這點呢?它沒引數啊!

對依賴注入方法來說可能最普遍的做法是使用帶預設值的引數。這將保留方法的當前功能,但當我們測試它的時候允許我們控制它的流程。

按這種方式修改isCurrentAnswerCorrect()方法將同時維持它目前的行為並允許我們測試它。現在你可以使你的金牌大師再次有效並執行它了。產品程式碼已經改變了,我們需要確保沒破壞它。

至於isCurrentAnswerCorrect()現在看起來,測試它只是送入十個值給rand()方法可能返回的每一個數字。

該測試功能是基於我們的測試程式的每一行來構建的。既然我們的測試程式很快了,我們可以幾乎連續執行它們。事實上有一旦檔案改變就執行測試的工具,我已經聽說有的連續執行他們的測試程式的開發者僅僅在每個命令的最後瞥一眼測試狀態列。至於你的程式,你知道會發生什麼,如果測試結果沒有變綠而你認為它應該的時候,你就做錯了什麼。它們的反饋顯得那麼緊湊,幾乎可以肯定在他們寫的最後一行或命令中的某些方面出錯了。

雖然聽起來有點極限測試驅動開發的意味,我想當你開發演算法的時候是非常有用的。我個人喜歡通過點選快捷方式執行我的測試程式,一個快捷鍵。正如測試幫助開發我的程式,我執行測試程式的快捷方式是F1。

讓我們回到我們的業務。該測試,有十個斷言,執行了66毫秒,大概6.6毫秒一個斷言。每個斷言執行一段我們的程式碼。這看起來正如我們在這篇教程開始定義的單元測試。

你注意到對數字7使用的assertFlase()了嗎?我打賭你們中的一半忽略了它。它深深埋在另一個斷言的分支中。很難發現。我認為它值得擁有自己的測試程式,所以我們做一個明確的單一錯誤答案的案例。

 

重構測試程式

正如我們在重構的任務,使程式碼更優秀,更容易理解,我們一定不能忘記我們的測試程式。它們和我們的產品程式碼一樣重要。我們也需要保持測試程式乾淨並易於理解。一旦我們發現測試程式有錯,在測試通過時我們就需要重構測試程式並且我們應該這麼做。這麼做,產品程式碼就能驗證我們的測試程式。如果測試程式變綠,我們重構它,當它變紅時我們就打破測試。我們可以只撤銷幾步並再試一次。

我們可以抽取正確的答案數字到陣列中並使用它生成正確的答案。

這會通過,但也會引入一些邏輯。也許我們可以將它抽到自定義的斷言中。這對於如此簡單的一個測試可能有點極端了,但這是個理解這種觀念的好機會。

現在,它在兩方面幫助我們。首先,我們將迴圈驗證陣列元素的邏輯放到一個私有方法中。正如我們通常將私有方法放在類最後,淡出視線,給公共方法中更高層次的邏輯讓道,我們設法將測試程式抽象。在測試方法中我們不關心答案是如何驗證正確性的。我們關心代表正確答案的ID。第二點得益是將實現從功能準備中分離出來。保持測試中的正確答案ID幫助我們從需要測試的前提中分離了實現的細節。

 

對產品程式碼依賴做測試

我們任何一個人會犯的最普遍的錯誤是當寫測試程式時去重複產品程式碼。通常,對於一些值或者常量,這是一個既是程式碼重複又是隱藏依賴的案例。我們的案例中,依賴是基於代表錯誤答案的回答ID。

但如何證明這個依賴?起初它似乎僅僅是對一個單一值的簡單重複。要回答你的困境問你自己這個問題:“如果我決定改變錯誤答案的ID,我的測試程式會失敗嗎?”。當然答案是不會。改變產品程式碼中一個簡單的常量並不會影響其行為,或者邏輯。因此,測試程式應該不會失敗。

這聽起來棒極了!但怎麼做到呢?好了,最簡單的方式是將所需的變數公開為公共類變數,最好是靜態的或常量。我們的案例中,因為我們沒有類,我們只將其作為全域性變數或常量。

首先修改RunnerFunctions.php檔案以便isCurrentAnswerCorrect()方法使用常量而不用本地變數。然後執行你的單元測試。這確保我們對產品程式碼的改變不破壞任何東西。現在,是時候測試了。

修改testItCanFindWrongAnswer()方法使用相同的常量。因為檔案RunnerFunctions.php在測試程式開始處引用了,宣告的常量將能訪問測試程式。

 

重構測試程式(再一次)

現在,我們依賴testItCanFindWrongAnswer()方法的WRONG_ANSWER_ID,難道我們不該重構測試程式以便testItCanFindCorrectAnswer()方法也依賴同樣的常量嗎?好吧我們應該這麼做。這不僅會使我們的測試程式更易於理解,也將使它更健壯。是的,因為如果我們選擇已經定義在測試程式中的正確答案列表中的錯誤答案ID,這樣的案例會使測試失敗但產品程式碼仍然是正確的。

當在測試方法中有正確答案的數字,這本身在某種程度上就是個好主意,當我們修改測試程式越來越多地依賴產品程式碼提供的值時,我們也希望隱藏關於數字的細節。第一步是提供一個提取方法的重構,將其放入它自己的方法中。

我們顯著修改了getGoodAnswerIDs()方法。首先我們使用range()方法生成列表,取代手輸所有可能的ID。然後,我們從包含WRONG_ANSWER_ID的陣列中將其剔除。現在正確答案ID的列表仍然獨立於錯誤答案ID設定的值。但這麼做足夠嗎?minimum和maximum ID

怎麼樣?我們不能也將它們以相似的方式抽出嗎?好吧,讓我們看下。

這看起來很不錯。常量僅僅作為了方法isCurrentAnswerCorrect()引數的預設值。它仍然允許我們在測試時插入需要的值,並且也使得那些變數的意義很明確。作為一個很好的副作用,檔案開頭的一小塊常量開始高亮我們RunnerFunctions.php中的用到的關鍵值了。漂亮!

只是不要忘了重新使金牌大師測試程式testOutputMatchesGoldenMaster()方法啟用。我們引入的常量僅僅在金牌大師測試程式中使用。我們的單元測試實際上始終引用這些值。

現在我們需要更新單元測試來使用這些常量了。

它很簡單容易。我們僅需要修改range()方法的引數。

最後一步對於測試程式我們能做的,是去清理我們留在testItCanFindCorrectAnswer()方法後面的殘局。

我們可以看到這段程式碼的兩個主要問題。第一個是前後矛盾的命名。我們曾命名答案correct,然後我們命名其為good。我們必須在二者之間決定一個。Correct從語法上來看更合適。正如correct是wrong的反義詞,而good的反義詞是bad。

我們基於上面所述的原因給私有方法重新命名了。但這還不夠。我們需要解決另一個問題。我們定義了一個私有方法的返回值給一個變數只是在下一行就用了相同的變數。而這是這個變數唯一的使用的地方。我們的案例中,該變數保留是因為它提供了數字陣列含義額外的宣告。它有自己的用途和作用域。但既然我們有了一個幾乎相同名字的方法,表達相同的意思,變數失去了它的效用。這就是一個沒必要的分配。

我們可以看到內聯變數重構移除了變數並且直接呼叫方法,取代了在下一行使用該變數。

現在,在這裡真正酷的事是我們開始於僅僅兩行並不清晰的程式碼,並且這個程式碼還被程式碼重複和隱藏依賴汙染了。經過了幾個步驟的變化之後,我們也以兩行程式碼結束,但我們打破了數字型的ID數字的依懶性。這是不是很酷還是什麼?

中斷執行

我們與RunnerFunctions.php的事完了嗎?好了,如果我看到if()那表明有邏輯處理。如果有邏輯處理意味著需要單元測試來驗證它。我們在run()方法的do-while()迴圈中有一個if()。是時候用我們IDE的重構工具抽出一個方法並且測試它了。

但我們應該抽出那一塊程式碼呢?乍看抽出條件表示式似乎是個好主意。這將得到如下程式碼。

這看起來很合適,它只是通過我們的IDE選擇合適的選單專案生成的,還有個問題困擾著我。物件aGame在do-while迴圈和抽出方法中都用到了。這樣處理怎麼樣?

這種方案將aGame物件從迴圈中移除了。但它也帶來了其他型別的問題。我們引數的數量增加了。現在我們需要傳入$dice。而引數的絕對數量,2個,足夠少而不至於引起任何問題,我們也必須思考下這些引數怎麼在方法本身中使用。$dice只在roll()方法被呼叫給aGame賦值的時候使用。而roll()方法在Game類中有著重要的意義,它不是能決定我們是否勝出或者失敗的方法。通過分析Game的程式碼,我們知道勝出者狀態為真只能通過呼叫wasCorrectAnswered()方法。這很奇怪,並且它高亮了Game類中一些嚴重的命名問題,而這我們將在即將到來的一課中討論。

基於以上所有觀察,可能更好的是使用我們抽出方法的第一個版本。

我們可以想信IDE,僅通過檢視程式碼我們可以相當肯定沒有什麼被破壞了。如果你感到不確定,只要執行你的金牌大師測試程式。現在讓我們把重點放在為這個漂亮的方法建立一些測試程式吧。

我想出了這個名字,通過傳送我想測試的到測試方法名中。根據測試程式應該測試的行為而不是它們會做什麼來命名你的測試方法是很重要的。這將幫助從現在起的六個月之後的其他人或者你自己,去理解這一小段程式碼真正應該做什麼。

但我們有個問題。我們的測試方法需要一個物件。我們需要像這樣執行它:

我們需要一個Game型別的$aGame物件。但我們在做單元測試,並不想要使用真實的,複雜的和難於理解的Game類。這將把我們指向一個新篇章,我們將在另一個教程中談到的測試:仿製,存根和作偽。這就是通過使用預定義方式中其他物件的行為來建立和測試物件的所有技術。而使用框架或甚至是PHPUnit內建的能力都會有所幫助,就我們目前的知識來說,對於我們的非常簡單測試我們可以做許多人忘記了的事。

我們可以在測試檔案中只建立一個和Game類相似的類,並且只定義兩個我們感興趣的方法。很簡單。

這使得我們的測試程式通過,並且我們仍然在毫秒區。注意兩個跳過的測試是金牌大師中的測試。

即使我們必須將我們的類命名得和Game不一樣,因為我們不能兩次宣告同一個類,但程式碼是十分簡單的。我們僅僅定義了兩個我們感興趣的方法。下一步就是真正返回些什麼並且測試它。但這也許比我們期望的更困難些,因為下面這行程式碼:

我們的方法呼叫無引數的isCurrentAnswerCorrect()方法。這對我們來說不好。我們無法控制它的輸出。它將只生產隨機數。在我們能繼續之前需要重構我們的程式碼。我們需要將對這個方法的呼叫放入迴圈,並且將它的結果作為引數傳遞給getNotWinner()方法。這將允許我們控制上面if表示式的輸出結果,從而控制我們的程式碼向下執行的路徑。對於我們的第一個測試,我們需要它進入if並呼叫wasCorrectlyAnswerd()方法。

現在我們有了控制,所有的依賴都被打破了。是時候測試了。

這是個通過測試,相當不錯。當然我們從過載的方法返回了true。

我們也需要通過if()來測試另一條分支。

這次我們只測false的案例,所以我們更容易區分兩種案例。

而我們隊FakeGame做了相應的修改。

 

最後的清理

重構抽出的方法

我們快做完了。抱歉這篇教程這麼長,我希望你喜歡它還有別睡著了。這是對RunnerFunctions.php檔案和它的測試程式的總結前最後的變更了。

我們的方法中有一些非必須的變數,我們需要清理它。我們的單元測試將使得這次變更很安全。

我們使用了相同的內聯變數重構,並導致了變數的消失。測試仍然是通過的,並且所有的單元測試一起仍然在100毫秒以下。我說這相當不錯啊。

重構測試程式(再次,再次)

是的,是的,我們還可以讓我們的測試程式更好一點。既然我們只有幾行程式碼,我們的重構將是簡單的。問題是下面這段程式碼。

我們在每個方法中重複呼叫了new FakeGame()。是時候抽出方法了。

現在,這使得$aGame變數十分沒用了。是時候內聯變數了。

這使得我們的程式碼更短同時也更具表達力。當我們讀取一個斷言時它讀起來像散文。當我們通過提供的正確答案使用假類試圖得到非勝出者時,斷言我們得到true。我仍不喜歡的是我們使用了相同的變數並且依賴測試將其賦值為true或者false。我想還應該有更具表達力的方式改變它。

哇哦!我們的測試程式變成單行的了,對於我們測試的它們也很具有表達力。所有的細節都隱藏在私有方法中,在測試程式最後。99%的情況下你都不會關心它們的實現,而當你需要檢視的時候,只要簡單的按住CTRL的同時在方法名上點選一下,IDE就會跳轉到實現了。

 

回到產品程式碼

如果我們看下迴圈,我們可以看到,有一個變數我們一眨眼的功夫就能夠除掉。

那將變成這樣:

再見了$notAWinner變數。但我們的方法名好可怕。我們知道我們總是喜歡積極的命名和行為,當需要有條件時使它無效。這個命名怎麼樣?

但用那個名字,我們需要在while()中使它無效,並且也要改變它的行為。我們開始修改測試程式。

但用那個名字,我們需要在while()中使它無效,並且也要改變它的行為。我們首先修改測試程式。

事實上只修改我們的假game類更好。用新的方法名使得測試程式真正可讀。

使測試程式通過

當然現在程式是失敗的。我們必須修改方法的實現。

 

修改金牌大師

單元測試通過了,但執行我們的金牌大師將失敗。我們需要使while表示式中的登入無效。

 

完成了!

現在,這使得金牌大師再次通過,我們的do-while讀起來也像寫的不錯的散文了。現在真的是停止的時候了。感謝你的閱讀。

相關文章