根除測試中的非確定性
自動化回歸套件在軟體專案中扮演至關重要的角色,對於減少生產中的缺陷和演化設計至關重要。在與開發團隊交談時,我經常聽說非確定性測試的問題,也就是有時通過,有時失敗的測試。如果放任不管,非確定性測試會完全破壞自動化回歸套件的價值。在本文中,我將概述如何處理非確定性測試。隔離有助於減少對其他測試的損害,但你仍必須盡快修復它們。因此,我將討論非確定性的常見原因的處理方法:缺乏隔離、非同步行為、遠端服務、時間和資源外洩。
2011 年 4 月 14 日
我很樂於看到 Thoughtworks 處理許多困難的企業應用程式,為許多很少看到成功的客戶帶來成功的交付。我們的經驗充分證明了敏捷方法,在我們十年前撰寫宣言時極具爭議且不受信任,是可以成功使用的。
敏捷開發有許多不同流派,但我們所做的事情中,自動化測試扮演著核心角色。自動化測試從一開始就是極端程式設計的核心方法,而這種哲學一直是我們敏捷工作的最大靈感。因此,我們在將自動化測試作為軟體開發核心部分方面獲得了許多經驗。
在教科書中介紹時,自動化測試看起來很容易。事實上,基本概念確實非常簡單。但在交付專案的壓力鍋中,會出現許多在文本中未受到太多關注的考驗。我太清楚了,作者習慣於略過許多細節,以便傳達核心觀點。在我與我們的交付團隊的對話中,我們遇到的重複性問題之一是測試變得不可靠,以至於人們不太注意它們是否通過或失敗。這種不可靠性的主要原因是某些測試已變得非確定性。
當測試有時通過,有時失敗,而程式碼、測試或環境沒有任何明顯變化時,測試就是非確定性的。此類測試會失敗,然後你重新執行它們,它們就會通過。此類測試的測試失敗看似是隨機的。
非確定性會影響任何類型的測試,但它特別容易影響範圍廣泛的測試,例如驗收或功能測試。
為什麼非確定性測試會造成問題
非確定性測試有兩個問題,首先它們沒有用,其次它們是一種惡性感染,可以完全毀掉你的整個測試套件。因此,在你整個部署管道受到影響之前,你需要盡快處理它們。
我將從擴展它們的無用性開始。擁有自動化測試的主要好處是,它們通過作為回歸測試[1]來提供錯誤偵測機制。當回歸測試變為紅色時,你知道你遇到了立即的問題,通常是因為一個錯誤在你不注意的情況下潛入了系統。
擁有這樣的錯誤偵測器有巨大的好處。最明顯的是,這意味著你可以在錯誤引入後立即找到並修復它們。這不僅讓你因為快速消滅錯誤而感到溫暖,而且還因為你知道錯誤是隨著你腦海中新鮮的最後一組變更而進入的,所以更容易移除它們。因此,你知道在哪裡尋找錯誤,這在消滅錯誤的戰鬥中已經完成了一半以上。
好處的第二個層次是,當你對錯誤偵測器越來越有信心時,你會鼓起勇氣做出重大變更,因為你知道當你失誤時,錯誤偵測器會啟動,你可以快速修復錯誤。[2]沒有這個,團隊會害怕對代碼進行必要的變更以保持代碼的簡潔,這會導致代碼庫腐爛和開發速度下降。
非確定性測試的問題在於,當它們變為紅色時,你不知道這是由於錯誤,還是僅僅是非確定性行為的一部分。通常,在這些測試中,非確定性失敗相對常見,因此當這些測試變為紅色時,你最終會聳聳肩。一旦你開始忽略回歸測試失敗,那麼該測試就沒有用了,你最好把它扔掉。[3]
事實上,你真的應該扔掉一個非確定性測試,因為如果你不這樣做,它就會具有傳染性。如果你有一組 100 個測試,其中有 10 個非確定性測試,那麼該套件通常會失敗。最初,人們會查看失敗報告,並注意到失敗出現在非確定性測試中,但很快他們就會失去這樣做的紀律。一旦這種紀律喪失,那麼在健康的確定性測試中出現的失敗也會被忽略。在那個時候,你已經輸掉了整個遊戲,並且最好擺脫所有測試。
隔離
我在這篇文章中的主要目標是概述非決定性測試的常見情況以及如何消除非決定性。但在進入主題之前,我提供一個必要的建議:隔離您的非決定性測試。如果您有非決定性測試,請將它們保存在與您的正常測試不同的測試套件中。這樣,您可以繼續注意正常測試的狀況,並從中獲得良好的回饋。
將任何非決定性測試置於隔離區。(但要快速修復隔離測試。)
接著的問題是,要如何處理隔離的測試套件。它們作為回歸測試沒有用,但它們作為清理工作的項目是有未來的。您不應該放棄此類測試,因為您在隔離中的任何測試都無法協助您進行回歸測試涵蓋範圍。
此處的危險是,測試會不斷被丟進隔離區並被遺忘,這表示您的錯誤偵測系統正在侵蝕。因此,有一個機制來確保測試不會在隔離區停留太久,是值得的。我發現有各種方法可以做到這一點。其中一個是簡單的數字限制:例如,只允許 8 個測試在隔離區。一旦您達到限制,您必須花時間清除所有測試。如果您喜歡這樣做,這具有批次處理您的測試清理的優點。另一種方法是對測試在隔離區的時間長度設定限制,例如不超過一週。
隔離區的通用方法是將隔離測試從主要部署管道中取出,以便您仍然可以獲得常規建置程序。然而,一個好的團隊可以更積極。我們的 Mingle 團隊將其隔離套件置於部署管道中,在其正常測試之後的一個階段。這樣,它可以獲得來自正常測試的回饋,但也被迫確保它快速解決隔離測試。[4]
缺乏隔離
為了讓測試能可靠地執行,您必須清楚地控制它們執行的環境,這樣您在測試開始時就能有一個已知的狀態。如果一個測試在資料庫中建立了一些資料並讓它存在,它可能會損壞依賴於不同資料庫狀態的另一個測試的執行。
因此,我發現專注於保持測試的隔離性非常重要。適當地隔離測試可以在任何順序執行。當您進入功能測試較大的操作範圍時,保持測試的隔離性會變得越來越困難。當您追蹤非決定論時,缺乏隔離性是一個常見且令人沮喪的原因。
讓您的測試彼此隔離,這樣執行一個測試不會影響任何其他測試。
有幾種方法可以獲得隔離性 - 或者從頭開始重建您的起始狀態,或者確保每個測試在執行完後都能適當地清理。一般來說,我比較喜歡前者,因為它通常更容易 - 特別是更容易找到問題的根源。如果一個測試失敗是因為它沒有正確建立初始狀態,那麼很容易看出哪個測試包含錯誤。但是,透過清理,一個測試將包含錯誤,但另一個測試將會失敗 - 所以很難找到真正問題。
從空白狀態開始通常對於單元測試來說很容易,但對於功能測試來說可能難得多[5] - 特別是如果資料庫中有許多資料需要存在。每次重建資料庫會為測試執行增加很多時間,因此這需要轉換為清理策略。[6]
當您使用資料庫時,一個方便的技巧是在交易中進行測試,然後在測試結束時回滾交易。這樣,交易管理員會為您清理,減少錯誤的機會[7]。
另一種方法是在執行一組測試之前建立一個幾乎不可變的起始固定裝置。然後確保測試不會改變初始狀態(或者如果它們改變了,它們會在拆除時反轉這些改變)。這種策略比為每個測試重建固定裝置更容易出錯,但如果每次建立固定裝置都花費太長時間,這可能是值得的。
儘管資料庫是隔離問題的常見原因,但您也可以在記憶體中獲得這些問題。特別要注意靜態資料和單例。這種問題的一個好例子是上下文環境,例如目前已登入的使用者。
如果您在測試中明確拆除,請注意拆除期間發生的例外情況。如果發生這種情況,測試可能會通過,但會導致後續測試的隔離失敗。因此,請確保如果在拆除中遇到問題,它會發出很大的噪音。
有些人比較不重視隔離,而更重視定義明確的依賴關係,以強制測試以指定順序執行。我比較喜歡隔離,因為它讓您在執行測試子集和並行測試時有更大的彈性。
非同步行為
非同步是一種恩惠,讓您在執行長期任務時,仍能保持軟體的回應性。Ajax 呼叫允許瀏覽器在回到伺服器取得更多資料時保持回應性,非同步訊息允許伺服器程序與其他系統進行通訊,而不會受到其遲緩的延遲所影響。
但在測試中,非同步可能會是一種詛咒。這裡常見的錯誤是使用睡眠
//pseudo-code makeAsyncCall; sleep(aWhile); readResponse;
這可能會以兩種方式影響您。首先,您會希望將睡眠時間設定得夠長,讓它有充裕的時間取得回應。但這表示您會花費大量時間閒置等待回應,進而減慢您的測試速度。第二個影響是,無論您睡多久,有時還是不夠。環境中會有一些變化,導致您超過睡眠時間,而且您會得到錯誤的失敗。因此,我強烈建議您永遠不要這樣使用裸露的睡眠。
永遠不要使用裸露的睡眠來等待非同步回應:請使用回呼或輪詢。
基本上有兩種策略可以測試非同步回應。第一個是讓非同步服務使用回呼,它可以在完成時呼叫。這是最好的,因為這表示您永遠不需要等待超過您需要等待的時間 [8]。最大的問題是環境需要能夠執行此動作,然後服務提供者需要執行它。這是開發團隊與測試整合的優點之一,如果他們可以提供回呼,他們就會提供。
第二個選項是輪詢答案。這不只是查看一次,而是定期查看,類似這樣
//pseudo-code makeAsyncCall startTime = Time.now; while(! responseReceived) { if (Time.now - startTime > waitLimit) throw new TestTimeoutException; sleep (pollingInterval); } readResponse
此方法的重點是,您可以將 pollingInterval
設定為相當小的值,並知道那是您會浪費在等待回應上的最大閒置時間。這表示您可以將 waitLimit
設定得非常高,這會將觸及它的機會降到最低,除非發生嚴重的問題。 [9]
請務必使用明確的例外狀況類別,指出這是失敗的測試逾時。這將有助於釐清發生錯誤的原因,並可能允許更精密的測試架構在其顯示中考量此資訊。
時間值,特別是 waitLimit
,永遠不應該是文字值。請務必讓它們永遠是可以用大量設定的值,不論是使用常數或透過執行時期環境設定。這樣,如果您需要調整它們(而且您會需要),您可以快速調整所有值。
所有這些建議都適用於您預期提供者會回應的非同步呼叫,但對於沒有回應的呼叫該怎麼辦?這些呼叫是我們在某個項目上呼叫命令並預期它會在沒有任何確認的情況下發生的呼叫。這是最棘手的案例,因為您可以測試預期的回應,但除了計時之外,沒有其他方法可以偵測到失敗。如果您正在建構提供者,您可以透過確保提供者實作某種方式來表示已完成(基本上是某種形式的回呼)來處理這個問題。即使只有測試程式碼使用它,這也是值得的 - 儘管您通常會發現這種功能對於其他目的也很有價值[10]。如果提供者是別人的工作,您可以嘗試說服,但否則可能會卡住。儘管這也是在使用測試替身時值得為遠端服務付出的案例(我將在下一節中進一步討論)。
如果您在非同步項目中發生一般性失敗,以至於它完全沒有回應,那麼您將永遠等待逾時,而您的測試套件將花費很長時間才會失敗。為了對抗這個問題,建議使用冒煙測試來檢查非同步服務是否完全回應,如果沒有,則立即停止測試執行。
您通常也可以完全避開非同步。Gerard Meszaros 的謙遜物件模式表示,每當您有一些邏輯處於難以測試的環境中時,您應該將需要測試的邏輯從該環境中隔離出來。在本例中,這表示將您需要測試的大部分邏輯放在您可以同步測試的地方。非同步行為應盡可能地最小化(謙遜),這樣您就不需要對它進行太多測試。
遠端服務
有時有人問 Thoughtworks 是否執行任何整合工作,我發現這有點有趣,因為我們幾乎沒有任何專案不涉及相當程度的整合。企業應用程式本質上涉及大量來自不同系統的資料組合。這些系統由其他團隊維護,根據自己的時程運作,這些團隊通常使用與我們嚴格測試驅動的敏捷方法截然不同的軟體哲學。
使用此類遠端系統進行測試會帶來許多問題,其中不確定性是問題清單中很重要的項目。通常遠端系統沒有我們可以呼叫的測試系統,這表示會影響實際系統。如果有一個測試系統,它可能不穩定到無法提供確定性的回應。
在此情況下,確保確定性至關重要,因此現在是時候使用測試替身了,這是一個看起來像遠端服務的元件,但實際上只是一個模仿遠端系統行為的假裝版本。替身需要設定,以便它在與我們的系統互動時提供正確類型的回應,但我們可以控制這種方式。透過這種方式,我們可以確保確定性。
使用替身有一個缺點,特別是當我們在廣泛範圍內進行測試時。我們如何確定替身以與遠端系統相同的方式運作?我們可以使用測試再次解決這個問題,這是我稱為合約測試的一種測試形式。這些測試會與遠端系統和替身執行相同的互動,並檢查這兩者是否相符。在這種情況下,「相符」可能並不表示產生相同的結果(由於不確定性),而是結果具有相同的本質結構。整合合約測試需要經常執行,但不是系統部署管道的一部分。通常最好根據遠端系統變更的頻率定期執行。
對於撰寫這類測試替身,我是自初始化假物件的忠實愛好者,因為它們非常容易管理。
有些人堅決反對在功能測試中使用測試替身,他們認為必須使用實際連線進行測試,才能確保端對端行為。儘管我認同他們的論點,但如果自動化測試是不確定的,它們就毫無用處。因此,與實際系統通訊所獲得的任何優勢都會被消除不確定性的需求所淹沒[11]。
時間
很少有事情比呼叫系統時鐘更不確定。每次呼叫時,您都會得到一個新的結果,任何依賴它的測試都可能會因此而改變。詢問在下一小時內到期的所有待辦事項,您會定期得到不同的答案[12]。
這裡最重要的就是確保您總是使用可替換成種子值以進行測試的例程包裝系統時鐘。時鐘存根可以設定為特定時間並凍結在該時間,讓您的測試可以完全控制其動作。這樣一來,您可以將測試資料同步到種子時鐘中的值。[13][14]
總是包裝系統時鐘,這樣一來就可以輕鬆地替換它進行測試。
需要注意的一件事是,您的測試資料最終可能會開始出現問題,因為它太舊了,而且會與應用程式中其他基於時間的因素產生衝突。在這種情況下,您可以將資料和時鐘種子移到新的值。執行此操作時,請確保這是您執行的唯一操作。這樣一來,您可以確定任何失敗的測試都是因為測試資料中的時間移動所致。
時間可能會造成問題的另一個領域是當您依賴時鐘的其他行為時。我曾經看過一個系統,它會根據時鐘值產生亂數金鑰。當這個系統移到一台可以於單一時鐘滴答中分配多個 ID 的較快機器時,它就開始失敗。[15]
我聽過太多因為直接呼叫系統時鐘而產生的問題,所以我會建議找出一個方法,使用程式碼分析來偵測任何直接呼叫系統時鐘的行為,並在當下讓建置失敗。即使是一個簡單的正規表示式檢查,也可能讓您在一個不吉利的時間進行呼叫後,省去令人沮喪的除錯工作。
資源外洩
如果您的應用程式有任何類型的資源外洩,這將導致隨機測試失敗,因為導致資源外洩超過限制而產生失敗的,就是哪個測試。這個案例很尷尬,因為任何測試都可能因為這個問題而間歇性失敗。如果這不是一個測試是非決定性的案例,那麼資源外洩就是一個值得調查的良好候選。
我所謂的資源外洩,是指應用程式必須透過取得和釋放來管理的任何資源。在非記憶體管理的環境中,明顯的範例就是記憶體。記憶體管理在很大程度上消除了這個問題,但其他資源仍然需要管理,例如資料庫連線。
通常,處理這類資源的最佳方式是透過 資源池。如果您這樣做,一個好的策略是將池配置為大小 1,並在沒有資源可提供時讓它擲出例外。這樣,在洩漏後請求資源的第一個測試將會失敗 - 這使得找到問題測試變得容易許多。
限制資源池大小的想法,是關於增加限制以使錯誤更有可能在測試中浮現。這是好的,因為我們希望錯誤顯示在測試中,以便我們可以在它們在生產中顯示之前修復它們。此原則也可以用於其他方式。我聽過一個故事,關於一個系統產生隨機命名的暫時檔案,沒有正確清理它們,並在碰撞時崩潰。這種錯誤非常難以找到,但顯示它的方法之一是在測試中存根隨機化程式,以便它總是傳回相同的值。這樣,您可以更快速地浮現問題。
腳註
1: 是的,我知道許多 TDD 倡導者認為測試的主要優點在於它驅動需求和設計的方式。我同意這是一個很大的好處,但我認為回歸套件是自動化測試給我們的最大好處。即使沒有 TDD,測試也值得為此付出代價。
2: 當然,有時測試失敗是因為程式碼應執行的動作發生變更,但測試尚未更新以反映新的行為。這基本上是測試中的錯誤,但如果及時發現,它也同樣容易修復。
3: 非確定性測試有其有用的角色。從隨機化程式中播種的測試可以幫助找出臨界狀況。效能測試總是會傳回不同的值。但這些類型的測試與自動化回歸測試完全不同,而自動化回歸測試是我的重點。
4: 這對 Mingle 團隊來說很有效,因為他們足夠熟練,可以快速找到並修復非確定性測試,並且有足夠的紀律來確保他們快速執行。如果您的建置因隔離測試失敗而中斷很長時間,您將失去持續整合的價值。因此,對於大多數團隊,我建議將隔離測試排除在主要管線之外。
5: 這裡沒有嚴格的定義,但我使用早期極端程式設計術語,將「單元測試」用於表示細微的測試,而「功能測試」用於表示更端對端且與功能相關的測試。
6: 一個訣竅是在每次測試執行前,使用檔案系統指令建立初始資料庫並複製它。檔案系統複製通常比使用資料庫指令載入資料更快。
7: 當然,這個訣竅只適用於您可以在不提交任何交易的情況下執行測試時。
8: 儘管您仍然需要一個超時,以防您永遠收不到回覆 - 而當您移至不同的環境時,該超時會面臨相同的危險。幸運的是,您可以將該超時設定得相當高,這會將它咬到您的機會降至最低。
9: 在這種情況下,測試執行速度會非常慢。如果您達到等待限制,您可能需要考慮中止整個測試套件。
10: 如果您的非同步行為是由 UI 觸發的,則通常會有一個好的 UI 選擇,即顯示非同步操作正在進行中的指示器。將此設為 UI 的一部分也有助於測試,因為停止此指示器所需的掛鉤可以與偵測何時推進測試邏輯的掛鉤相同。
11: 在這些情況下,即使遠端系統是確定性的,使用測試替身也有其他優點。通常,回應時間太慢,無法使用遠端系統。如果您只能與即時系統對話,則您的測試可能會在該系統上產生顯著且不受歡迎的負載。
12: 您可以根據目前時間為每個測試重新設定您的資料儲存。但這是一項繁重的工作,而且充滿潛在的計時錯誤。
13: 在這種情況下,時鐘存根是打破隔離的常見方法,每個使用它的測試都應確保它已正確重新初始化。
14: 我的某位同事喜歡在午夜前後強制執行測試,以捕捉使用目前時間並假設它在一個或兩個小時後是同一天的測試。這在像月底的最後一天這樣的時間特別好。
15: 雖然,當然,這並不總是是非決定論錯誤,而是由於環境變更所致。依據時鐘滴答聲與 ID 分配的接近程度,可能會導致非決定論行為。
致謝
如同以往,我需要感謝許多 Thoughtworks 同事分享他們的經驗,並提供素材來彙整這篇文章。
Michael Dietz、Danilo Sato、Badrinath Janakiraman、Matt Savage、Krystan Vingrys 和 Brandon Byers 閱讀了這篇文章,並給了我一些進一步的回饋。
Ed Sykes 提醒我使用資料庫檔案的檔案系統副本為每個測試建立初始資料庫的方法。
重大修訂
2011 年 4 月 14 日:首次發布
2011 年 3 月 24 日:在 Thoughtworks 內部張貼草稿以供審查
2011 年 2 月 16 日:開始撰寫文章