閱讀心得:軟體測試的反面模式(anti-patterns)

YH Yu
後端新手村
Published in
15 min readAug 23, 2018

原文:Software Testing Anti-patterns

作者總結了13條 anti-patterns(就是別再這麼幹啦!),不侷限在特定的程式語言或測試框架,而是站在一個宏觀的角度來討論測試時常犯的錯誤。

而我自己是深覺在閱讀時瘋狂中箭。想起那些不斷重構、尋找一些奇技淫巧來寫測試,最後熱情被消磨殆盡的日子,真的是心有戚戚焉。就想把它筆記下來時時停醒自己。

那麼首先,以文章中的最後一條 anti-pattern ,也正是作者撰文的動機作為開場:

Anti-pattern 13:因為不了解(如何設計良好的測試),而輕忽測試

Giving testing a bad reputation out of ignorance

相信許多人在新專案開始時,都懷抱著雄心壯志:「這次一定要好好寫測試,建立一個優雅穩定的開發流程!」但現實總是殘酷的,一開始還算滿意的架構,寫著寫著,測試的程式碼越來越難維護。大把時間都花在設計和修改測試上,最後乾脆放棄。

又或是加入一個既有的專案時,發現團隊不太寫測試(此時往往會發現程式碼中似乎”曾經”有過一些測試),老鳥表示「沒時間繼續弄啦!」

作者和許多覺得測試浪費時間的資深工程師談過,發現他們往往體會過:

  • 每當改動程式碼,就要花大把時間在尋找、修改測試(Anti-pattern 5)
  • 為了追求更高的測試覆蓋率(code coverage),測試的數量多到很厭世(Anti-pattern 6)
  • 跑測試非常耗時,改動程式碼久久才能獲得回饋(Anti-pattern 7)
  • 在需求不明確的情況仍堅持遵守TDD,花太多時間在”想像”該怎麼測試(Anti-pattern 11)

諸如此類令人挫折的情況。

事實上,這些案例極有可能是踩到了作者歸納出的 anti-patterns ,而且是可以避免的。原文共有13條 anti-patterns 來討論測試時常遇到的問題:

  1. 只有單元測試,沒有整合測試
  2. 只有整合測試,沒有單元測試
  3. 測試的種類分佈不符合專案特性
  4. 花太多時間在測試非核心功能
  5. 測試綁定在功能的內部實作上
  6. 過度追求測試覆蓋率
  7. 測試結果不穩定或太耗時
  8. 需要手動介入測試
  9. 輕忽測試程式碼的品質
  10. 上線時遇到的bug沒有納入測試
  11. 盲目遵守TDD
  12. 對使用的測試框架不夠熟悉
  13. 因為不了解(如何設計良好的測試),而輕忽測試

定義單元測試/整合測試

由於如何劃分測項(test case)的類型,並沒有絕對統一的標準。所以首先需要界定本文中所指的單元測試(unit test)和整合測試(integration test)各自為何?

單元測試:

  • class 或 method(邏輯相對單純)
  • 測試的速度快,不需事先建置環境,與目標無關的部分一律 mock/stub

整合測試:

  • component 或 service(邏輯相對複雜)
  • 測試的速度慢,測試前後可能需要建置、清理環境

依經驗法則來看,若需要網路或資料庫連線、進行檔案讀寫,或者使用到外部系統等等,則分類為整合測試

Anti-pattern 1:只有單元測試,沒有整合測試

Having unit tests without integration tests

有些問題只有透過整合測試才能檢查到,例如:

  • Component 的整合
  • 資料庫的 transaction、trigger、procedure
  • Wrong Contract(例如本來串接得好好的外部API有變動)
  • Performance、Timeout

我們需要整合測試去捕捉單元測試沒有涵蓋到的部分

若一個既有的專案完全沒有整合測試,可能的原因和建議:

  • 團隊成員的測試經驗不足,只會寫單元測試:這沒有什麼直接的解法,一個好的團隊至少需由幾位資深的工程師帶領。
  • 曾有過,但後來變得難以維護,成本遠大於效益而放棄:見 anti-pattern 5、7、8。
  • 建置測試環境本身就是一大挑戰,最後只能直接上線(production):有些較特別的專案可能真的不好建置,但絕大多數的情況,尤其在虛擬機器和容器已非常普遍的現在不該如此。因此若發現測試環境難以建置,應該試著重新檢視、調整專案的建置流程。

