2015/1/12

軟體測試經驗分享

測試自動化

這裡先暫且放下 BDD、TDD 等進階的軟體工程理論,講到測試,許多人的刻板印象,第一個想到的就是手動式的測試方法。把程式執行起來,照著幾個情境人工觸發程式處理程序看看程式的行為、輸出的結果符不符合預期,有問題的話就修改程式再週而復始相同的過程。但其實這是一個很吊詭的現象,程式開發的用意是要協助人們將工作自動化,以減少人力的負載。結果我們有能力幫別人將工作自動化,而自己做的工作卻仍然在使用落後的人工處理!如果讓客戶知道,客戶不會懷疑我們的程式能用嗎? 就像做餐飲,如果連自己做出來的餐點自己都不吃,會有客人想吃嗎?

再者,考慮到修改程式後,處理邏輯間可能出現相互干擾的情況,回歸測試是一個必要的程序。也就是改完了程式,最好再將之前測試過的情境再重新走過一次,以確認新的程式碼沒有影響到程式其他的部份。就如同一般人工和自動化之間的差別,如果是用人工,絕大部份的人就算在沒有時間壓力的情況之下,想到要全部重來就應該會意興闌珊,然後只確認跟程式碼修改有關的情境。

在不考慮回歸測試的情況下,當程式的規模愈大,要涵蓋程式碼邏輯的測試路徑數目會呈現對數成長。也就是說如果要確保程式的品質,要走過的情境就會出現爆炸性的成長。這並不是單純的人力可以負荷得來的,就算是專案有預算成立專責的測試團隊,不用自動化的方式測試也只是加速人力的耗損罷了!

有經手過大型程式開發經驗的人就應該會清楚,有很多時候程式問題出現在資料處理邏輯的深處,要進行測試經常要先執行前置的程序、以製造產生問題的資料狀態或執行條件。往往這個過程會需要很多的步驟,同時很多時候也要等待程式進行處理。以手動的方式來測試,為了確認一次程式修正後的結果,就必須要耗掉一段不小的時間,而且大多數都是人被綁在電腦前等待程式回應、以便輸入下一個步驟指令。如果修正的結果不如預期,整個程序又要再來一次,如此不斷的反覆。工作的效率往往因此而被拖垮,更不用說是要進行先前提到的回歸測試。此時,不負責任的工程師因為怠惰會只修改程式碼,就自以為程式一定可以正確的運作而結束工作。負責任的工程師則在被要求工作效率的壓力之下,急就章地做簡單的確認,心虛地交出工作成果。

如此程式的品質怎麼可能會好,就算是在這種情況下能夠有好的品質,那也只是負責的人有足夠的經驗在程式開發時迴避掉潛在的問題。而這類型的開發專案所會面臨的風險不是產出品質不穩定、使用者被迫成為測試系統問題的白老鼠;就是品質全依賴在特定的人身上,一但負責的人收手,專案產出的品質就有可能因此崩潰。

所以要有效率地提昇程式產出的品質,自動化測試是必要的開發程序,不管是在多人協同合作的團隊或是一個人獨立負責的專案裡。一旦測試的流程自動化,不論是初次完成的程式或是後續的修改,只要啟動自動化的流程,接下來就剩等待工具所產出的報告,中間的過程完全不需要人工介入,當然生活的品質也可以因此而提昇,因為不必要再膠著於機械、重覆、耗時的工作上。

要做到測試自動化,當然就是要寫測試用的程式碼來檢驗正式的程式是否符合需求。認為撰寫測試程式是重工、浪費時間的人,應該要試著放下意氣之爭,撰寫測試程式碼的確會增加起始階段的工作時間,然而一旦測試程式完成之後是會大大地有助於縮短工作時間。堅持「與其花時間在撰寫測試程式,還不如手動跑幾個情境來得有效率」的人,並不適合去擔任稍具規模之程式開發的工作,這樣的理論只能被應用在微型、不需要與人合作的程式開發工作上,而這樣的思維在團隊中只是把自己的責任轉移給其他人、只會增加其他成員的負擔。

