天天看點

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

點選檢視第一章 點選檢視第三章

第2章

建構安全體系

測試是一項技能,雖然這可能會讓一些人感到驚訝,但這是一個事實。

—Mark Fewster and Dorothy Graham,《自動化軟體測試》,1999

我将測試作為本書的開篇可能會讓一些讀者感到意外,但請相信我,這樣做有幾個好處。在過去的幾年中,測試已經成為衡量軟體品質好壞的一個重要名額。一個好的測試政策所帶來的好處是巨大的。任何測試(前提是認真設計過的)對代碼品質的提高都是有好處的。在保證軟體品質的所有措施中,測試是最不可或缺的一環,在本章中我将為你解釋這是為何。

請注意,本章所講的内容通常稱為POUT(Plain Old Unit Testing,普通的單元測試),而不是TDD(Test-Driven Development,測試驅動開發,一種軟體開發模式),後者将在本書的後面章節另外讨論。

2.1 測試的必要性

1962:NASA的水手一号

水手一号太空飛船于1962年7月22日發射升空,計劃飛向金星執行星際探索任務。由于它的定向系統出了一個問題,導緻它的Atlas-Agena二級發射火箭工作異常,并在發射後不久便與地面控制中心失去聯系。

幸運的是,在火箭設計與建造階段就已經考慮到了這種情況。于是發射火箭的導航系統接過了控制權并開啟自動駕駛模式。然而,由于導航系統軟體設計問題,它下達了一個錯誤的控制指令,導緻火箭偏離航線并且不能調整方向,而火箭的前進方向變成了地球上的人口密集區域!

在火箭發射293秒後,現場的地區安全官員下達了銷毀火箭的指令。在NASA的一份檢測報告中顯示,這次事故是由于控制系統源代碼中的一個拼寫錯誤導緻的,代碼中缺少了一個“-”号。而這一失誤造成的損失高達1850萬美元,在當時這可是一筆不小的損失。

如果問一些軟體開發人員為什麼說軟體測試是有好處的而且是有必要的,我想最普遍的回答就是能夠減少故障(bug)、錯誤(error)以及缺陷(flaw)。毫無疑問,這個回答基本正确:軟體測試是QA的一個組成部分。

軟體的bug通常是令人不愉快的。程式的錯誤行為通常讓使用者大為惱火,比如無效的輸出或者使用者最讨厭的不定時崩潰的問題,甚至諸如在文本框中的文字被截斷這樣的小問題,也會讓使用者在日常工作中痛苦不堪。最終導緻的結果就是使用者滿意度下降,甚至使用者轉而使用其他産品。除了經濟上的損失外,軟體開發商的專業印象也會是以大打折扣,最糟糕的情況是,公司營運困難,以緻大量裁員。

1986:THERAC-25醫用加速器災難

這一事件可以說是軟體開發曆史上最轟動的一次失敗。THERAC-25是一款放射治療裝置,它由加拿大國有企業,加拿大原子能有限公司,Atomic Energy of Canada Limited (AECL)于1982年至1985年研發并生産,共生産了11台裝置以供美國和加拿大的診所使用。

由于品質保證體系不完善,以及開發過程中存在的其他問題,使得它的控制系統中存在嚴重的bug,直接導緻三名病人死于過量的輻射,還有三名患者由于輻射遭受健康永久的、嚴重的損壞。

此次事件的調查表明,這款裝置的控制系統由同一個人開發并測試,這是導緻這一悲劇的諸多因素之一。

一提起電子裝置,人們首先想到的就是台式電腦、筆記本電腦、平闆或者智能手機,而說到軟體産品,人們聯想到的就是線上購物、辦公軟體以及資訊商務系統。

但是這些隻占我們在日常生活中接觸到的軟體和電子産品的一小部分,目前使用的絕大部分軟體都是通過控制實體裝置與外界相連。我們的生活由軟體掌控。可以這麼說,目前軟體影響着我們所有人。軟體無處不在并逐漸成為我們生活中必不可少的一部分。