Anti-pattern 2:只有整合測試,沒有單元測試

Having integration tests without unit tests

理論上,單元測試能測到的,整合測試也可以。但若因此就只寫整合測試會有什麼問題呢?

舉例來說,我們想測試某個 service ,它包含了4個依序執行的 method A~C,“只”使用單元測試或整合測試總共需要的測項數量如下:

CC:Cyclomatic complexity(循環複雜度,這邊可以簡單理解為 method 的輸入輸出可能有幾種情況)

想要將所有情況都覆蓋到,即使這個例子已經非常簡單,整合測試所需要的測項也遠大於單元測試。

若我們不強求測到所有的情況,只挑一些比較重要的路徑來寫整合測試呢?仍會遇到下列問題:

  • 整合測試比較不好去測試極端案例(corner case)。例如想測試 method C 在特定 input 下的運作是否符合預期?這對單元測試來說非常容易(直接塞給C就好了),但整合測試可能要經過複雜的邏輯(method A、B)才能生出這個 input。
  • 整合測試通常比較慢,若整個專案都是整合測試,那測試會非常耗時。
  • 整合測試的複雜度比較高,當測試結果有誤的時候,定位錯誤程式碼不像單元測試般直覺。

小結:綜合 anti-pattern 1、2,我們可以優先寫單元測試,沒辦法覆蓋到的部分則使用整合測試,兩者相輔相成,缺一不可

Anti-pattern 3:測試的種類分佈不符合專案特性

Having the wrong kind of tests

典型的測試金字塔(test pyramid)建議,一個專案應該要有最多的的單元測試,整合測試次之,UI測試最少。

但較務實的做法是依照專案的類型去調整分佈的比例:

不同性質的專案,著重的測試類型也不同

假設要開發一個企業內部用的新系統,而這個系統將會與其它既有的系統、資料庫做整合。大量的整合測試來驗證系統間的溝通是最首要的。

又或者今天是開發parser工具,那麼大量的單元測試來確保資料能正確地被處理就非常合理。

測試金字塔非絕對,重點是將心力放在能為專案帶來更多價值的測試上。

Anti-pattern 4:花太多時間在測試非核心功能

Testing the wrong functionality

如果我們夠幸運,手上的專案已經有完善的測試,實作新功能的時候,順手添加一些測試非常輕鬆寫意。

但絕大多數的情況,專案只有少量或是根本沒有測試才是工程師的日常!想要在龐大的既有程式碼上添加測試,該從何處著手呢?

顯然地,不同職責的程式碼,出錯時的嚴重性(severity)自然也不盡相同。以電商網站為例,金流處理不當而扣了消費者兩次錢,跟推薦的商品不夠理想,造成的後果可是天差地遠!我們可以將程式碼分成三個等級:

  1. 關鍵(Critical)程式碼:改動頻繁、常常壞掉,或含有關鍵商業邏輯
  2. 核心(Core)程式碼:跟關鍵程式碼比起來沒有這麼頻繁的改動,但仍具有一定重要性
  3. 其它程式碼:不太變動,或即是壞掉也沒這麼嚴重

測試的優先順序—在關鍵程式碼達到 100% 的測試覆蓋率前,不需要去考慮別的測試。關鍵程式碼全部都測試到後,再讓核心程式碼也能達到 100% 的測試覆蓋率。在前兩者都完成後,行有餘力再去考慮其它程式碼的測試,或是乾脆把時間拿來實作新功能吧!

這個 anti-pattern 或許聽起來有點廢話,但我們常會”逐一”地為專案添加測試而沒有先仔細思考,每段程式碼的重要性是不同的!

Anti-pattern 5:測試綁定在功能的內部實作上

Testing internal implementation

如同專案本身的程式碼,測試的程式碼一樣需要不定時修改、重構。但若我們”常常”需要做這件事,例如新實作一個功能,卻要修改許多既有的測試來配合,那就有點不正常了!

作者有舉了一個例子說明何謂“測試跟內部實作綁的太緊”,網路上也有需多相關的文章討論。我覺得這部分很取決於語言的特性跟個人的經驗,但有一個公認的方向:需要測試的是外在行為,而非內部實作。

如果我們是針對行為寫測試,一般來說除非程式碼的邏輯有變,否則新功能不應該破壞到原來可以通過的測試。更別說只為了避免破壞原有的測試,而在重構的過程中留下所謂”向前相容”的程式碼了

