準則: 方法呼叫的測試觀念
測試觀念的基礎是表面上看起來可信的軟體錯誤,以及如何以最佳方式來彰顯這些錯誤。這個準則說明一種方法,以偵測程式碼不處理方法呼叫結果的情況。
關係
相關元素
主要說明

簡介

以下是毀損程式碼的範例:

File file = new File(stringName);
file.delete();

問題是 File.delete 可能失敗,但程式碼不會進行這項檢查。修正它需要增加這裡所顯示的斜體程式碼:

File file = new File(stringName);



if (file.delete()


== false) {...}

這個準則說明一種方法,以偵測程式碼不處理方法呼叫結果的情況。(請注意它假設呼叫的方法會產生您提供的任何輸入的正確結果。這就是應該測試的東西,但建立所呼叫的方法之測試觀念是一項獨立作業。也就是說,測試 File.delete 並不是您的工作。)

主要觀念是您應該針對方法呼叫的每個不同而未處理的相關結果各建立一個測試觀念。為了定義這個詞彙,我們先看一下結果。當執行某個方法時,它會變更世界的狀態。以下是一些範例:

  • 它可能推送執行時期堆疊上的回覆值。
  • 它可能擲出異常狀況。
  • 它可能變更廣域變數。
  • 它可能更新資料庫中的記錄。
  • 它可能透過網路傳送資料。
  • 它可能將訊息列印到標準輸出中。

現在,我們看一下相關,同樣使用一些範例。

  • 假設所呼叫的方法會將訊息列印到標準輸出中。這便「變更了世界的狀態」,但它無法影響這個程式進一步的處理。不論印出任何東西,甚至完全沒有印出任何東西,它也無法影響程式碼的執行。
  • 如果方法成功會傳回 true,失敗會傳回 false,您的程式很可能應該根據這個結果來分支。因此,這個回覆值是相關的。
  • 如果所呼叫的方法會更新程式碼稍後會讀取和使用的資料庫記錄,結果(更新記錄)便是相關的。

(在相關和不相關之間,並沒有絕對的分界。在呼叫 print 之後,您的方法可能會造成配置緩衝區,這項配置可能是相關的。在這裡,我們可以想像得到一個取決於是否配置了緩衝區及配置了哪些緩衝區的問題。這是可想像的,但它表面上看起來完全可信嗎?)

方法通常可能會有大量結果,但其中只有一部分是不同的。例如,設想一個會將位元組寫入磁碟的方法。它可能會傳回小於零的數字來表示錯誤;否則,它會傳回寫入的位元組數(可能小於要求的數目)。大量的可能性可以分組到三個不同的結果中:

  • 小於零的數目。
  • 寫入的數目等於要求的數目
  • 寫入了部分的位元組,但小於要求的數目。

所有小於零的值都分組成單一結果,因為沒有合理的程式會區分它們。它們全部(如果確實不只一個有可能)都應該當作錯誤來處理。同樣地,如果程式碼要求寫入 500 個位元組,則實際寫了 34 或 340 個都無關緊要:未寫入的位元組可能會帶來相同的情況。(如果某個值,例如 0,應該執行不同的動作,便會形成一個新的不同結果。)

在所說明的定義詞彙中,還有最後一點。這個特定測試技術並不在意已處理的不同結果。請再一次考量這個程式碼:

File file = new File(stringName);
if (file.delete() == false) {...}

它有兩個不同的結果(true 和 false)。這個程式碼處理它們。它可能會以不正確的方式來處理它們,不過,工作成果準則:布林和界限的測試觀念中的測試觀念會檢查它們。 這個測試技術所關切的是不同的程式碼尚未特別處理的不同結果。 可能的原因有兩個:您覺得區分是不相關的,或您只是忽略它。以下是第一個案例的範例:

result = m.method();
switch (result) {
    case FAIL:
    case CRASH:
       ...
       break;
    case DEFER:
       ...
       break;
    default:
       ...
       break;
}

FAIL CRASH 由相同的程式碼來處理。這時最好檢查是否真正適合這麼做。以下是被忽略的區分範例:

result = s.shutdown();
if (result == PANIC) {
   ...
} else {
   // success! Shut down the reactor.
   ...
} 