當我們走進電梯,我們的生命就由軟體掌控。飛機也由軟體控制着,全世界的空中交通管制系統更是離不開軟體的管理。目前,汽車上也存在着大量與網際網路相連的控制軟體,為我們的安全保駕護航。空調、感應門、醫療裝置、火車、工廠中的生産線……無論我們想幹什麼,都會不由自主地與軟體産生聯系。随着數字革命的進步和物聯網(IoT,Internet of Things)的快速發展,我們與軟體的聯系将會更加密切,無人駕駛汽車就是一個很好的例子。

毫無疑問的是,在這種軟體密集型系統中,一旦出現bug将導緻災難性的後果。在這些系統中,任何一個錯誤都可能對我們的身體和生命構成威脅。試想一下,一旦飛機的控制系統出現異常,很可能導緻成百上千的人死于空難,而引發事故的原因可能隻是飛機自動巡航系統的if語句條件判斷錯誤。在這種複雜的控制系統中,軟體的品質是沒有任何商量餘地的,完全沒有商量餘地!

即便是在對人身安全要求沒有那麼嚴格的系統中,bug也會造成難以估量的損失,尤其是需要日積月累才會表現出來的bug。不難想象,金融軟體中的漏洞将會成為且正在成為當今世界銀行危機的導火索。假設一個大銀行的金融軟體由于bug導緻每次送出請求時會重複兩次,而這種行為在幾天後才被發現,這将會導緻什麼後果呢?

AT&T電話網絡的崩潰事故

1990年1月15日,美國電話電報公司(AT&T)的長途電話網絡崩潰,導緻9小時内高達7500萬次的通話請求得不到響應。而導緻這一惡果的原因,僅僅是AT&T在1989年12月,将全部114個計算機控制的交換裝置更新到第四代電子交換系統(4ESS)時,部署在代碼中的一條break語句。這一問題于1月15日在AT&T公司的曼哈頓控制中心首先爆發出來,随後引起連鎖反應,并導緻整個通信網絡中近半數的裝置當機。

在此事故中,估計損失6000萬美元,而在通信網絡癱瘓的9個小時内産生的經濟損失遠高于這一數字。

2.2 測試入門

在軟體開發項目中有不同級别的品質保證措施,這些不同級别的品質保證措施通常用金字塔的形式形象地表述,也就是所謂的測試金字塔。這一基本概念是由Scrum Alliance創始人之一、美國軟體開發工程師Mike Cohn提出的,他曾在其著作《Succeeding with Agile》[Cohn09]中描述了測試金字塔,Cohn用測試金字塔描述了高效的軟體測試所需的自動化程度。在随後的幾年裡,測試金字塔得到了進一步發展,如圖2-1所示。

當然,金字塔形狀并非偶然,它背後的資訊是,你要比其他類型的測試進行更多次的低層次單元測試(幾乎100%代碼覆寫率),但是為什麼會這樣?

實踐表明,關于測試實施和維護的總成本是朝着金字塔頂端增長的。大型系統的測試和手動的使用者驗收測試通常是很複雜的,并且通常需要大規模的組織又不易實施自動化。例如,一個自動化的UI測試是很難編寫的,通常是比較脆弱的,而且相對較慢。是以,這些測試通常是手動進行的,它适合于客戶稽核(驗收測試)和QA定期的探索性測試,但是在日常開發過程中使用它們太耗費時間且代價昂貴。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

此外,大型系統測試或UI驅動測試完全不适合檢查整個系統中所有可能的執行情況。軟體系統中有太多處理各種可能情況的代碼、異常和錯誤處理,交叉相關問題(安全性、事務處理、日志記錄……)以及其他所需的輔助功能,但這些通常是無法通過普通使用者接口去觸發的。

非常重要的一點是,如果系統級别的測試失敗了,則可能難以找到錯誤的确切原因。系統測試通常基于系統的測試用例,執行用例期間涉及許多元件,這意味着要執行數百甚至數千行代碼,這其中的哪一行代碼導緻了測試失敗?這個問題通常無法輕易回答,它需要花費時間和代價去分析。

