準則: 維護自動化測試套組
這個準則提供有助於維護測試套組的設計和管理原則。
關係
主要說明

簡介

如同實體物件,測試也可能毀壞。這並不是它們削弱了,而是它們的環境有了改變。它們有可能是移轉到新的作業系統。更可能的原因是它們操作的程式碼有了改變,改變的方式理所當然地造成測試失敗。假設您在處理 e-banking 應用程式的 2.0 版。在 1.0 版中利用這個方法來登入:

public boolean login (String username);

在 2.0 版中,行銷部門瞭解到密碼保護可能是一個好觀念。因此,將方法變更如下:

public boolean login (String username, String password);

任何使用 login 的測試將會失敗,甚至無法編譯。 由於此時尚無法執行太多有用的工作,無法撰寫太多不含 login 的有用測試。您可能會面對千百次失敗的測試。

您可以利用會尋找 login(something),並將它改成 login(something, "dummy password") 的廣域搜尋取代工具來修正這些測試。之後,再安排所有測試帳戶使用這個密碼,這時您便回到了您的軌道。

之後,當行銷部門又決定密碼不能包含空格時,您又要全部重來一次。

這類事物是一項沉重的浪費,尤其是在測試的改變並非輕易可得之時,而現實的情況也往往就是如此。但另外還有一個更好的方法。

假設測試最初並未呼叫產品的 login 方法。相反地,它們是呼叫一個程式庫方法,由它來執行使測試登入並完成作業準備的任何動作。開始時,這個方法看起來可能如下:

public boolean testLogin (String username) {
  return product.login(username);
}

當 2.0 版有了改變,公用程式庫也配合著改變:

public Boolean testLogin (String username) {
  return  product.login(username

, "dummy password");
}

這時您只變更一個方法,而不是變更上千項測試。

理想上,在測試工作開始時,便可以取得所有必要的程式碼方法。但實際上,它們無法全在預料之中,您可能在產品的 login 第一次有了改變之後,才會知道您需要 testLogin 公用程式方法。 因此,測試公用程式方法通常都會依照需求從現有的測試分解出來。執行這項不間斷的測試修復非常重要,即使是在排程壓力之下,也是如此。如果沒有這麼做,您將會浪費許多時間來處理又難看又無法維護的測試套組。您可能會發現您自己將它拋棄,或無法撰寫所需數量的新測試,因為您所有可用的測試時間都用來維護舊的測試。

附註:產品 login 方法的測試仍會直接呼叫它。如果它的行為有了改變,您就必須更新這些測試的一部分或全部。(如果行為變更之後,沒有任何 login 測試失敗,它們可能不很擅長於偵測問題。)

抽象有助於管理複雜度

上述範例顯示如何從具體的應用程式中將測試抽象摘要出來。很可能您仍可以做進一步的抽象。您可能會發現許多測試都開始於一組共用的方法呼叫序列:它們登入、設定某狀態,再導覽到您在測試之應用程式的一部分。之後,每項測試才能執行一些不同的動作。這項設定可以也應該包含在含有喚起性名稱的單一方法中,如 readyAccountForWireTransfer。在這個方式下,當您撰寫特定類型的新測試時,可以節省許多時間,每項測試的意圖也會更容易理解。

可理解的測試非常重要。舊測試套組的常見問題是,沒人知道這些測試是做什麼,又為什麼這麼做。當它們中斷時,會傾向於用最簡單的方式來修正它們。結果測試尋找問題的能力就變弱了。它們不再測試它們最初所要進行的測試。

另一個範例

假設您在測試一個編譯器。某些最初撰寫的類別定義了編譯器的內部剖析樹及它的轉換。您有許多構成剖析樹的測試,並測試這些轉換。其中一項測試看起來可能如下:

/* 
 * Given
 *   while (i<0) { f(a+i); i++;}
 * "a+i" cannot be hoisted from the loop because 
 * it contains a variable changed in the loop.
 */
