簡介
「開發人員測試」一詞用來歸類最適合由軟體開發人員執行的測試作業。其中也包括這些作業建立的工作成果。「開發人員測試」包含下列種類在習慣上會涉及的工作:「單元測試」、許多所謂的「整合測試」及一部份所謂的「系統測試」。雖然「開發人員測試」通常是和「實作」領域的作業有關,但也與「分析與設計」領域的作業有關係。
以這種「整體性」的角度來看待「開發人員測試」,有助於降低「不可分割性」的傳統方法經常引起的風險。在傳統的「開發人員測試」方法中,初期將測試重點放在評估所有單元的獨立運作性上。在開發生命週期的後期,隨著開發工作接近尾聲,才將整合單元組合至運作的子系統或系統中,並在此環境下進行第一次測試。
這種方式有諸多缺點。首先,因為建議採取先測試整合單元,後測試子系統的階段區隔方式,在這些測試中發現任何錯誤時,通常為時已晚。最後通常會導致無計可施,甚至需要重大修改才能解決。如此的修改不僅工程浩大,更妨礙其他方面的正常進展。專案失控或前功盡棄的風險將隨之升高。
其次,太過於僵硬地劃分「單元」、「整合」及「系統」測試的界線,將無法察覺到橫跨界線發生的錯誤。將這幾類測試分別指派給不同的團隊去執行,風險也隨之愈演愈烈。
RUP
提供的開發人員測試樣式,有助於開發人員在特定的時間點專注於最有價值和最適當的測試。就算只在單一反覆期間,由開發人員儘量從自己的程式碼找出並更正愈多的問題,通常愈有效率,不必再另外麻煩別的測試小組去執行。目標是希望及早發現最重大的軟體錯誤,不論這些錯誤發生在獨立單元、單元整合,或重要使用者情境之內的整合單元工作中。
初學開發人員測試的陷阱
許多一開始就嘗試大量徹底測試工作的開發人員,不久之後都半途而廢。他們發現這似乎毫無價值。再者,有些開發人員一開始將開發人員測試做的不錯,但後來也發現建立的是無法維護的測試套組,最後仍然付諸流水。
本頁提供克服最初障礙及建立測試套組的準則,避免落入維護性陷阱。如果需要詳細資訊,請參閱準則:維護自動化測試套組。
建立期望
認為開發人員測試有價值的人會起而力行。視為苦差事的人會設法躲避。這只不過是大多數行業中大部份開發人員的共同特質,從以往的經驗來看,不算是缺乏規範,無須感到可恥。因此,身為開發人員的您,應該認為測試值得執行,並設法實現其價值。
理想的開發人員測試遵循嚴格的編輯測試循環。每當產品稍有變動時,例如在類別中加入新的方法,就要立即重新執行測試。只要有任何測試中斷,即可確切得知造成問題的程式碼。這種簡單、穩健的開發步調,就是開發人員測試最大的回報。預料會有很冗長的除錯過程。
一個類別的變動破壞另一個類別的正常運作並不稀奇,可以預料不僅已變更的類別需要重新執行測試,還需要執行更多的測試。理想情況是您每小時會在元件上重新執行許多次完整的測試套組。每次有重大變更時,就要重新執行套組、監視結果,然後繼續下一個變更或修正上一個變更。預料必須設法來加速回饋。
自動化測試
執行手動的測試通常較無實效。對於某些元件,自動化測試很簡單。我們以記憶體內資料庫為例。此資料庫透過一個 API 與用戶端通訊,且除此之外,沒有其他可與外界溝通的介面。測試如下:
/* 檢查元素最多只能新增一次。*/
// 設定
Database db = new Database();
db.add("key1", "value1");
// 測試
boolean result = db.add("key1", "another value");
expect(result == false);
這些測試只有一個地方有別於一般的用戶端程式碼:因為不相信 API 呼叫的結果,所以進行檢查。如果 API 讓用戶端程式碼撰寫起來很簡單,則也可以輕易撰寫測試程式碼。如果測試程式碼不容易撰寫,則會很快出現警告,指出可以改良
API。測試優先設計與 Rational Unified Process 強調及早解決重要風險,兩者觀念不謀而合。
然而,元件與外部的關係愈密切,測試的難度也就愈高。兩個常見的例子如下:圖形使用者介面和後端元件。
圖形使用者介面
假設上述範例的資料庫透過使用者介面物件的回呼來接收資料。當使用者填寫一些文字欄位,然後按下按鈕時,就會叫用此回呼。您一定不希望每小時多次填寫欄位、按下按鈕,不斷重複做這種手動測試。那麼您必須設法經由程式控制來送出輸入,通常是在程式碼中「按下」按鈕。
按下按鈕會推動執行元件的部份程式碼。這段程式碼很可能會改變一些使用者介面物件的狀態。因此,您也必須設法以程式化的方式查詢這些物件。
後端元件
假設測試中的元件不會實作資料庫,只是將實際的磁碟內存資料庫包裝起來。因為要測試這個實際的資料庫可能不容易。因為安裝和配置比較困難。授權費也可能很昂貴。資料庫可能拖垮測試速度,導致您不願意經常執行測試。在這些情況下,應該值得以一個較簡單但足夠支援測試的物件來「阻絕」資料庫。
當您的元件所交談的元件尚未完成準備時,Stub 也很適用。您不希望因為等待其他人的程式碼而無法開始測試。
如果需要詳細資訊,請參閱 概念:Stub。
不要自行撰寫工具
開發人員測試看起來相當直接明確。您只要設定一些物件、透過 API 呼叫,然後檢查結果,若結果不符預期,則宣布測試失敗。設法將測試組合起來執行也很方便,可以個別地執行或以整組方式執行。支援這些需求的工具稱為測試架構。
開發人員測試是直接明確的,且測試架構的需求亦不複雜。然而,如果您自做聰明撰寫自己的測試架構,則徒勞無功地修補架構所浪費的時間,將遠超過您的預期。現在已有許多現成的測試架構可用,包括商用和開放原始碼,直接拿來利用有何不可。
建立支援程式碼
測試程式碼動輒重複。類似下列的程式碼很常見:
// 不容許空值名稱
retval = o.createName("");
expect(retval == null);
// 不容許開頭空格
retval = o.createName(" l");
expect(retval == null);
// 不容許尾端空格
retval = o.createName("name ");
expect(retval == null);
// 第一個字元不能是數字
retval = o.createName("5allpha");
expect(retval == null);
這段程式碼是經由複製、貼上某項檢查,再進一步編輯成為另一項檢查而建立。
這裡有兩項風險。如果介面改變,勢必要進行大量編輯(在較複雜的情況下,只是進行整體取代是不夠的)。另外,如果程式碼相當複雜,則測試目的將迷失在字裡行間。
當您發現自己一直重複相同的作法時,請認真考慮將重複的部份抽離到支援程式碼。儘管上述程式碼很簡單,但若按照下列方式撰寫,將更利於閱讀和維護:
void expectNameRejected(MyClass o, String s) {
Object retval = o.createName(s);
expect(retval == null); }
...
// 不容許空值名稱
expectNameRejected(o, "");
// 不容許開頭空格。
expectNameRejected(o, " l");
// 不容許尾端空格。
expectNameRejected(o, "name ");
// 第一個字元不能是數字。
expectNameRejected(o, "5alpha");
撰寫測試的開發人員經常因為執行太多剪貼動作而出錯。如果您懷疑自己有這種傾向,反向操作即可。一定可以將程式碼中所有重複的文字剔除。
優先撰寫測試
撰寫程式碼之後再撰寫測試是件苦差事。大家都很想趕緊做完,然後離開。在撰寫程式碼之前撰寫測試,可讓測試工作形成良性回饋循環的一部份。隨著實作愈多程式碼,愈多的測試會通過,最後,等到所有測試都通過之後,您也就完成任務。事先撰寫測試的人通常都很成功,不會浪費時間。關於測試優先的詳細資訊,請參閱概念:測試優先設計
測試保持簡單明瞭
您應該沒想到自己或其他人以後一定會修改測試。例如,常見的情況是在稍後的反覆過程中會要求變更元件的行為。舉一個簡單的例子,假設元件曾經宣告一個平方根方法,如下所示:
double sqrt(double x);
在這個版本中,負數的引數會導致 sqrt 傳回 NaN(根據 IEEE 754-1985 Standard for Binary Floating-Point Arithmetic 的定義,代表 "not
a number")。在新的反覆中,平方根方法可接受負數,並傳回複數結果:
Complex sqrt(double x);
原先對 sqrt 的測試必須變更。這表示必須瞭解運作、更新,以適用於測試新的 sqrt。更新測試時,您必須小心別破壞原本的錯誤偵測機制。以下是可能發生的情況:
void testSQRT () {
// Update these tests for Complex
// when I have time -- bem
/* double result = sqrt(0.0); ... */ }
還有其他更吊詭的情形:測試經過變更,可以實際執行,但測試的目標已偏離主題。經過多次反覆測試之後,最後形成的測試套組太軟弱,找不出許多錯誤。這種情形有時稱為「測試套組變質」。變質的套組不值得維護,終將作廢。
除非測試實作的測試構想非常明確,否則無法維護測試的錯誤偵測機制。測試程式碼通常沒有太多註解,儘管如此,通常還是比產品條碼更難以理解背後的「理由」。
sqrt 的直接測試中較不可能有測試套組變質的情形,反而在間接測試中較容易發生。將會有呼叫 sqrt 的程式碼。此程式碼也會有測試。當 sqrt 變更時,這些測試有一部份將失敗。變更 sqrt
的人可能必須變更這些測試。因為較不熟悉這些測試,且測試與變更的關係較不明確,此人很可能為了通過測試而削弱測試的偵錯能力。
在建立測試的支援程式碼時(如上述的要求),請注意:支援程式碼應該清楚闡明測試的目的,避免含糊不清。經常聽到有人抱怨在物件導向程式中找不到一個能從頭到尾完成一件工作的地方。如果您查看任何方法,將會發現工作只是轉交到其他地方。這種結構有好處,但會造成初學者不容易瞭解程式碼。除非他們下過苦心,否則所做的變更可能不正確,或導致程式碼更複雜和脆弱。除非後來的維護人員疏於照料,否則在測試程式碼中也有可能會出現同樣的情形。您一定要撰寫易於瞭解的測試,以免發生問題。
比對測試結構與產品結構
假設有人繼承您的元件。他們需要稍做修改。他們可能會希望檢視舊的測試資料以協助新的設計工作。在撰寫程式碼之前,他們希望先更新舊的測試(測試優先設計)。
但如果找不到適當的測試,則再多的美意也將毫無用武之地。他們將採取的動作是先變更,找出哪些測試失敗,然後修正測試。這將導致測試套組變質。
因此,測試套組的結構必須有條有理,且可以從產品結構中看出測試位置。開發人員通常會以並列階層來排放測試,亦即每一個產品類別各有一個測試類別。所以,每當有人變更 Log 類別時,他們就知道測試類別是
TestLog,也知道何處可以找到程式檔。
讓測試違反封裝
您可能會完全仿照用戶端程式碼來設計測試,和用戶端程式碼使用相同的介面與元件進行一模一樣的互動。但這有缺點。假設您測試一個維護雙向鏈結串列的簡單類別:
圖 1:雙向鏈結串列
假設您測試的是 DoublyLinkedList.insertBefore(Object existing, Object newObject)
方法。在其中一個測試中,您想要在串列的中間插入元素,然後檢查是否成功插入。此測試利用以上的串列來建立這個更新的串列:
圖 2:雙向鏈結串列 - 插入項目
此測試檢查串列正確性如下:
// 串列現在多了 1 個元素。
expect(list.size()==3);
// 新元素在正確的位置
expect(list.get(1)==m);
// 檢查其他元素是否仍然存在。
expect(list.get(0)==a); expect(list.get(2)==z);
這似乎已足夠,但實際上不然。假設串列實作不正確,且往回指標未正確設定。亦即,假設更新的串列實際情形如下:
圖 3:雙向鏈結串列 - 實作錯誤
如果 DoublyLinkedList.get(int index) 從頭至尾遍訪一次串列(很可能),則測試會錯過這項失敗。如果類別提供 elementBefore 和
elementAfter 方法,則檢查這些失敗很簡單:
// 檢查鏈結是否已全部更新
expect(list.elementAfter(a)==m);
expect(list.elementAfter(m)==z);
expect(list.elementBefore(z)==m);
//這會失敗
expect(list.elementBefore(m)==a);
但如果沒有提供這些方法該怎麼辦?您可以設計較複雜的方法呼叫,當懷疑的問題確實存在時,這些呼叫將會失敗。例如,以下是可行的:
// 檢查從 Z 開始的逆向串列是否正確。
list.insertBefore(z, x);
// 如果遺漏而未更新,X 將
// 緊接在 A 後面插入。
expect(list.get(1)==m);
但建立這種測試比較繁雜,且可能相當難以維護(除非您撰寫很適當的註解,否則完全無法瞭解測試的動作和理由)。有兩個解決方案:
-
在公用介面中增加 elementBefore 和 elementAfter 方法。但這樣會實際公開實作方式,未來變更將更困難。
-
讓測試「揭開神秘面紗」,直接檢查指標。
即使對於 DoublyLinkedList 這麼簡單的類別,甚至對於產品中更複雜的類別,後者通常是最佳解決方案。
測試和其測試的類別通常會放在同一個套件中,且設定為 protected 或 friend 存取權限。
常見的測試設計錯誤
每一項測試會操作一個元件,然後檢查結果是否正確。測試的設計(使用的輸入及如何檢查正確性)可能有利於發現問題,也可能不慎隱瞞問題。以下是一些常見的測試設計錯誤。
沒有事先指定期望結果
假設您正在測試的元件會將 XML 轉換成 HTML。您嘗試利用一些 XML 範例,經過轉換,然後在瀏覽器查看結果。如果畫面正確,您將 HTML 儲存為正式預期的結果以「保存」HTML。後來,再以一項測試來比對轉換的實際輸出和預期的結果。
這是很危險的作法。即使是經驗豐富的電腦使用者,也習慣相信電腦的結果。您可能疏忽畫面外觀的錯誤 (更何況瀏覽器又相當容忍格式不正確的 HTML)。將不正確的 HTML 視為正式的預期結果,測試將永遠找不出問題。
直接仔細檢查 HTML 會比較安全,但仍然很危險。因為輸出很複雜,很容易忽略錯誤。如果事先手動撰寫預期的輸出,您將會發現更多問題。
沒有檢查背景
測試通常會檢查是否已變更原本應該變更的事物,但測試的建立者通常會忘記檢查是否有保留原本應該保留的事物。例如,假設有一個程式預料會變更檔案中的前 100 筆記錄。確認第 101 筆沒有改變是一種很好的想法。
理論上,您應該檢查沒有遺漏「背景」中的任何物件(整個檔案系統、所有記憶體、可經由網路接觸的任何物件)。實際上,您必須謹慎選擇您有能力檢查什麼。但一定要做這個選擇。
沒有檢查持續性
只因為元件指出已有變更,並不代表已實際在資料庫中確認變更。您必須透過另一個途徑來檢查資料庫。
沒有加入變化
測試可能只設計為檢查一筆資料庫記錄中三個欄位的影響,但必須填入其他許多欄位才能執行測試。對於這些「不相干的」欄位,測試人員通常會一再地使用相同的值。例如,在文字欄位中總是使用情人的姓名,或在數值欄位中總是使用 999。
問題在於有時不該發生的事,實際上卻發生。偶爾,有的錯誤起因於不太可能的輸入所形成的一些不明確的組合。如果一直使用相同的輸入,您將沒有機會發現這些錯誤。如果持續不斷地改變輸入,就有發現的可能。使用不是 999
的數字或使用其他人的姓名,通常沒有什麼壞處。如果改變測試所用的值沒有壞處,還可能有好處,就請改變吧 (附註:如果現在的情人就是您的同事,最好還是別亂用舊情人的名字)。
還有另一項好處。當程式應該使用欄位 Y 時,卻誤用欄位 X,就產生一種不易察覺的錯誤。如果兩個欄位都包含 "Dawn",則無法偵測到這種錯誤。
沒有使用實際資料
測試中經常會使用虛構的資料。這種資料很普通,顯得不切實際。例如,客戶姓名可能是 "Mickey"、"Snoopy" 及
"Donald"。因為不是實際使用者輸入的資料(例如,太短),很可能遺漏實際使用者遭遇的問題。例如,這些只包含一個單字的姓名無法查出程式碼未處理含空格的姓名。
為了慎重起見,請稍微花一點心思使用實際的資料。
沒有注意到程式碼什麼事也沒做
假設您將一筆資料庫記錄起始設定為零,接著執行應該在記錄中儲存零的計算,然後檢查記錄是否為零。請問您究竟在測試什麼?搞不好根本沒有執行過任何計算。可能也不會儲存任何資料,測試不會有結論。
這個例子似乎不太可能。但這種錯誤可能在不知不覺中就發生。例如,您可能為複雜的安裝程式撰寫一項測試。測試的目的是檢查安裝成功之後會移除所有暫存檔。但是,由於使用安裝程式的所有選項,此測試中,有一個特定的暫存檔並未建立。果然不出所料,這就是程式忘記移除的暫存檔。
沒有注意到程式碼做錯誤的事
一個程式有時會根據錯誤的理由來做正確的事。舉一個普通的例子來說明,請看這行程式碼:
if (a < b && c)
return 2 * x;
else
return x * x;
邏輯表示式錯誤,而您撰寫的測試以錯誤方式求值,結果選擇錯誤的分支。很不幸地,就這麼巧合,變數 X 在測試中的值剛好是 2。所以,錯誤分支的結果反而正確,與正確分支得到的結果相同。
對於每一種預期的結果,您應該質問是否存在根據錯誤理由取得結果的這種假象。雖然通常不可知,但也不是完全不可能。
|