不幸的是,在一些軟體開發項目中,你會發現退化的測試金字塔,如圖2-2所示。 在這樣的項目中,人們把更多的精力投入到了較高層次的測試中,而忽略了基本的單元測試(Ice Cream Cone Anti-Pattern)。在極端情況下,他們完全不做單元測試(Cup Cake Anti-Pattern)。

是以,由一系列可選而有用的測試元件作為支撐,且基于廣泛而廉價、精心制作、快速、定期維護、能完全自動化的單元測試的測試平台,可以成為確定軟體系統高品質的堅實基礎。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

2.3 單元測試

沒有測試的“重構”不能稱之為重構,它僅僅是到處移動垃圾代碼。

—Corey Haines (@coreyhaines), December 20, 2013, on Twitter           

單元測試是一小段代碼,在特定上下文環境中,單元測試能夠執行産品的一部分代碼。單元測試能夠在很短的時間内,展示出你的代碼是否達到了預期的運作結果。如果單元測試覆寫率非常高,那麼,你就可以在很短的時間内,檢查正在開發的系統的所有元件是否運作正常。單元測試有許多優點:

□大量的調查和研究已經證明,在軟體部署運作以後修複bug的代價,比在單元測試階段修複bug的代價要高得多。

□單元測試能夠給出關于整個代碼庫(已經寫了單元測試的那一部分代碼)的即時回報,如果單元測試覆寫率足夠高(大約100%),開發人員在幾秒鐘内就能知道代碼庫中的代碼是否能夠正常運作。

□單元測試讓開發人員有足夠的信心重構代碼,而不必擔心因重構而帶來的錯誤。事實上,沒有單元測試的重構是非常危險的,嚴格來講,不能稱之為重構。

□高覆寫率的單元測試,可以有效防止陷入耗時和讓人手足無措的代碼調試中,可以大大降低長時間使用調試器調試的問題。當然,沒有人能夠完全避免使用調試器調試。調試器可以用來分析細微的問題,或者找出執行失敗的單元測試的原因。但是,調試器不應該是確定代碼品質的關鍵工具。

□單元測試是一種可以被執行的産品文檔,因為單元測試精确地展現了代碼是如何被設計和使用的。可以說,單元測試是一組非常有用的示例代碼。

□單元測試可以很容易地檢測回歸測試的代碼,也就是說,單元測試能夠很快地檢查更改代碼後引發的異常。

□單元測試可以促進實作整潔且良好的接口,可以幫助開發人員避免檔案間不必要的依賴關系。可測試性的設計也是良好的可用性的設計,也就是說,如果一段代碼可以很容易地與測試夾具內建,那麼,這段代碼通常也可以很容易地內建到産品的代碼。

□單元測試能夠促進開發。

上述提到的最後一個優點看似是沖突的,這裡做一下解釋,單元測試能夠促進開發—這似乎是不可能的事情,也不合乎正常的邏輯。

毫無疑問,編寫單元測試意味着成本的投入。首先,最重要的是,管理者隻看到了這種成本的投入,卻并不明白為什麼開發人員應該為測試投入時間。特别是在項目的開始階段,單元測試對開發速度的促進幾乎是看不見的。在項目的早期階段,當系統的複雜度較低并且大部分元件都工作得很好的時候,編寫單元測試看起來隻是無意義的付出。但是,時代正在改變……

當系統變得越來越龐大(超過100 000行代碼量)且系統複雜度增加時,了解和驗證系統變得越來越困難(還記得我在第1章描述的軟體熵嗎?)。通常,當不同開發團隊中的許多開發人員協同開發一個龐大的系統時,他們每天都要面對其他開發者編寫的代碼,如果沒有單元測試,這将成為一項令人沮喪的工作。我确信,團隊中的每個人都知道那些愚蠢的、無休止的調試,在單步模式中一遍又一遍地調試代碼,同時一次又一次地分析變量的值……這非常浪費時間!并且,這也将大大降低開發速度。