在習慣了沒有測試程式碼的開發方式之後,要改變工作的習性照著 TDD 的理論先去撰寫測試程式,的確是會有很大的心理障礙。但是其實可以反過來思考,在寫完程式之後、進行手動測試前,想像著把手動執行的步驟給程式化就可以每次完成修改後一鍵測試,應該可以形成撰寫測試程式碼的動力。尤其是對沒有圖形介面的程式測試來說,動機應該會更強烈。有圖形介面的程式用手動的方式來進行測試是很直觀的,但是對沒有圖形界面的程式,例如:Web Service 來說就會出現問題。因為人工無法直接觸發程式,所以就必須要額外製作一個模擬用的 Client 來送出指令,嚴格地來說這就是測試程式的一種了。要開始測試了才寫測試程式碼,也許不符合 TDD 的概念,但最少跨出第一步,習慣之後調整先後次序就僅是次要的議題了。

Code Coverage

有了自動化的測試程序之後,接下來會面臨到的議題是「要測試到什麼程度才足夠?」。「測試的結果有沒有通過」是自動化測試裡很基本的指標,但是如果測試的程式碼跟手動測試一樣只是隨機挑幾個情境來進行,會讓測試的結果偏離程式品質的實際情況。試想,如果要檢查一個房子的結構,抽查四根柱子是否會比只抽查一根柱子的結果要來得能讓人信服。所以這時候就會有另外一項指標是 Code Coverage,這個指標代表了正式程式碼在執行測試的過程中有多少部份被執行過。如果 Code Coverage 只有 1%,就算測試結果是通過的,也不代表程式是沒有問題的,畢竟還有 99% 是沒有被確認過的。

回到原本的問題,要測試到什麼程度其實要看專案對於品質的接受程度,並且由相關的負責人員來設定標準。就像之前提到的,程式的規模愈大,要涵蓋程式碼邏輯的測試路徑數目會呈現對數成長。所謂的測試路徑,用簡化的說法就是在一連串的「程式碼邏輯判斷式」中,「程式執行經過的判斷式條件區塊之組合」就是測試路徑。也就是如果程式碼裡有二個 If-Else 的判斷式,測試的路徑最少會有四條。如此可以想見,當程式碼裡的邏輯判斷式愈多,所會產生的測試路徑數目就會開始急速的膨脹。

如果專案要求程式的可靠度到達到百分之百,那所有的測試路徑就必須要確認過,以保證程式在任何情況下都是可以正確地運作。只是這會付出極大的成本,因為要測試的情境數可能會是個天文數字。會達到這種程度要求的,大概也只有發射火箭上太空之類的專案負擔得起測試成本。一般的專案能夠有資源投入測試工作已經是百裡挑一了,所以合理地訂定指標數值才是重點。

自動化測試的一項優勢是可以在付出相對小的成本來涵蓋大量的測試情境,像是使用 Data Driven 的方式來以大量的資料驗證程式的資料處理邏輯。但也不可能無限上綱,用自動化測試就可以達到全路徑的測試,因為分析測試路徑並設計測試資料不是一件簡單的工作。

Code Coverage 就成為了檢視測試程度的基本指標,一個經過量化以數字來表達測試被涵蓋了多少部份的指標。Code Coverage 達到 100% 是不是就代表測試是完美的全路徑?不是!其實光 Code Coverage 到達到 100% 就是一件很不容易的事,先不說程式有很多防呆邏輯是不好去做出有效的模擬資料,就連程式語言本身都會有一些細節問題會讓你無法達成 100% 的 Code Coverage。

以下方的 Pseudo Code 為例:
    If a > b
        c = a / b
    else
        c = a / 2
當測試程式設定 a = 4, b = 2 及 a = 4, b = 8 二組資料可以讓這段程式碼的 Code Coverage 達到百分之百,但是程式就萬無一失了嗎?不是!因為邏輯上 b = 0 時會讓程式拋出例外導致執行中斷,明顯地程式有瑕疪但測試報告並沒有辦法顯現出來,所以 100% 的 Code Coverage 並不表示測試是完整的。

