Junit 新實例

2004 年 8 月 24 日

我經常收到關於 JUnit 測試架構的設計選擇之一的問題 - 為每個測試方法執行建立新物件的決定。這足以保證快速建立一個 bliki 條目。(不過我幾乎不得不指出,我寫關於 JUnit 的文章並不表示我不認為其他形式的測試很重要。有很多有用的測試活動,儘管 JUnit 及其相關技術對許多活動很有價值,但它並非萬能的解決方案。對於更多關於測試的部落格文章,我建議您查看 Brett PettichordBrian MarickJames Bach 的部落格。您也不應該假設我寫關於 xUnit 測試的文章暗示重構、用例或使用牙線不重要。)

考慮以下這個小的 Java 測試類別

import junit.framework.*;
import java.util.*;

public class Tester extends TestCase {
  public Tester(String name) {super(name);}
  private List list = new ArrayList();
  public void testFirst() {
    list.add("one");
    assertEquals(1, list.size());
  }
  public void testSecond() {
    assertEquals(0, list.size());
  }
}

有些人可能沒有意識到這一點,但兩個測試都通過了 - 而且無論它們以哪個順序執行都會通過。這是因為為了執行這個 JUnit 會建立 兩個 Tester 實例,一個用於每個 testXXX 方法。因此,list 欄位會在測試方法執行時重新初始化。現在有些人認為這是 JUnit 中的 錯誤,但事實並非如此 - 這是經過深思熟慮的設計決策。(有關這類事情的更多資訊,請注意 Kent 的新書。)

JUnit 的基本設計源自 Kent Beck 在 Smalltalk 中建立的測試架構。(實際上稱之為架構有點名不符 - Kent 從未將它作為一個架構發布。他希望人們自己建立它,因為這只需要一到兩個小時 - 這樣他們就不會害怕在想要不同東西時對它進行更改。)JUnit 的關鍵原則之一是隔離 - 也就是說,任何測試都不應執行任何可能導致其他測試失敗的操作。

隔離提供了幾個優點。

  • 任何測試組合都可以按任何順序執行,並得到相同的結果。
  • 您永遠不會遇到這種情況:您試圖找出一個測試失敗的原因,而原因是另一個測試的寫法。
  • 如果一個測試失敗,您不必擔心它會留下導致其他測試失敗的殘留物。這有助於防止隱藏真實錯誤的連鎖錯誤。

現在 JUnit 提供其他支援隔離的機制,特別是 setUptearDown 方法,它們會在每個測試方法的開始和結束執行。若要將此用於我的簡單範例,請執行下列動作。

  public void setUp() {
    list = new ArrayList();
  }
	

大部分時間,您不需要使用 tearDown,因為 setUp 可以執行您需要的任何重新初始化。

您可以透過讓所有狀態都位於局部變數中,且完全不使用欄位,來隔離您的測試方法。然而,這表示您必須在每個測試中複製 setUp 程式碼,而您知道我有多麼厭惡重複

JUnit 方法的批評者認為,由於您有 setUptearDown,因此您不需要每次都使用新的物件。您只要確保在這些方法中重新初始化所有欄位即可。JUnit 方法的支持者認為,這可能是真的,但許多人會在欄位中進行初始化,而且您最好提供這種程度更高的隔離。畢竟,架構設計的重要部分是讓正確的事情(隔離)變得容易,而讓會造成問題的事情(但並非不可能)變得困難。畢竟,這樣做的成本是什麼?

關於 JUnit 方法成本的主要論點是基於所建立的額外物件,包括 JUnit 測試案例和在設定和欄位初始值設定程式中建立的所有其他物件。大部分時間,我認為這個論點是錯誤的。對於建立大量物件,有許多恐懼,但大部分時間這是不合理的,這是基於一個過時的思維模式,說明物件配置和收集如何運作。當然,在某些環境中,物件建立可能會成為一個問題,而 Java 在早期就是其中之一。然而,現代的 Java 可以幾乎沒有開銷地建立物件,這不再是一個問題。(Smalltalk 長期以來就不是問題,這就是為什麼 Kent 和 Erich 不擔心這一點的原因。)因此,大部分時間,請不要擔心建立物件。

話雖如此,大部分並不表示總是如此。一個不想要頻繁建立物件的範例是資料庫連線。這的確有道理,但在一個測試案例類別中的所有測試方法中分享是不夠的 - 你會希望分享的範圍更廣。一個便宜又粗糙的方法是使用靜態變數。一般來說,最好避開靜態變數,但在測試執行期間,它們通常沒問題 - 儘管我還是比較喜歡避開它們。JUnit 實際上提供了一個非常靈活的機制來分享測試固定物件 - TestSetup 裝飾器。這允許你為任何測試套件設定一些共用狀態,這會給你更多靈活性,讓你可以在測試群組之間分享狀態 - 比僅在單一測試案例類別中的方法之間分享要靈活得多。

TestSetup 可能最大的問題是,很難找到相關資訊,以至於我幾乎預期在文件檔中看到「小心豹子」。而且的確有豹子 - 如果你使用 TestSetup,你就會破壞隔離,而隔離一旦破壞,往往會導致難以找到的錯誤。除非你真的非常需要,否則不要使用它。(但如果你需要,這個論壇串會給你一些使用它的提示,J.B. Rainsberger 的新書也是)

(所有這些可能會讓你納悶,為什麼每個測試方法不在它自己的類別中。的確,JUnit 最早的形式就是這樣做的,使用一個內部類別,以測試案例為子類別,並使用固定裝置。雖然這是一個比較明顯的設計,但它讓撰寫測試變得更困難。因此,他們採用了可插入式選擇器模式這種比較晦澀的使用方式。)

對 JUnit 方法的第二個反對意見是它不直觀 - 它用來實現此目的的機制很難理解。我對此表示同情,可插入式選擇器模式鮮為人知,而使用不熟悉模式的設計風格通常會讓人感到不舒服。總的來說,我喜歡 JUnit 的方法,因為我認為隔離和測試撰寫的容易性比深奧的實作更重要。

但優秀的人們並不認同我的看法。Cedric Beust 的TestNG沒有這樣做,更令人驚訝的是,流行的NUnit實作也沒有這樣做(儘管Jim 現在對這個決定感到後悔)。以下 NUnit 測試會導致失敗。

  [TestFixture]
  public class ServerTester
  {
    private IList list = new ArrayList();
    [Test]
    public void first() {
      list.Add(1);
      Assert.AreEqual(1, list.Count);
    }
    [Test]
    public void second() {
      Assert.AreEqual(0, list.Count);
    }
  }

如果你使用的是這種風格的架構,我強烈建議你在設定方法中初始化所有你的實例變數。這樣你就可以讓你的測試保持隔離,並避免一些除錯引起的除毛。

我碰巧不同意重複使用測試案例實例 - 但我不認為做出此決策的人智商只有個位數、腦中想著複雜的金融殺戮,或正用下半身做出一些奇怪的行為。他們以不同的方式呼叫設計權衡 - 我認為當我們能夠對軟體設計的流動本質抱持著尊重的不同意見時,生活會更好。