特别是在軟體開發的中後期,以及在産品傳遞後的維護階段,良好的單元測試會展現出它們積極的一面。在編寫單元測試後的幾個月或幾年裡,當一個元件或産品的API需要更改或擴充的時候,單元測試能夠最大程度地節省時間。

如果單元測試覆寫率很好,那麼開發人員編輯一段自己寫的代碼或别人寫的代碼,影響不會太大。良好的單元測試有助于開發人員快速了解另一個人編寫的代碼,即使這段代碼是在三年前編寫的。如果單元測試失敗,通過失敗的資訊,能夠準确知道失敗的地方。開發人員可以相信,如果所有的單元測試都通過了,那麼所有的函數都可以正常運作,煩人的調試就會變得不常見。調試主要用于分析那些錯誤現象不直覺的失敗的單元測試,這将是一件很有趣的事情。單元測試具有正向的促進作用,能給我們帶來更快更好的結果,開發人員也将對基礎代碼有更大的信心,并對此感到滿意。如果更改需求或加入新的特性呢?也沒有問題,因為單元測試能夠快速、頻繁地完成産品的單元測試,并且能夠保證産品的品質。

單元測試架構

C++的單元測試架構有很多種,例如:CppUnit、Boost.Test、CUTE、Goole Test等。

一般而言,幾個單元測試架構的集合稱為xUnit,所有遵循所謂的xUnit的基本設計的單元測試的架構,其結構和功能都是從Smalltalk的SUnit繼承而來的。抛開實際情況不談,本章内容沒有涉及某個單元測試架構,因為本章内容适用于一般的單元測試,單元測試架構完整而詳細的對比内容将超出本書的範圍。進一步講,選擇一個合适的單元測試架構取決于很多因素。例如,如果以最小的工作量和最快的速度添加新的單元測試,對你來說是非常重要的,那麼,最小的工作量和最快的速度将成為你選擇單元測試架構的主要因素。

2.4 關于QA

開發人員可能會認為:“為什麼我要測試我的軟體?我們有測試人員和品質保證(QA,Quality Assurance)部門,這是他們的工作。”

關鍵問題在于:軟體品質隻是QA部門關注的問題嗎?

簡單明了的答案是:不是!

我以前說過這個問題,現在我再說一遍,盡管你的公司可能有一個單獨的QA小組來測試軟體,但開發組的目标應該是QA沒有發現任何缺陷。

—Robert C. Martin,《The Clean Coder》[Martin11]

将一個已知的有缺陷的軟體移交給QA是非常不專業的行為,專業的開發人員永遠不會把保證系統品質的責任推給其他部門。相反,專業的軟體開發人員與QA的人建立了富有成效的合作夥伴關系,他們緊密合作,互相補充。

當然,傳遞100%無缺陷的軟體是一個很難達到的目标,QA有時會發現一些問題,這也很好。QA是我們安全體系的第二道防線,他們會檢查以前的品質保證措施是否充分有效。

我們可以從錯誤中學習并變得更好,專業開發人員通過修複QA發現的缺陷來立即補救這些品質問題,并通過編寫自動化單元測試在未來捕獲這些異常。然後,他們應該仔細考慮這個問題:“以上帝的名義,我們忽略的這個問題是如何出現的?”本次學習總結的成果應該用于以後改善開發的品質。

2.5 良好的單元測試原則

我看到過很多沒有任何意義的單元測試代碼。單元測試應該為項目帶來價值,為了實作這一目标,單元測試應該遵循一些基本原則,下面我将描述這些基本原則。

2.5.1 單元測試的代碼的品質

高品質地要求産品代碼,同樣高品質地要求單元測試的代碼。更進一步地講,理論上,産品代碼和測試代碼之間不應該有任何差別—它們生而平等。我們不能說這是産品代碼,那是測試代碼,不能把原本屬于一體的代碼分開,千萬不要那樣做!将測試代碼和産品代碼分成兩類的思想是以後項目中忽略單元測試的根本所在。

2.5.2 單元測試的命名

如果單元測試失敗,開發人員希望立即知道以下資訊:

□測試單元的名稱是什麼?誰的單元測試失敗了?

