Test-first 編程是自面向對象編程以來最有效的編碼方式,但它假定您從一個空白屏幕開始編程。當代碼已經存在時,您應該怎么做呢?使用一個流行的開放源碼的 java™ 工具作為例子,作者 Elliotte Rusty Harold 向您展示了如何為從未測試過的遺留代碼開發測試套件。
/* I have no idea how this works but it seems to.  Whatever you     do, don't toUCh this function, and don't break this code!    (雖然我不知道這段代碼起什么作用,但是看上去它似乎是有用的。無論您做什么,一定不要碰這個函數,不要破壞這段代碼!)*/   如果您曾經遇到過帶有此類注釋的代碼,這種情況并不少見。因為沒有人了解這些系統,所以有時候就使用規則約束禁止進入整個系統;但是仍然需要對這些系統進行維護。即使是一個已經完全沒有 bug 的系統(又有哪個系統能夠完全沒有 bug?),外部環境的改變也會使代碼的改變成為必要。Y2K 營業額是一個最大且最明顯的例子。歐元的引入對于某些金融系統來說相當于造成外傷。Sarbanes-Oxley 引入了新的以前不存在的報告要求,而且為了支持這些新規則,必須對遺留軟件進行翻新。這個世界不是靜態的,所以軟件也不能是靜態的,它必須向前發展否則就會被代替。
好消息是,測試驅動開發不僅僅適合于新代碼。即使是程序員維護老代碼時也可以利用它編寫、運行以及通過測試。對于已經在生產中的遺留系統,測試確實更加 重要。只有通過測試,您才能確信您對于系統中的某一部分所做的改變不會中斷其他地方的另外一部分。當然,您可能沒有時間或者經費為一個規模龐大的代碼基礎達到 100% 的測試覆蓋率,但是即使是并不完美的覆蓋率也能減少失敗的風險,加速開發并且產生更加健壯的代碼。
本文使用 jEdit 做為例子,向您展示如何為從未測試過的遺留代碼開發一個單元測試套件。jEdit 是一個流行的開放源碼的文本編輯器,它完全沒有任何測試套件!但是我將馬上開始對其進行修改。在本文中,我著手開發一個測試套件,其目的是為了使將來 jEdit 的開發更加多產、高效并且有趣。
第一次測試
中國有句老話,千里之行始于足下。遺留代碼的測試套件首先開始于一個單獨的測試。重點是做什么和從何做起。不要掉入相信因為不能夠測試每行代碼所以就不能夠測試任何東西的陷阱。只管打開您的 IDE 并且開始編寫測試。使用 JUnit(或者 NUnit,或者 CppUnit,或者任何您喜歡的框架)和一個一般的 IDE,您通常就能夠在 20 分鐘以內編寫出第一個測試。編寫測試要比編寫模型代碼簡單得多。測試很小并且具有獨立的代碼塊。它們不需要很多配置、思考和理解。您不需要 “專業知識” 來艱難地編寫出高質量的測試。
測試套件需要做的第一件事情是直接到達方法的中心。尋找您能夠做的最大范圍、最全面的測試。對于一個獨立的應用程序,可能是 main() 方法。例如,這是我的第一個 jEdit 測試用例。它所做的就是運行應用程序的 main() 方法并且檢驗它是否在屏幕上輸出了正確的窗口: 
import java.awt.Frame;import junit.framework.TestCase;public class MainTest extends TestCase {    public void testMain() {        org.gjt.sp.jedit.jEdit.main(new String[0]);        // make sure there's a window on the screen        Frame[] allFrames = Frame.getFrames();        for (int i = 0; i < allFrames.length; i++) {           Frame f = allFrames[i];           if (f.isFocused()) {              assertTrue(f instanceof org.gjt.sp.jedit.View);           }        }    }}第一個測試的目的不是在邊界條件上費力,也不是為了查看解決了什么問題。第一個測試是一個發煙試驗,目的是為了對于什么可能是錯誤的給您一個清晰的概念。即使最基本的測試也不能揭示出構造系統、運行時環境、已安裝的軟件以及對每件事情進行本質上的破壞的其他主要問題中存在的問題。我的第一個測試用例確實能夠準確地發現 jEdit 代碼基礎的這樣一個問題:在我的類路徑中沒有包含所有可能的目錄。
我并沒有開始測試類路徑配置,但是我尋找到的問題也是重要的,因為它可能導致代碼基礎很難調試。類似這種的全面測試涉及到應用程序的很多方面。很多不同的東西能夠中斷并且導致測試失敗。就這種意義上說,并不是非常統一。在 test-first 編程中,這不是一個問題;但是當測試遺留代碼時,您沒有時間或者預算為每個單獨的方法或者分支編寫獨立的測試。您必須在編寫每個測試時盡量地覆蓋盡可能多的方法和分支。使用一些測試來測試大部分代碼比根本不進行測試要好。
對 main() 方法進行故障診斷
測試 main() 方法并不作用于所有的應用程序。例如,庫中不包含 main() 方法。一些確實具有 main() 方法的應用程序可能也不希望這個方法被不止一次的調用。如果您這樣做的話,靜態初始化軟件會非常混亂。它們可能不去清除一些對象和類,因為它們假定當程序存在時,虛擬機也存在,所有對象和類都將被自動清除。如果事實如此,您可能需要更深層地觀察您的應用程序,尋找第一個測試點。
            但是,不要走的過深。對于相對于 test-last 開發來說的 test-first 開發,尤其是對于遺留代碼來說,您可能會遇到一個問題是,經常存在未公開的依賴關系和先決條件。一些方法假定其他對象存在并且在它們運行前已經創建了。例如,大多數菜單欄不會脫離它們的父窗體單獨起作用。
實際上,如果您試著不止一次地調用 main() 方法,jEdit 就會變得非常混亂。我希望能一次也不調用它。但是,很多其他代碼依賴于 jEdit.initSystemPRoperties() 方法已經被調用,并且這個方法是私有的。執行它的惟一方法就是調用 main()。我采用的解決方法是,只有當 main() 方法一次也沒被調用過的時候才調用它,如下所示: 
private static boolean hasMain = false; protected void setUp() {    if (!hasMain) {        jEdit.main(new String[0]);        hasMain = true;    }    View view = jEdit.getFirstView();    while (view == null) {        // First window may take a little while to appear        view = jEdit.getFirstView();    }    menubar = view.getJMenuBar();   }非常重要的是,hasMain 域是靜態的。jEdit 為每個測試方法構造了一個新的測試用例對象,所以只有靜態域才能保存每個套件狀態。
如果能夠自由地重構正在測試的代碼,您的工作會簡單些。特別是將一些私有方法變成公有方法能夠使這個代碼編寫起來更容易。在 test-first 開發中,這些都不成問題,因為您傾向于將代碼編寫得易于測試。然而,遺留代碼幾乎不考慮可測試性,因此,您必須消除這樣的阻礙。
介紹裝備
一旦編寫了第一個測試,您就能夠經常快速地從同一個框架下開發更多的測試。將初始化和清理代碼放入 setUp() 和 tearDown() 方法中,注意從那里您可以真正快速地編寫多少測試。例如,我編寫過一些基礎測試來保證 jEdit 菜單欄出現并且在正確的地方顯示正確的菜單,如清單 2 所示:
package org.jedit.test;import javax.swing.*;import org.gjt.sp.jedit.*;import junit.framework.TestCase;public class MenuTest extends TestCase {    private JMenuBar menubar;    private static boolean hasMain = false;    protected void setUp() {        if (!hasMain) {            jEdit.main(new String[0]);            hasMain = true;        }        View view = jEdit.getFirstView();        while (view == null) {            // First window may take a little while to appear            view = jEdit.getFirstView();        }        menubar = view.getJMenuBar();    }    public void testFileIsFirstMenu() {        JMenu file = menubar.getMenu(0);        assertEquals("File", file.getText());    }    public void testEditIsSecondMenu() {        JMenu edit = menubar.getMenu(1);        assertEquals("Edit", edit.getText());    }    public void testHelpIsLastMenu() {        JMenu help = menubar.getMenu(menubar.getMenuCount()-1);        assertEquals("Help", help.getText());    }    // Tests for other menus...}與典型的 test-first 編程不同,我在此處編寫了很多測試代碼卻沒有必要編寫任何模型代碼。標準的 TDD 只編寫能夠使一個測試失敗的足夠多的代碼。然后,它就切換到模型代碼中直到測試通過為止。我并不是說 TDD 原則是錯誤的,但當兩百萬行的遺留模型代碼已經存在時,它的確不能成為一個有用的選擇。那時的目的是為了盡快地獲得盡可能大的覆蓋率。
            您可能已經注意到我的測試的另外一個問題了。jEdit 是國際化的,所以測試也應該是國際化的。也就是說像 "File" 和 "Edit" 這樣的字符串應該被分離放入資源束中,這樣測試將在地方化的系統上通過,在這些系統中,它們可能有其他的名字,如: "Fichier"、"Edition" 和 "Aide"。這并不難做到,但是與本題不太相關,所以我在另一個時間對其進行講解。 
測試還是調試?
如果遺留系統是一個好的系統,您的大多數測試還是很有希望通過的。盡管如此,您也會找到 bug。當測試一個以前從未經過測試的代碼基礎時,這種情況很可能很快就出現而不是以后才出現。這時,標準的 TDD 方法是停止測試并且開始進行修改直到測試通過。然而,這是假設您已經測試了模型中的其他所有內容并且相當自信如果您的修改中斷了系統中的其他部分,您可以立即發現。在遺留測試中,這不是一個安全的方法。在修改一個以前的 bug 時,您很有可能將一個新的 bug 引入未經測試的代碼中;而且假如這樣的話,您可能不能立即注意到這個新 bug。因此,強烈建議首先編寫更多的測試,稍后再對 bug 進行修復。歸根結底,這是一種基于如下一些因素的判斷調用:
如果上面問題的回答都是 “是”,那么就去修復這個 bug。如果回答是 “否”,則應該在修復改代碼之前首先盡力擴充測試套件。
通過功能劃分進行測試
在最高層次上開始測試能夠以最快速度獲得代碼覆蓋率。對于遺留代碼,應該考慮應用程序在做什么而不是考慮單個方法。盡量為它所做的每件事編寫測試。對于一個 GUI 應用程序如 jEdit,菜單項提供應用程序功能的一個好的典型。激活每個菜單并且證實它做了應該做的。例如,清單 3 展示了這樣的測試,向一個窗口輸入一些文本,激活 "select all" 菜單項,剪切所選中的文本,然后驗證文本在剪貼板中,而不在窗口中:
public void testCut() {      JEditTextArea ta = view.getTextArea();      ta.setText("Testing 1 2 3");      JMenu edit = menubar.getMenu(1);      JMenuItem selectAll = edit.getItem(8);      selectAll.doClick();      JMenuItem cut = edit.getItem(3);      cut.doClick();      assertEquals("", ta.getText());      assertEquals("Testing 1 2 3", getClipboardText());}private static String getClipboardText() {      Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();      Transferable contents = clipboard.getContents(null);      if (contents != null             && contents.isDataFlavorSupported(DataFlavor.stringFlavor) ) {       try {           return (String) contents.getTransferData(DataFlavor.stringFlavor);       }       catch (Exception ex){           return null;       }      }      return null;}  很可能我在這個方法中測試的有點太多。最好將一些測試轉移到裝備中。無論如何,盡一切辦法進行測試。
我仍然在調查,但是清單 3 中的測試可能已經發現 jEdit 中的一個 bug。第一次我運行它時,測試失敗了。當我設置了一個斷點并且再次在調試器中運行它時,它通過了。這是一個 Heisenbug!我懷疑這涉及到多線程的同步問題,所以我在調用 setText() 之前插入了一個 2.5 秒的時延,測試通過了。如果沒有時延,測試始終失敗。如果有時延,測試通過。下一步是找出為什么需要時延并且判斷它是否是一個可修復的 bug。沒有人會說為 GUI 代碼編寫測試很簡單。 
通過代碼結構進行測試
當您已經測試了應用程序的基本功能,考慮代碼中的替代路徑就變得非常重要了。在大多數語言中,您可以按照下面的步驟分解您的測試:
            您也可以在第 4 步之前使用一個代碼覆蓋率工具,但是我寧愿您手動完成前面的步驟。雖然很多類、程序包和方法都可以通過功能測試進行測試,但是當您從一個程序員的角度而不是從一個用戶的角度查看程序時,經常會發現不同的問題。
實際上,在很多情況下,您從來都不會接觸到第 4 步。您完全沒有時間或者預算來編寫每個可能的測試。這樣也可以,因為做一些測試和不作測試的區別比做所有的測試和做一些測試的區別更大更重要。
自動測試
可以使用反射生成一個測試骨架。這樣能夠更容易找到您需要測試的所有公有方法。每個測試都像這樣開始:
public void testMethodName() {   fail("Test Code Not Written Yet");}這種方法不好的一面是會立刻得到成百上千個失敗的測試。一個可供選擇的方法是在每個測試中添加一個 TODO 注釋而不是完全失敗。然后當時間允許時,您再檢查并補充這些測試。
public void testMethodName() {   // TODO fill in test code}如果您使用的是 JUnit 4,您能夠簡單地將測試注釋為 @Ignore,直到您將它們填寫完,例如:
@Ignore public void testMethodName() {   // TODO fill in test code}在這種情況下,運行測試提醒您它雖然跳過了測試,但是它不會使您失望。實際上,它給您評為 Pass-Fail-Incomplete。
您能夠找到很多可以為您自動編寫測試的工具。然而,這些工具生成的測試往往相當瑣碎和基本,例如方法是否能夠處理傳遞來的 null 參數。這種工具不能真正了解每個類和方法應有的功能。所以,您需要人類的智能。
處理失效代碼
在這一階段很可能會令您吃驚的是,您的代碼中有多少是您實際上并不需要的。遺留代碼基礎往往具有很多殘留代碼,這些代碼現在已經不需要了,盡管在當時是必需的。越老的遺留代碼,您會找到越多的殘留代碼。有時候,這些代碼是明顯不可達的(未調用的私有方法以及未讀入的本地變量,等等)。這種類型的殘留代碼也可以通過靜態代碼分析工具如 PMD 和 FindBugs 找到。有時失效代碼看起來并不是那么明顯,只有為了測試試圖到達它時才能發現實際上的殘留程度。
不管您用何種辦法找到這種殘留代碼或者無論任何理由 它原來被放在這里,都將它去掉。您需要維護的代碼越少越好。
探索性測試
進入一個遺留系統,您常常對哪里進行檢查有好的想法:您對于某個特定的模塊、程序包或者是環境設置有問題,這些問題驅使您進行測試。在這種情況下,盡一些辦法使您的測試集中在那個區域。
有時會發現非常清楚和明顯的 bug。修復它之前,首先編寫一個測試。然后運行這個測試證實測試失敗。然而,經常令人吃驚的是,關于這個 bug 的第一直覺并不正確,測試通過了。測試失敗與否,都不要把它丟掉。它對于將來的開發仍然是有價值的。把它放在您的測試套件中,繼續編寫其他的測試。重復進行,直到找到一個真正失敗的測試,從而找到造成 bug 的真正原因。
            結束語
不要過分追求完美。即使您有一個大規模的未經測試的遺留代碼基礎,現在就開始為它編寫測試吧。不要為達到獲得百分之百的覆蓋率過多擔心。您所編寫的每個測試都會增加您對代碼的信心、排除 bug 并且為將來的開發提供更多的靈活性。需要增加一個特性?編寫一個測試。找到一個 bug?編寫另外一個測試。遺留程序員也可以很靈活。
參考資料
學習關于作者
Elliotte Rusty Harold 來自新奧爾良, 現在他還定期回老家喝一碗美味的秋葵湯。不過目前,他和妻子 Beth 定居在紐約臨近布魯克林的 Prospect Heights,同住的還有他的貓咪 Charm(取自夸克)和 Marjorie(取自他岳母的名字)。他是 Polytechnic 大學計算機科學的副教授,他在該校講授 Java 和面向對象編程。他的 Web 站點 Cafe au Lait 已經成為 Internet 上最流行的獨立 Java 站點之一,它的姊妹站點 Cafe con Leche 已經成為最流行的 xml 站點之一。他的書包括 Effective XML、Processing XML with Java、Java Network Programming 和 The XML 1.1 Bible。他目前在從事處理 XML 的 XOM API、Jaxen XPath 引擎和 Jester 測試覆蓋率工具的開發工作。
(出處:http://m.survivalescaperooms.com)
新聞熱點
疑難解答