若發現有些功能不去存取內部實作就很難測試,也可以檢視一下是不是這部分程式碼的耦合度(coupling)太高了。

這也是測試所帶來的好處之一:架構良好的程式碼,通常也易於測試。

Anti-pattern 6:過度追求測試覆蓋率

Paying excessive attention to test coverage

較大型的的專案不太可能達到 100% 的測試覆蓋率,而 100% 的測試覆蓋率也不代表程式碼沒有任何的 bug。

有些測試付出的成本遠小於能帶來的效益(anti-pattern 4)。以 80/20 法則來看,測試的覆蓋率高到一定程度後,就很難再添加有用的測試。

比起測試覆蓋率,作者提出了他用來衡量測試品質的指標:

  • PDWT(Percent of Developers who Write Tests):最重要,團隊中有多少工程師會寫測試?
  • PBCNT(Percent of Bugs that Create New tests):有多少實際上遇過的bug 確實地轉成測試了?(anti-pattern 10)
  • PTVB(Percent of Tests that Verify Behavior and not implementation):有多少測試是針對行為而非內部實作?(anti-pattern 5)
  • PTD(Percent of Tests that are Deterministic to total tests):有多少測試,失敗時能真正代表程式碼出錯了?(anti-pattern 7)

雖然「跑過測試前程式碼都不算完成」聽起來十分美好,但在現實中,過度執著於測試覆蓋率只會讓我們感到精疲力盡,也沒有必要。

Anti-pattern 7:測試結果不穩定或太耗時

Having flaky or slow tests

「咦!這次測試怎麼沒跑過?之前還好好的啊,再跑一次看看!」
「嗯,再跑一次就過了呢!但是剛剛怎麼了?我程式碼到底有沒有錯?」

撇除測試本身就有 bug 的情況,時好時壞的測試絕對是一大問題。這會讓工程師不再信任測試結果,最終放棄測試。測試要能帶來價值,它的結果必須是穩定的。

沒有通過的測試必須代表某段程式碼有誤,而不該是“再跑一次試試看”。

舉例來說,若有一個 method 會去呼叫第三方的 API 來取得天氣預測,結果可能有晴天、陰天和雨天,然後做出對應的處理。那麼可以使用固定的假資料來驗證該 method 可以正確地處理三種天氣,而不是每次隨機驗證其中一種情況。至於天氣的 API contract 則另外獨立測試(隔離)。

類似的問題還有過度緩慢的測試,我們開發過程中,需要從測試得到立即的回饋。若需要耗費的時間太久,自然工程師就會不願意再執行測試。

將不穩定或緩慢的測試(通常是整合或 UI 測試)從常規測試隔離出來吧!

Anti-pattern 8:需要手動介入測試

Running tests manually

作者這部分多在探討何謂 CI/CD 的完整實踐,並指出許多公司雖然宣稱導入了 CI/CD,但在測試環境的建置、和 QA 團隊的配合等等,還是有許多需要人為介入的地方。

舉來來說,一個專案不應該停在”等待 QA 團隊驗證”的狀態,QA 團隊的測試也要是 CI/CD 的一環。正如同實作新功能時,工程師必須撰寫相關的單元、整合測試。QA 團隊一樣也需要為新功能添加自動化測試。在程式碼提交到版本控制系統時,整個過程都在 build server 中自動完成並產出報告。不會有需要手動去將專案”送到”下一個環節的情況。

測試的過程中,需要手動介入的程度越高,可能導致:

  1. 測試繁瑣、耗時
  2. 額外維護文件來說明測試步驟
  3. 只要是人,就有可能出錯(不熟該專案的測試流程、文件有疏漏等等)

雖然整個 CI/CD 的流程,主要還是取決於組織的架構及所需的測試類型。但對於我們工程師而言,至少單元、整合測試的部分必須做到全自動化。

像是測試環境的建置及清理、結果的分析等動作,都不該需要手動去處理。以確保測試的品質及效率。

Anti-pattern 9:輕忽測試程式碼的品質

Treating test code as a second class citizen

不知為何,我們常常用相對隨意的態度去撰寫測試程式碼。像是容忍大量重複的程式碼或偷懶使用hardcode等等。這就是陷入了所謂「將測試程式碼當作二等公民」的錯誤。