□單元測試測試了什麼?單元測試的環境是怎麼樣的(測試場景)?

□預期的單元測試結果是什麼?單元測試失敗的實際測試結果又是什麼?

是以,單元測試的命名需要具備直覺性和描述性,這是非常重要的,我建議建立所有單元測試的命名标準。

首先,以這樣的方式命名單元測試子產品(依賴于單元測試架構,稱為測試用具或測試夾具)是很好的做法,這樣單元測試代碼很容易衍生于單元測試架構。單元測試應該有一個像Test的名字,很顯然,必須用測試對象的名稱來替換占位符。例如,如果被測試的系統(SUT)是Money機關,與該測試單元對應的單元測試夾具,以及所有的單元測試用例都應該命名為MoneyTest(見圖2-3)。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

除此之外,單元測試必須有直覺的且易了解的名稱,如果單元測試的名稱或多或少沒有意義,比如testConstructor()、test4391()或sumTest(),那麼單元測試的名稱不會有太大的幫助。通過下面的建議,可以為單元測試取一個好名字。

一般來說,可以在不同場景下使用多種用途的類,一個直覺的且易了解的名稱應該包含以下三點:

□單元測試的前置條件,也就是執行單元測試之前的SUT的狀态。

□被單元測試測試的部分,通常是被測試的過程、函數或方法(API)的名稱。

□單元測試預期的測試結果。

遵循以上三點建議,測試過程或方法的單元測試命名的模闆,如下所示:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

下面是幾小段示例代碼:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

另一個建構直覺的且易了解的單元測試名稱的方法,就是在單元測試名稱中顯示特定的需求。這樣的單元測試的名稱通常能夠反應應用程式域的需求,例如,單元測試名稱來自于利益相關者的需求。

下面是一些具有特定域需求的單元測試名稱的示例:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

當你閱讀上面這些單元測試的名稱時,即使在沒有單元測試代碼的情況下,也是非常直覺的且易了解的。從這些單元測試的名稱中可以很容易地得到許多有用的資訊。如果單元測試失敗,這樣的命名将會是一個很大的優勢。幾乎所有的單元測試架構都會把失敗的單元測試的名稱輸出到标準輸出(stdout),是以,這種直覺的且易了解的單元測試命名,極大地促進了錯誤的定位。

2.5.3 單元測試的獨立性

每個單元測試和其他的單元測試都必須是獨立的。如果單元測試之間是以特定的順序執行的,那麼這将是緻命的,因為一個單元測試的執行依賴于前一個單元測試的影響,例如,改變了類的狀态,改變了上下文環境等。永遠不要編寫“一個單元測試的輸出是另一個單元測試的輸入”的單元測試。當離開一個單元測試的時候,不應該改變測試單元的狀态,這是後續單元測試執行的先決條件。

主要的問題可能是由全局狀态引起的,例如,在單元測試中使用單例或使用了靜态的成員。單例不僅增加了單元測試之間的耦合度,還經常會保持一個全局的狀态,單元測試之間因為全局狀态而變得互相依賴。例如,如果一個全局狀态是某個單元測試成功執行的先決條件,目前面的單元測試成功執行并修改了這個全局狀态時,那麼接下來的單元測試就會執行失敗。

尤其是在遺留系統中,經常雜亂無章地使用單例模式,這就引出了一個問題:如何才能擺脫這些雜亂無章的對單例的依賴關系,讓我們的代碼更易于測試呢?這是在第6章的依賴注入部分讨論的一個重要問題。

處理遺留系統

如果你在所謂的遺留系統中添加單元測試時遇到許多困難,我強烈推薦Michael C寫的《Working Effectively with Legacy Code》[Feathers07]。這本書包含了許多政策,用于處理大型的、未經測試的遺留代碼,這本書還包括了24種依賴中斷技術。當然,這些政策和技術超出了本書的範圍。

2.5.4 一個測試一個斷言

我知道這是一個有争議的話題,但我會試着解釋為什麼我認為這很重要,我的建議是限制一個單元測試隻使用一個斷言。如下所示:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