那 Code Coverage 的指標到底有什麼意義?我的經驗是最少這項指標可以協助我們確認「程式碼有多少比例是可被執行的」。這句話看起來好像有點多餘,程式碼不能執行的話早就被 Compiler 給擋了下來,應該不用到測試階段吧!Compiler 擋下的語法上的錯誤,就是所謂的編譯時期的錯誤,而 Runtime Error 則是要執行時才會出現。以上的 Pseudo Code 就是一個很典型的例子,程式碼隱含了 Runtime Error,但在編譯時期卻不見得會顯示錯誤。

根據莫非定律,程式會出現錯誤的地方都是沒有被確認過的部份。很多程式設計師在寫完程式後,大多的心態都是「我寫的程式一定不會有錯」。就算是會花時間確認程式運行結果,也都只聚焦在主要流程或是常出現的資料狀態。所以很多程式平常都是好好的,但是一旦出現不常見的情況或是資料狀態時,程式就會產生執行時期的錯誤。所以儘可能地提高 Code Coverage 對程式品質是有一定的助益,也可以用來協助檢視測試資料的樣本量是否有達成目標。

以團隊角色來看,撰寫程式的人在把程式交給測試人員之前,最少應該要做到確認自己的程式不會一執行就當掉,基本的錯誤處理有被撰寫在程式碼中,否則就算程式勉強進到測試階段也只是浪費其他人的時間。

單元還是整合?

在撰寫測試程式時,很多時候會糾結在到底是要寫單元測試?還是整合測試?是要以白箱的方式來做?還是以黑箱的式來做?如果是選擇寫單元測試,初次接觸的人選定單元的方式大多都是用 Class 來成為單元的基準,因為 Class 的界線明確,也是 OOP 在集結程式碼、表達設計內容的單位。如果以此為基準,要做到純理論的單元測試基本上是不太可能的,單元測試是期望把所要測試的單元與外界的交互關係完全隔離掉,只確認單元內部的運作是否符合設計的內容。

但要完全隔離一個 Class 談何容易,真正能夠只做單純地資料處理的 Class 畢竟只是系統中不同類型的其中一種,而不管是哪一種類型的 Class 或多或少都會需要引用其他的 Class,尤其是控制類型的 Class。所以有很多的 Test Framework 會利用像是語言的特性來建立 Mock 或 Stub,以便取代非測試對象的 Class 來進行隔離。

要搭配 Mock 之類的技術來做單元測試,使用 IoC 或是 DI 的概念來設計 Class 是個可以達到有效隔離的方式,但卻也不是完全沒有缺點。過度的設計相對地會使設計的內容過於抽象、破碎,會增加許多額外的開發工作外,也提高新進人員的學習曲線等維護成本。

即使是透過這些技術,要做到百分之百的隔離還是不太可能的,就算是把自行設計的 Class 隔離掉了,但單元中使用的不管是內建還是第三方函式庫提供的 Class 都不屬於測試的範圍。依照理論這些被引用的 Class 應該也要被隔離吧?但實際的情況是,就算有辦法隔絕,做到這種程度測試應該也進行不下去了,因為要測試的程式區塊可能在被大量的假 Class 取代後失去了功用,測到的大概只剩單行單行的指令。

會形成這樣的困境其實是一開始就被所謂單元的範圍給限制住了,在單元測試的理論上並沒有要求一定要以 Class 為單位,所以並不是毫無差別地隔離所有引用到的 Class 就叫單元測試。單元測試的概念應該是來自於電子電路的設計,以一塊電路板來說,單元指的是這個電路板的話,電路板上所使用的電子元件都是測試的範圍,不需要管是自行設計還是採購來的。

如同之前提到的,測試的內容是基於專案所能承受的成本而定。每一個專案都是獨特的,對每一個專案來說「沒有最佳的方案、只有最適合的方案」。限定單元只能是一個 Class 也許是最佳的方案,但不見得是最適合的。也許把設計上的一個 Subsystem 當作是一個單元是個相對合適的方式;或是以一個 Class 為主,所有被其引用的 Class 都屬於同一個單元。只不過這樣的模式就沒有像以 Class 為單元那樣容易管理測試的資訊,在沒有其他資訊的協助之下單元的界線不再那麼地明確,有沒有單元未被測試所涵蓋,最直接有效的資訊大概就只有 Code Coverage 了。