測試程式碼會伴隨著專案的程式碼一直存在,同樣需要長期地維護。累積的技術債最後依然會反饋到我們身上。

因此規劃出良好的架構來因應後續的修改、重構是非常重要的!像DRYKISSSOLID等常見的原則,在測試程式碼上並無區別。

為了團隊和將來的自己好,審慎地規劃、撰寫測試程式碼吧!

Anti-pattern 10:上線時遇到的bug沒有納入測試

Not converting production bugs to tests

在上線時(production)才碰到的bug,非非非非非常地有價值!為什麼?

它肯定是我們目前測試沒涵蓋到的,且很可能是關鍵程式碼(anti-pattern 4)。納入測試後可以保證未來不會在同一個地方再次出錯。

唯一的例外是跟程式碼正確性無關的 bug,例如伺服器的 configuration 有誤,除此之外的 bug 都應該在修復的同時將它納入測試中。

若一個既有的專案還完全沒有任何測試的話,也不用多想了,就從這些曾經在上線時遇過的 bug 開始吧!

Anti-pattern 11:盲目遵守TDD

Treating TDD as a religion

實作功能時,下列四個時機點什麼時候撰寫測試程式碼最好呢?

  1. 之前就先寫好測試
  2. 實作的同時一邊寫測試
  3. 完成再補測試
  4. 根本不寫測試

作者的答案是都可以!對於那些不太重要的程式碼,甚至完全不寫測試也不是什麼大問題(anti-pattern 4)。

TDD是一個非常好的習慣但並非教條。

當我們面對的需求不太明確,或還處於實驗階段(今天寫的程式碼可能明天就全部砍掉重練)時,堅持TDD反而會耗費過多的心力在”想像”測試上。

作者(也是大多數人)通常的做法是:

  1. 實作到一個段落
  2. 撰寫測試
  3. 故意讓預期結果相反,或把實作的原始碼關鍵部分註解掉,看看測試是否會失敗
  4. 改回正確的結果,期望順利通過測試

反覆迭代以上過程,即是一個良好的開發流程。只要測試的觀念正確,不是只有TDD才是唯一的做法。

Anti-pattern 12:對使用的測試框架不夠熟悉

Writing tests without reading documentation first

一個專業的工程師必須能充分掌握自己所使用的函式庫(library)和框架(framework)。我們願意花時間去了解它們如何使用,甚至探究背後的原理來改進用法。

但在面對測試時,類似 anti-pattern 9。我們常常只看了一兩頁文件和幾個範例後就直接開始寫測試了。專案內充滿了各種自己寫的“utility methods”,土法煉鋼地實作一些其實測試框架本身就有提供的功能。

像這樣重複發明自己的輪子,容易造成開發人員間的測試不一致、測試經驗無法轉換到其它專案等等問題。而誤用了測試框架,也可能導致測試本身有bug,出現令人摸不著頭緒的結果。

為了避免這些情況,閱讀測試框架的文件,了解諸如:

  • 建置和清理環境
  • 設置mock/stub
  • 支援的 configuration
  • 驗證非同步的結果
  • 多個測項如何組織編排
  • 各個測項是依序還是平行執行,環境是獨立或共用

像這些測試常用到的功能,大多的測試框架都有自己的使用方式。除此以外才需要去寫專屬組織/專案的 utility methods。

總結

在試圖改進自己測試程式碼的過程中,深深體會到想要寫出良好的測試,要求的技術和經驗完全不下於實作功能本身。對專案的邏輯架構、程式語言和框架要有足夠認識外,也須了解測試的觀念、手法和工具等等。

而在實踐測試的過程中,除了對撰寫的程式碼的正確性更有把握了。同時也是一個機會,讓自己以一個旁觀者的角度,重新審視程式碼是否足夠易讀、好用,甚至優雅。

雖然這邊大多的 anti-patterns 可能乍看都蠻理所當然的,但自己以前在寫測試的時候常常不加思索就開始埋頭苦幹。最後才覺得不夠乾淨、不好維護,就整天在打掉重練。

最後就把這13條 anti-patterns,轉換成正向的原則,當作一個檢查表吧:

遵循良好的原則去規劃測試,可以少走許多冤枉路

如果這篇文章對你有所幫助的話,歡迎拍手讓我知道,最多可以拍50下喔👏👏

--

--

YH Yu
後端新手村

Ever tried. Ever failed. No matter. Try Again. Fail again. Fail better.