有人可能争辯說我們還可以檢查其他比較運算符(例如,Money :: operator==())在該單元測試中是否正常工作,隻需添加更多斷言就可以輕松實作這一點,如下所示:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章
帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

我認為這種測試方法的問題是顯而易見的:

□如果由于某些原因而導緻測試失敗了,開發人員可能很難快速找到錯誤原因。最重要的是,前面一個斷言的錯誤掩蓋了其他的錯誤,也就是說,它隐藏了後續的斷言,因為測試的執行被中斷了。

□正如單元測試的命名一節(2.5.2節)中所述,我們應該以精确且富有表現力的方式命名測試。通過多個斷言,單元測試确實可以測試很多東西(順便說一下,這違反了單一職責原則,參見第6章),并且很難為它找到一個好的名字,上面的...testAllComparisonOperators()仍然不夠精确。

2.5.5 單元測試環境的獨立初始化

該規則有點類似于單元測試的獨立性,在一個幹淨整潔的單元測試運作完成後,與該單元測試相關的所有狀态都必須消失。更具體地說,在運作所有單元測試時,每個單元測試都必須是應用程式的一個獨立的可運作的執行個體,每個單元測試都必須完全自行設定和初始化其所需的環境,這同樣适用于執行單元測試後的清理工作。

2.5.6 不對getters和setters做單元測試

不要為類的簡單的getters(通路器)和setters(設定器)編寫單元測試,如下所示:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

你真的認為這樣簡單而直接的方法會出問題嗎?這些成員函數通常非常簡單,是以為它們編寫單元測試是愚蠢的。此外,這些簡單的getters和setters已經隐式地通過其他且更重要的單元測試進行了測試。

注意,我剛才說到,測試常見且簡單的getters和setters是沒有必要的,但有時getters和setters并不是那麼簡單。根據我們稍後将讨論的資訊隐藏原則(參見3.5節“資訊隐藏原則”),如果getter是簡單的,或者它必須通過複雜的邏輯來确定它的傳回值,那麼它就應該被隐藏起來。是以,有時顯式地為一個getters或setters寫出單元測試是很有用的。

2.5.7 不對第三方代碼做單元測試

不要為第三方代碼編寫單元測試代碼!我們不必驗證庫或架構是否按預期的那樣工作。例如,我們可以問心無愧地大膽假設,調用C++标準庫中的成員函數std::vector::push_back()無數次都不會出錯。相反,我們可以預測第三方代碼都有自己的單元測試。在你的項目中,不使用那些沒有自己的單元測試和品質可疑的庫或架構,這是一種明智的架構選擇。

2.5.8 不對外部系統做單元測試

對于外部系統,道理也和第三方代碼一樣,不要為你要開發的系統環境中的第三方系統編寫測試代碼,這不是你的責任。 例如,如果你的财務軟體使用一個通過Internet連接配接的現有的外部貨币轉換系統,那麼你不應對這個外部系統進行測試,這樣的系統不能提供明确的結果(貨币之間的轉換因子每分鐘都在變化),并且由于網絡問題可能根本無法對其進行測試,我們不對外部系統負責。

我的建議是無視這些東西(見本章後面的測試使用的虛假對象章節),測試你自己的代碼,而不是他們的代碼。

2.5.9 如何處理資料庫的通路

目前,許多軟體系統都包含(依賴)資料庫系統,将大量的對象和資料長期存儲到資料庫中,進而可以友善地從資料庫查詢這些對象和資料,當系統被關閉以後,這些對象和資料也不會丢失。

一個很重要的問題是:在單元測試期間,我們應該如何處理資料庫的通路?

我對這個問題的第一個也是最重要的建議是:能不使用資料庫進行單元測試,就不使用資料庫進行單元測試。

—Gerard Meszaros, xUnit Patterns