loopTest = new LessOp(new Token("i"), new Token("0"));
aPlusI = new PlusOp(new Token("a"), new Token("i"));
statement1 = new Statement(new Funcall(new Token("f"), aPlusI));
statement2 = new Statement(new PostIncr(new Token("i"));
loop = new While(loopTest, new Block(statement1, statement2));
expect(false, loop.canHoist(aPlusI))

這是一項很難讀的測試。假設過了一段時間,情況有了改變,您必須更新測試。 這時您有更多要繪製的產品基礎架構。尤其是,您可能會有一項將字串轉換成剖析樹的剖析常式。這時最好完整重寫測試來使用它:

loop=Parser.parse("while (i<0) { f(a+i); i++; }");
// Get a pointer to the "a+i" part of the loop. 
aPlusI = loop.body.statements[0].args[0];
expect(false, loop.canHoist(aPlusI));

這類測試比較容易瞭解,既能節省當下的時間,又能節省將來的時間。事實上,它們的維護成本很低,因而您不妨將它們大部分都延遲到能夠使用剖析器之時。

這個方法也有缺點:這類測試可能在轉換程式碼(意料中)或剖析器(意外)中發現問題。因此,問題的隔離和除錯可能會有點困難。另一方面,找到剖析器測試遺漏的問題,並不是太壞的事情。

另外,剖析器中的問題也有可能遮住轉換程式碼中的問題。這種機會很小,它的成本幾乎一定小於維護較複雜的測試的成本。

集中焦點改進測試

大的測試套組會包含某些不變的測試區塊。它們對應於應用程式中的穩定區域。其他測試區塊則會經常變更。它們對應於應用程式中行為經常變更的區域。後面一些測試區塊傾向於大量使用公用程式庫。每項測試都會測試可變更區域中的特定行為。公用程式庫的設計,是讓這類測試檢查它鎖定的目標行為,同時又相對免於未測試行為的變更。

例如上面所顯示的「迴圈上升」測試,現在就免於如何建置剖析樹的細節。它仍會感應 while 迴圈剖析樹的結構(因為提取 a+i 的子樹所需要的存取序列)。如果這個結構證明為可變,就可以建立 fetchSubtree 公用程式方法來使測試進一步抽象:

loop=Parser.parse("while (i<0) { f(a+i); i++; }");


aPlusI = fetchSubtree(loop, "a+i");

expect(false, loop.canHoist(aPlusI));

現在,這項測試只會感應兩個東西:語言定義(如整數可以隨著 ++ 而漸進式)和支配迴圈上升的規則(它在檢查其正確性的行為)。

拋棄測試

即使是使用公用程式庫,與檢查項目無關的行為變更也可能週期性地中斷測試。修正測試並不在於因變更而有機會找到問題;您這麼做,是為了保留測試日後有機會找到某些其他問題。不過,這一系列修正的成本,可能會超出測試假設性地尋找某個問題的價值。也許將測試拋開,努力建立更有價值的新測試會比較好。

大部分人都會抗拒拋棄測試的觀念,至少會堅持到維護的重擔淹沒了他們,因而將所有測試拋開為止。決策最好要小心而連貫,就個別測試逐一進行,自問一下:

  1. 這項測試修正到好,要花多少工夫,要加到公用程式庫嗎?
  2. 要另外花多少時間?
  3. 這項測試將來找到嚴重問題的可能性如何?它和相關測試的追蹤記錄如何?
  4. 多久之後,測試會再次中斷?

這些問題的回答都只是粗估,甚至是猜測。但與簡單採用修正所有測試的原則相比,問一下總比較好。

另一個拋棄測試的原因是它們如今是多餘的。例如,早在開發之時,可能會有許多基本剖析樹建構方法(LessOp 建構子之類的東西)的簡單測試。後來,在撰寫剖析器時,會有許多剖析器測試。由於剖析器會使用建構方法,因此,剖析器測試也會間接測試它們。當程式碼的變更中斷了建構測試時,便適合將某些測試視為多餘而加以捨棄。當然,任何新的或變更過的建構行為,也都需要新的測試。它們也許是直接實作(如果很難在剖析器中完整測試它們的話),也可能是間接實作(如果在剖析器中測試會比較適合,也比較容易維護的話)。