另一個衍生的問題是:單元測試和整合測試的界線又在哪裡?把整個系統當作一個單元、網路和資料庫都包含在裡面,不行嗎?這樣的話單元和整合測試跟本就是一樣的,不是嗎?舉例的問題誇張了點,稍微大一點的系統應該都有辦法做拆解,不致於拿整個系統做單元。萬一非不得已,好歹也要想辦法把資料庫和網路隔離開來,畢竟不太可能在真實的環境中執行測試。

難道就不會有單元是一定要綁著與測試標的無關的外界系統嗎,例如:資料庫或是網路?我想,有吧!在我們的週遭應該還存在著不少這樣的系統。那到底是要做單元測試?還是要做整合測試?正統科班背景出身或是對於理論嫻熟的人會說:這是設計所造成的問題,只要正確地設計,單元是單元、整合是整合,不太有機會混為一談。

非關理論,對於「要做單元測試還是整合測試」、「單元測試和整合測試的界線」這些問題,我個人的經驗是放下這些名詞吧!大部份的時候是無招勝有招,能做到什麼程度就做到什麼程度。只是要考量到自動化測試的限制,測試應保持獨立、並且和真實環境做切割。測試的資料準備程序則是要考慮列在自動化的過程裡,以避免不確定的初始資料影響了測試的結果。

很多時候一個團隊裡的成員素質可能不是那麼整齊地都是正統訓練出身,甚至多數是半路出家,會打字、會把網路上的範例組裝後看起來可以執行的程式設計師。成員不見得了解理論、甚至完全沒有接觸過,更別提在設計上能應用 IoC、DI;在技術上能清楚並且會使用 Test Framework 裡的 Mock、Stub 等功能。團隊的管理者在現實中也不見得會有太多的資源來提昇成員的素質,或是有足夠的時間來投入和成員溝通觀念與設計細節。在這樣的專案團隊裡,理論成了高調的空談,要求成員產出測試則只是避免專案品質過度失控的手段之一,所以思考出一個專案成本可承受的自動化測試模式才是重點。

而不管是使用黑箱還是白箱、單元還是整合,Data Driven 都會是一個必要的工具。有了 Data Driven,可以藉由簡易的調整資料工作,來達成提高 Code Coverage 的目的,也可以依據問題的發生原因持續累積回歸測試所需的樣本。一個方便的 Data Driven 工具除了要可以在撰寫測試程式時很容易地傳入資料,並且要可以在測試的報告中顯示出造成測試失敗的資料樣本。否則只會讓測試的結果失去意義,因為只能知道系統有地方不符合預期,但是卻沒有辦法找出問題的根源來加以修正。Data Driven 工具最好還可以有資料產生計畫的功能,以便在自動化測試時能夠每一次都精確地產生出測試所需的初始資料,甚至可以不用預先製作出大量的初始資料,減少資料儲存的成本。

驗證及確認

一個分工比較明確的團隊在執行測試程序時,應該會引發一個問題是測試的程式是要由 SD 來開發?還是由 PG 來開發?這個問題其實牽涉到了軟體工程裡所提到的 V&V,也就是驗證及確認(Verification and Validation)。對於一個有制度的開發團隊來說,這是一個很重要的機制,不管在資安的 ISO 27001 或是軟體成熟度的 CMMI 都會提到。但很多的專案管理者也許知道要進行測試,但是卻很容易輕忽了驗證及確認的必要性。

軟體開發的目的就是為了要滿足使用者的需求,所以在 SA 訪談完、開好了需求的規格之後交給了 SD,SD 依據需求的內容進行規劃、完成了系統規格的設計,接下來就是由 PG 來依設計的內容實作出程式。在這一整個過程裡,就算是做了測試並且通過,還是會有一個很大的風險:程式不是使用者要的。為什麼?怎麼會?不是都照著做了嗎?對!問題就出在有誰來確認後一個階段的產出是照著前一個階段的要求來做的?