在單元測試過程中,資料庫可能會引起各種各樣的問題。例如,如果許多單元測試使用同一個資料庫,那麼,這個資料庫就會趨向于一個大的集中式的存儲系統,這些單元測試必須為不同的目的而共享這個資料庫。而這種共享,可能會對本章前面讨論過的單元測試的獨立性産生不利的影響,可能很難保證每個單元測試所需的前提條件。一個單元測試的執行,可以通過共享的資料庫對其他的單元測試産生不好的影響。

另一個問題是,資料庫的存儲速度是緩慢的。通路資料庫的速度比通路計算機記憶體的速度要慢得多。與資料庫互動的單元測試往往比完全不依賴于資料庫的單元測試慢得多。假設你有幾百個單元測試,每個單元測試需要額外的平均500毫秒的時間,這很有可能是由于查詢資料庫導緻的。總之,通路資料庫的單元測試比沒有通路資料庫的單元測試要多花費幾分鐘的時間。

我的建議是模拟資料庫(參見本章後面5.2.12節“測試替身”),隻在記憶體中執行所有的單元測試。不要擔心,如果系統中存在資料庫的使用,那麼,在系統內建和系統測試級别會測試資料庫

2.5.10 不要混淆測試代碼和産品代碼

有時開發人員産生了一個想法,用測試代碼來裝備他們的生産代碼。例如,在測試期間,一個類可能以如下方式包含了處理協作類的依賴關系的代碼:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

DataAccessObject是特定DAO(資料通路對象)的抽象基類,在本例中為CustomerDAO和FakeDAOForTest,後者就是所謂的測試替身(fake object),這是一個用于測試的虛拟對象(參見本章後面的2.5.12節),目的是替換真正的DAO,因為我們不想測試它,并且我們不想在測試期間儲存Customer的資料(謹記我關于資料庫的建議)。使用兩個DAO中的哪一個由布爾資料成員inTestMode控制。

這段代碼雖然可行,但這一解決方案有幾個缺點。

首先,我們的生産代碼會混雜測試代碼,雖然初看并不顯眼,但它會增加産品複雜度并降低代碼的可讀性。我們需要一個額外的成員來區分系統的測試模式和生産使用,這個布爾成員與客戶無關,更不用說系統的域了。而且不難想象系統中的許多類都需要這種類型的成員。

此外,Customer類依賴于CustomerDAO和FakeDAOForTest,你可以在源代碼頭部的包含檔案清單中看到它,這意味着在生産環境中測試虛拟類FakeDAOForTest也是系統的一部分,我們寄希望于測試替身的代碼永遠不會在生産中被調用,但是它确實被編譯、連結并部署在了生産中。

當然,也有一些更優雅的方法來處理這些依賴關系,并保證生産代碼不受測試代碼的影響。例如,我們可以在Customer::save()中注入特定的DAO作為一個參考參數。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

或者,也可以在構造Customer類型的執行個體期間完成。在這種情況下,我們必須将DAO的一個引用作為類的成員屬性。此外,我們必須通過編譯器禁止自動生成預設構造函數,因為我們不希望Customer的任何使用者可以建立一個未正确初始化的執行個體。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章
帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

deleted函數[C++11]

在C++中,如果有些類型成員沒有被定義,編譯器會自動為這些類型生成所謂的特殊成員函數(預設構造函數、拷貝構造函數、拷貝指派運算符和析構函數)。從C++11開始,這個特殊成員函數清單多了移動構造函數和移動指派運算符。C++11(及更高版本)提供了一種簡單且聲明性的方法來阻止自動建立任何特殊成員函數、普通成員函數和非成員函數,你可以删除它們。例如,你可以通過以下方式阻止建立預設構造函數:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

另一個例子:你可以删除new運算符以防止在堆上動态配置設定一個類:

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

第三種替代方案是特定的DAO可以由Customer類已知的一個工廠(請參閱第9章中有關設計模式的Factory模式部分)來建立。如果系統在測試環境中運作,我們可以從外部配置Factory以建立所需的DAO。無論你選擇哪種可能的解決方案,Customer類都能與測試代碼脫離,Customer與特定的DAO沒有依賴關系。

2.5.11 測試必須快速執行