結果是關機可能會傳回另一個不同的結果:RETRY。所寫的程式碼會將這個情況視為如同順利完成的情況,而這幾乎是一定錯。

尋找測試觀念

因此,您的目標是思考您先前所忽略的不同但相關的結果。這似乎是不可能:為什麼您現在發現它們是相關的,而先前沒有發現?

回答是當在測試的心靈框架中,而不是在程式設計的心靈框架中,系統性地重新檢查程式碼時,有時會使您閃現思想靈光。您可以質疑您自己的假設,依序逐步執行您的程式碼,查看您呼叫的方法,重新檢查它們的文件,再思考。以下是些需要監視的情況。

「不可能」的情況

錯誤的回報通常似乎是不可能。請重新檢查您的假設。

這個範例顯示 Unix 用來處理暫存檔的一般慣用方法的 Java 實作。

File file = new File("tempfile");
FileOutputStream s;
try {
    // open the temp file.
    s = new FileOutputStream(file);
} catch (IOException e) {...}
// Make sure temp file will be deleted
file.delete();

目標是確定不論程式如何結束,都一律會刪除暫存檔。您執行這個動作的方式是建立暫存檔,再立即刪除它。在 UNIX 中,您可以繼續使用已刪除的檔案,當流程結束時,作業系統會負責將它清除。粗心的 UNIX 程式設計師可能不會撰寫檢查刪除失敗的程式碼。因為他既順利建立檔案,他也必定能夠刪除它。

Windows 不會出現這個鬧劇。刪除失敗是因為檔案是開啟的。這個情況並不容易發現:截至 2000 年 8 月為止,Java 文件並未列舉出 delete 可能失敗的情況;它只說,有可能失敗。但在「測試模式」中,程式設計師可能會質疑他的假設。由於他的程式碼應該是「一次寫入,到處執行」,因此,當 File.delete 在 Windows 中失敗,他可能會問 Windows 程式設計師,因而發現這個可怕的事實。

「不相關」的情況

另一個反對去注意不同但相關的值的力量是已經相信它不重要。Java Comparatorcompare 方法會傳回 <0 的數字、0 或 >0 的數字。這些是三個可以嘗試的不同情況。這個程式碼將其中兩個混合起來:

void allCheck(Comparator c) {
   ...
   if (c.compare(o1, o2) <= 0) {
      ...
   } else {
      ...
   } 

但這可能是錯誤的。探查是否錯誤的方法是個別嘗試這兩個情況,即使您真相信它們沒有差別也一樣。(正是要測試您的信念。) 請注意,您可能為了其他原因而重複執行 if 陳述式的 then 部分。為什麼不用小於 0 的結果來嘗試其中之一,也用等於 0 的結果來嘗試其中之一?

未擷取的異常狀況

異常狀況是一種不同的結果。在既有背景之下,請設想這個程式碼:

void process(Reader r) {
   ...
   try {
      ...
      int c = r.read();
      ...
   } catch (IOException e) {
      ...
   }
}

您會預期去檢查處理常式程式碼是否真正對於讀取失敗執行了正確的動作。但假設異常狀況明確地未獲得處理。相反地,在測試之時,它可以透過程式碼向上傳送。在 Java 中,看起來可能如下:

void process(Reader r) 


throws IOException {
    ...
    int c = r.read();
    ...
}

這項技術要求您即使程式碼明確未處理這個案例,也要加以測試。為什麼? 原因在於這類錯誤:

void process(Reader r) throws IOException {
    ...
    


Tracker.hold(this);
    ...
    int c = r.read();
    ...
    


Tracker.release(this);
    ...
}

程式碼在這裡會影響廣域狀態(透過 Tracker.hold)。如果擲出異常狀況,將不再呼叫 Tracker.release

(請注意,無法釋放大概不會有明顯而立即的結果。問題必須等到 流程 再次呼叫時才會發生,因此,試圖再次 保留 物件將失敗。關於這類問題,Keith Stobie 的 "Testing for Exceptions" 是一篇值得參考的好文章。(取得 Adobe Reader))

未發現的錯誤

這個特定技術不會處理方法呼叫的所有相關問題。以下是它不太會擷取到的兩個類型。

不正確的引數

請考量這兩行 C 程式碼,第一行錯誤,第二行正確。

... strncmp(s1, s2, strlen(s1)) ...
... strncmp(s1, s2, strlen(


s2)) ...

strncmp 會比較兩個字串,如果第一個字串在辭彙上小於第二個字串(在字典中比較前面),將傳回小於 0 的數字。 如果它們相等,它會傳回 "0"。如果第一個字串的詞彙較大,便會傳回大於 0 的數字。不過,它只會比較第三個引數所提供的字元數。問題在於利用第一個字串的長度來限制比較,而它應該是第二個字串的長度。

這項技術會需要三個測試,每個不同的傳回值各一個。以下是您可能使用的三個測試:

s1 s2 預期的結果 實際的結果
"a" "bbb" <0 <0
"bbb" "a" >0 >0
"foo" "foo" =0 =0

未發現問題,因為這項技術沒有任何東西會強迫第三個引數有任何特定的值。這時需要類似下列中的測試案例:

s1 s2 預期的結果 實際的結果
"foo" "food" <0 =0

雖然有適合用來擷取這些問題的技術,但實際很少會用到它們。將測試工作花在 一組豐富的測試,鎖定多種問題類型(且您希望將這個類型當作一項副作用來擷取)會比較好。

不清楚的結果

當您逐一編寫和測試各個方法時,會出現一種危險。以下是範例。這裡有兩個方法。第一個方法是 connect,它要建立一個網路連線:

void connect() {
   ...
   Integer portNumber = serverPortFromUser();
   if (portNumber == null) {
      // pop up message about invalid port number
      return;
   }

當需要埠號時,它會呼叫 serverPortFromUser。這個方法會傳回兩個不同的值。如果使用者選取的埠號有效(1000 或更大),它會傳回這個埠號。否則,它會傳回空值。如果傳回空值,測試的程式碼便會蹦現一則錯誤訊息,並結束作業。

當測試 connect 時,它會依照預期來運作:有效的埠號造成建立連線,無效的埠號造成蹦現錯誤訊息。

serverPortFromUser 的程式碼稍微複雜。它會先蹦現一個視窗來要求字串,且會有標準的「確定」和「取消」按鈕。以使用者的動作為基礎,這時會有四個情況:

  1. 如果使用者輸入有效號碼,就會傳回這個號碼。
  2. 如果號碼太小(小於 1000),就會傳回空值(因而會顯示無效埠號的相關訊息)。
  3. 如果埠號格式不對,會再傳回空值(適用相同的訊息)。
  4. 如果使用者按一下「取消」,便傳回空值。

這個程式碼也能夠依照預期來運作。

不過,這兩段程式碼的組合會有不當結果:使用者按下「取消」會取得無效埠號的相關訊息。所有程式碼都能依照預期運作,但整體效果仍是錯的。它是依照合理方式來測試,但遺漏一個問題。

問題在於 null 是代表兩個不同意義(「不當的值」和「使用者已取消」的一個結果)。這項技術沒有任何東西會強迫您注意 serverPortFromUser 的設計問題。

不過,測試仍會有幫助。當個別測試 serverPortFromUser(只是要看它在四個案例中,是否都會傳回預期的值)時,會失去使用的環境定義。相反地,我們假設它是利用 connect 來測試。這時會有四項測試同時處理這兩個方法:

輸入 預期的結果 思考過程
使用者輸入 "1000" 開啟了 1000 埠的連線 serverPortFromUser 會傳回可用的數字。

使用者輸入 "999"

蹦現無效埠號

serverPortFromUser 會傳回空值,導致蹦現

使用者輸入 "i99"

蹦現無效埠號 serverPortFromUser 會傳回空值,導致蹦現
使用者按一下「取消」 應該取消整個連線流程 serverPortFromUser 會傳回空值,只是等待一分鐘,沒有意義...

在較大的環境定義中測試,往往會彰顯小型測試所碰不到的整合問題。同樣地,測試設計期間的縝密思考,也往往能夠在執行測試之前彰顯問題。(但如果沒有捉到問題,測試時也會捉到它。)