每一個人的長成、教育背景或多或少都有差異,而對語言、文字的理解也會有一定的落差,更別提長期爆肝工作下出現的精神錯亂,所以在每個階段的交替間一定會有機會發生不一致的情況。如果測試程式交由 PG 來開發,會形成球員兼裁判的現象,自己測自己的東西很容易會出現盲點,當然也更不可察覺自己對設計內容的"誤解"。在負負得正的情況下,產出了一份通過測試的報告。

每一個開發環節上的認知落差,最後產出的程式可能就會和使用者期望有著天差地別的距離。就像是打靶,槍口差個幾釐米子彈可能就由靶心飛到另一個靶上去。而在資安上會面臨的風險是:也許程式滿足了使用者的需求,但是怎麼保證系統沒有被置入預期之外的後門或是潛藏的弱點?

依照開發的 V-Model 所講述的內容,每一個開發的階段都要有其對應的驗証和測試工作。驗証最直接的方式當然就是靜態的審查,以程式碼來說 Code Review 是常見的手法。就像一開始說的,畢竟是人工,花費時間又容易出錯,可以用程式來取代當然是比較理想的做法。舉例來說,Visual Studio 就有提供功能,在繪製 UML 圖形時把程式碼的 Class 與圖形做關連來進行驗証,可以用來確認程式碼是否有依照循序圖的內容來實作。

如果沒有工具的協助,我過去的經驗中還可以使用 Reflection 的技術,由 SD 依照循序圖撰寫測試程式來檢查 Method 中呼叫的內容是否符合規格。可以達到和某些 Test Framework 裡提供透過 Mock 來收集呼叫過程相似的效果,但終究還是屬於靜態掃描的一種,只是以程式來代替人工的 Code Review。所以有一些動態的資訊,例如:透過迴圈被呼叫的次數,是沒有辦法在測試中確認。

所以 PG 不用寫測試程式?我想應該是要看 PG 該寫什麼類型的測試程式,就像之前提到的 PG 應該對寫出來的程式負起責任,最少要有依據來証明所寫的程式碼都有被試著執行過,而且沒有明顯的問題。而 SD 應該要有方法能夠確認 PG 所產出的程式是依照規格實作的,寫測試程式則是可行而且有效率的方法之一。當然這一項工作在一人分飾多角的團隊裡會看起來很沒有意義,程式明明就是自己設計、自己寫程式碼,哪來的溝通問題,還另外再寫程式來檢查自己?又不是時間太多?

這個問題以 SDLC 的觀點來看,會呈現不同的意義。軟體的生命週期在其被宣告死亡、下線之前,之所以被認定為活著的軟體是因為被需要、被使用。既然是被持續地使用,會有需求改變的情況也是很常見的,如果軟體無法回應需求的變化,那就會逐漸地不被需要、無法使用,而走向死亡。所以軟體要持續活著,不斷的修改是一個過程,就算開發時只有一個人統籌所有的工作,在後續的修改過程中還是有可能改由不同的人接手。可以有一個修改後確認的依據,哪怕只是簡單的確認呼叫的次序是否符合規格,對品質的確保都有一定的助益。最少在測試不通過的當下,可以察覺程式是有工作還沒有完成的,不管是正式或測試的程式碼要被調整。

同場加映

其實在不受限於組織規範要求的情況下,BDD、TDD 配合 Scrum 的開發模式是很值得參考的,可以讓測試的工作更有效地被落實在開發流程中。相較於傳統的瀑布式開發流程,導入這些開發的理論可以展現出較快速、較有彈性的使用者需求調整,而不需要再透過凍結需求、繁複的確認、長時間實作等流程來成為不利開發專案進行的因素。由於交付與確認都是相對地即時,使用者可以在一個個合理時間區間的 Sprint 後實際操作所期望之系統對應功能,可以大大的減少產出的結果不符合預期的風險,同時也提高了結案的機率。

0 意見:

張貼留言