在大型項目中,單元測試的量級早晚會達到上千條。這在軟體品質保證方面是有促進作用的。但比較尴尬的是,測試人員也許直到送出代碼的時候才會執行它們,因為這項工作耗費的時間過于漫長。

很顯然,測試花費的時間和團隊的生産力有很大的關系。如果運作單元測試需要花費15分鐘、30分鐘甚至更多,那麼開發人員的工作進度就會由于長時間等待測試結果而受到影響。即使執行每個單元測試平均“隻”需要幾秒鐘,那麼執行完1000個測試用例也需要超過8分鐘。這就意味着如果這些測試案例每天需要執行10次的話,那麼将有1.5小時的時間花在等待上。結果就是,開發人員會減少單元測試的次數。

我的建議是:測試必須快速執行!單元測試必須為開發者建立一套快速回報機制。一個大型項目的所有單元測試的執行時間最多花費3分鐘,當然,越少越好。在開發過程中,為了更快地執行本地的測試用例,測試架構要提供一種簡便的方法來暫時關閉不相關的測試組。

毫無疑問,在最終産品釋出前,測試平台上的所有測試用例都應該執行到。一旦測試用例執行失敗,應當立即通知開發團隊。可以通過電子郵件提醒,或在顯眼的地方标記出來(比如,在牆上的顯示屏上展示出來,或者通過測試平台控制訓示燈提醒開發人員)。即使隻有一個測試用例不通過,也不能釋出産品!

2.5.12 測試替身

單元測試應該隻被稱為“單元測試”,被測試單元在單元測試執行期間,與依賴系統完全無關,也就是說,被測試單元不依賴其他單元或外部系統。例如,雖然在系統內建測試的時候,資料庫的測試不是必要的,但是這是內建測試的目的,是以禁止在實際單元測試期間通路資料庫(如查詢,參見2.5.9節“如何處理資料庫的通路”)。是以,要測試的單元與其他子產品或外部系統的依賴性應該被所謂的測試替身(Test Doubles)替換,測試替身也被稱為僞對象(Fake Objects)或假模型(Mock-Ups)。

為了以一種優雅的方式使用測試替身,盡量達到被測試單元之間的松耦合(參見3.7節“松耦合原則”)。例如,抽象(如純抽象類形式的接口)可以在通路單元測試不關心的合作者的時候被引入,如圖2-4所示。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

假設你想開發一個應用程式,該應用程式使用外部的Web服務進行貨币轉換。在單元測試期間,不能正常使用外部服務,因為貨币轉換因子每秒都在發生變化。此外,通過網際網路查詢服務是比較慢的,很有可能失敗,而且也不能模拟邊界值的情況。是以,在單元測試期間,必須用測試替身替換實際貨币轉換服務。

首先,我們必須在代碼中引入一個可變點,可以用一個測試替身替換與貨币轉換服務的通信子產品,通常可以使用一個接口達到這個目的,該接口在C++中是一個僅包含純虛成員函數的抽象類。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

通過Internet通路被封裝在實作CurryNyCurror接口的類中的貨币轉換服務。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

第二個實作測試目的的方法是:測試替身CurrencyConversionServiceMock。這個類的對象将傳回一個預定義的轉換因子,在單元測試的時候需要用到這個預定義的轉換因子。此外,這個類的對象還提供了從外部設定轉換因子的能力,例如,用于模拟邊界情況。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

産品代碼中,在使用貨币轉換服務的地方,現在用接口來通路貨币轉換服務。得益于這種抽象,用戶端代碼在運作時是完全透明的—無論是通路實際的貨币轉換服務還是貨币轉換服務的替身。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章

在UserOfConversionService類的單元測試中,測試用例能夠通過初始化構造函數把僞對象(mock object)傳遞給這個類的對象。另一方面,在軟體正常運作的情況下,也可以通過構造函數把真實的服務傳遞給這個類的對象。這種技術稱為依賴注入模式,在後面第9章“設計模式和習慣用法”會進行詳細讨論。

帶你讀《C++代碼整潔之道:C++17 可持續軟體開發模式實踐》之二:建構安全體系第2章