是的, 這關(guān)系到你的測試策略. 然而通常的測試策略對單元測試的要求都是盡可能周全.
于是這就是一個測試設(shè)計的問題. 是的,測試代碼也需要設(shè)計, 也需要重構(gòu), 也需要
Domain Specific.
Q: 我的單元測試編譯鏈接速度很慢, 而且有些條件很難測, 比如內(nèi)存不足, 或者環(huán)境很難搭建,
比如需要網(wǎng)絡(luò)或數(shù)據(jù)庫, 怎么解決?
A: 這是集成測試, 不是單元測試. 你一定把系統(tǒng)所有的組件都編譯鏈接起來了.
那么如果你的測試失敗了, 是哪一部分的問題呢?
通常單元測試需要滿足一個條件: 不依賴任何其它單元, 即隔離性. 實現(xiàn)手段就是在測試環(huán)境中能夠輕易的假冒依賴,
并設(shè)定依賴按照我們的意愿進行工作. 一個例子就是你的代碼依賴 malloc 獲取內(nèi)存, 而你想測試內(nèi)存不足的情況.
那么我們應(yīng)在能夠/需要在單元測試中使用使用一個假冒的 malloc 來代替真正的 malloc, 并且我們能控制假冒的 malloc 返回 NULL
以模擬內(nèi)存不足的情況. 關(guān)于如何做到這一點, 可參考一些成熟的"假冒"框架, 如
mockcpp 等.
Q: 我原來的測試都是用真實的代碼來跑, 一個測試能覆蓋多個單元. 你現(xiàn)在都把依賴替換掉了,
那被替換掉的模塊有問題怎么辦? 怎么保證集成真實的代碼后還能正確工作?
A: 其它單元有其它單元自己的單元測試, 各自關(guān)注自己. 集成測試像以前一樣, 該怎么測還怎么測,
并不是有了單元測試就不要其它測試了.
Q: 單元測試就是設(shè)計? 單元測試怎么能反映/代替設(shè)計 ?
A: 單元測試反映的是局部的設(shè)計, 局限于本單元以及與之交互的其它單元.
前面說的單元測試能夠反映系統(tǒng)的其它部分對當前單元的需求, 所謂設(shè)計就是單元之間的職責劃分, 交互和依賴關(guān)系
當你試圖測試一個單元時, 卻發(fā)現(xiàn)需要創(chuàng)建大量的其它對象, 而且按照你腦海中的實現(xiàn),
有些對象是在單元內(nèi)部創(chuàng)建的, 根本無法在測試環(huán)境中假冒它們. 這時候, 你即使只是為了減少測試的難度, 也會逼迫自己思考:
-
這個單元是否做了太多的事, 承擔了額外的職責, 違反了單一職責原則?
-
是否應(yīng)該把依賴讓外界設(shè)置進來, 而不是自己在內(nèi)部創(chuàng)建, 這樣測試時就能把依賴設(shè)置為假冒的實現(xiàn)?
是的, 單元測試警示你思考一下自己的設(shè)計
Q: 單元測試是設(shè)計, 還有人說源代碼是設(shè)計, 到底是測試是設(shè)計還是源代碼是設(shè)計?
A: 這實際上是另外一種角度. 源代碼就是設(shè)計的論斷基于兩個假設(shè)
-
設(shè)計階段中工程師的工作產(chǎn)物, 也就是他的設(shè)計,
是應(yīng)該能夠在實施階段被不同的實施者嚴格并且?guī)缀跻荒R粯拥膶崿F(xiàn)
-
軟件開發(fā)人員也是工程師, 即軟件工程師
如果我們認同這兩個假設(shè), 那么軟件工程師的什么產(chǎn)物能夠被嚴格并且重復實現(xiàn)的呢?
是你的Word形式的"設(shè)計"文檔嗎? 是CAD工具畫出的UML圖嗎? 都不是, 因為它們都不精確, 有無數(shù)種實現(xiàn)方式, 根本談不到嚴格,
不同的開發(fā)人員會有完全不同的實現(xiàn). 事實上, 只有源代碼,才能滿足這個約束. 這樣軟件的設(shè)計階段, 就是直到軟件工程師完成源代碼的那一刻, 而軟件的實施階段,
其實就只剩編譯和部署了. 跑題了.
Q: 單元測試是需求"文檔", 單元測試又是設(shè)計"文檔", 它怎么能既是需求又是設(shè)計呢?
A: 名字和斷言描述需求, 環(huán)境設(shè)置描述設(shè)計 ...
Q: 既然單元測試描述的是需求, 它就應(yīng)該是黑盒測試了? 可單元測試不一直都被認為是白盒測試嗎?
A: 黑白都是相對于你觀察的層次. 相對于其它從外部觀察"系統(tǒng)"行為, 不涉及源代碼的測試來說,
單元測試深入到內(nèi)部觀察盒子的行為, 所以是白盒. 而具體到每個單元測試用例, 依然在盡可能的從外部觀察"單元"的行為, 所以又是黑盒.
Q: 但是你們常用的 Mock 技術(shù), 明顯把單元測試推向白盒的境地.
A: 說來話長, 但可以先說結(jié)論: 基于狀態(tài)的測試 over 基于交互/行為的測試,
雖然右邊的也有巨大的價值, 但我們認為左邊的更穩(wěn)定和更富有對系統(tǒng)的洞察力
基于狀態(tài)的測試描述的是需求, 基于交互行為的測試描述的是實現(xiàn). 相對于需求來說, 實現(xiàn)更易發(fā)生變化,
尤其在另外一種實踐"重構(gòu)"的沖擊下, 描述實現(xiàn)的測試將被修改的面目全非, 帶來相當?shù)姆倒ず途S護成本
一種例外, 就是交互本身就是需求, 這時 mock 是合適的選擇. 一個杜撰的例子參見<<TDD: Tricky Driven Design 3, 方法>>中最后銀行API的例子
而現(xiàn)實生活中, 存在一些情況, 雖然使用 mock 可能帶來后期的維護成本,
但它帶來的好處也是不可代替的. 比如對先期整體測試代碼的編碼量的降低. 這在 C/C++ 項目中尤其明顯:
受限于C/C++的編譯模型, 使用常用的預(yù)處理期接入點和編譯鏈接接入點技術(shù)來接入
stub 實現(xiàn)時, 要小心維護頭文件的防衛(wèi)宏, 頭文件的名稱, 不同環(huán)境下構(gòu)建腳本的include路徑設(shè)置, 庫路徑設(shè)置等.
手工寫stub的方式變的及其繁瑣和容易出錯. 這時候, 一個易用的 mock 框架如
mockcpp 等將節(jié)省大量的編碼和先期維護工作
而幾乎所有的mock框架, 都支持將 mock 對象退化為 stub, 如
mockcpp 中 mock 對象的
defaults() 設(shè)置, 或者 JMock 2 中的 Allowing . 事實上, 這是我推薦的 mock 使用方式:
通常情況下讓它退化為簡單的stub, 必要時才使用它強大的期待設(shè)置和驗證能力.
通常單元測試有兩個公認的約束需要滿足:
-
快
-
隔離依賴.
重申一遍結(jié)論就是:
在滿足單元測試的快和隔離依賴的前提下,
-
優(yōu)先選擇基于狀態(tài)的黑盒測試(可使用手寫stub或mock退化的stub)
-
除非交互和行為本身就是需求(可使用mock對象的全部特性)
Q: 怎么測 private 函數(shù)?
A: 把它變成 public 的.
我是認真的. 如果發(fā)現(xiàn) private
函數(shù)無法簡單的通過某個public函數(shù)的測試來覆蓋而需要專門的測試, 意味著你的單元可能承擔了太多的職責, 應(yīng)該拆分到一個單獨的單元中, 并開放為 public
函數(shù).
如果使用 C++, 在測試環(huán)境中 #define private public.
如果使用 g++, 在測試環(huán)境中加入 -fno-access-control.
Q: 類似 private, 一些意圖實現(xiàn)良好設(shè)計的語言特性, 如 static, sealed,
final, 非虛函數(shù)等, 卻總是給代碼的易測試性帶來麻煩, 該如何取舍?
A: 沒什么好辦法. 這些語言特性和測試的目的是相同的, 都是為提高代碼質(zhì)量, 減少出錯的可能,
雖殊途同歸, 但卻互相限制, 效果也不一樣.
我認為工業(yè)界是時候嚴肅認真的考慮測試環(huán)境了, 最好在語言中內(nèi)建對測試的支持,
一些為產(chǎn)品環(huán)境設(shè)計的語言特性, 應(yīng)該在測試環(huán)境中關(guān)閉, 而在產(chǎn)品環(huán)境中生效. 其實之前很多編譯器都支持 Release 和 Debug 兩種環(huán)境,
也是從代碼質(zhì)量的方面考慮的. 現(xiàn)在毫無疑問證實單元測試比 Debug 更有效, 是時候與時俱進增加對 Test 的支持而逐漸罷黜對 Debug 的支持.
在語言本身增加對測試的支持之前, 我們不得不想辦法在測試環(huán)境中繞過語言特性的限制, 尤其對遺留系統(tǒng),
代碼已經(jīng)存在的情況. 比如對于 C++ 中的 static 函數(shù), 可以將整個被測單元 #include, 或者 #define static 為空.
宏代表了一層間接, 在測試環(huán)境中, 這層間接是至關(guān)重要的. 其它方法可參考 <<Working Effectively with Legacy Code>>,
<<假冒的藝術(shù)>>中的介紹.
Q: 剛才提到了要支持"測試"而不是"Debug", 測試和Debug難道有什么矛盾嗎?
A: 有. 如果你發(fā)現(xiàn)不得不 Debug, 就是測試粒度太粗, 步子邁的太大, 產(chǎn)品代碼過長等導致的,
甚至可能你卷入了過多的單元而破壞了測試的隔離性. Debug還是代碼邏輯不清, 行為難以斷言的表現(xiàn). 用測試幫你定位錯誤.
Q: 我知道為遺留系統(tǒng)增加新特性是要先寫測試保證系統(tǒng)原來的行為, 可遺留代碼很龐大,
我甚至都不知道系統(tǒng)目前的行為, 怎么辦?
A: 特征測試: 保持代碼行為的測試, 獲取當前運行結(jié)果, 來填充測試, 以獲取系統(tǒng)目前行為.
其實測試可以分為兩類: 試圖說明想要實現(xiàn)的目標, 或者試圖保持代碼中既有的行為; 在特性實現(xiàn)后, 前者會轉(zhuǎn)化為后者. 詳細信息請參見<<Working
Effectively with Legacy Code>>
Q: 有成熟的關(guān)于在遺留系統(tǒng)上實踐 TDD 或者單元測試的實踐嗎?
A: 還是<<Working
Effectively with Legacy Code>>, 或者<<在大型遺留系統(tǒng)基礎(chǔ)上運作重構(gòu)項目>>
Q: 前面經(jīng)常說到 C++ 或其它面向?qū)ο笳Z言, 卻沒有提到 C, 那么過程式語言中如何應(yīng)用 TDD ? 有什么不一樣?
A: 基本一樣, 并且在過程式語言中應(yīng)用 TDD, 可能會導出面向?qū)ο箫L格的設(shè)計.
比如如果直接調(diào)用某個函數(shù), 那么不得不通過編譯時替換或鏈接時替換來接入假的實現(xiàn). 這樣其實比較麻煩, 因此可能會促使你選用函數(shù)指針
,以便方便的在測試環(huán)境中進行替換. 隨著時間的推移, 你會發(fā)現(xiàn)一組組概念相關(guān)的函數(shù)指針出現(xiàn)了, 那么把它們和它們操作的數(shù)據(jù)綁定在一起, 定義一個 struct,
就形成了一種對象風格. 當然這反而可能會令你的代碼更復雜, 這需要在實踐中取舍.
也有可能在過程式語言中你覺得 TDD 對設(shè)計的促進不大, 而且測試用例也比較枯燥, 就是測個分支,
返回值什么的. 是的, 邏輯就隱藏在分支和返回值中, 如果習慣了過程式思維并不打算改變, TDD 對設(shè)計的影響則更多的體現(xiàn)在依賴管理上,
如頭文件和編譯單元的職責劃分. 如果把不同職責的函數(shù)混在一個編譯單元里面, 則很難實施鏈接替換等手段, 除非你選擇一個類似
mockcpp 的框架, 不需要鏈接替換.
Q: 如果使用 TDD, 那么測試人員怎么安排? 是不是一開始就要進入項目組?
可那時還沒有產(chǎn)品代碼,測什么?
A: 是, 是一開始就要進入項目組, 可不是因為 TDD. 是,
測試人員是一開始沒什么可測的, 可不代表就沒活干.
TDD是一種開發(fā)方法, 是開發(fā)人員參與的活動. 其效果是以可執(zhí)行的形式文檔化你的需求,
迫使你分清職責隔離依賴以驅(qū)動你的設(shè)計, 編織安全網(wǎng)以扼殺Bug在搖籃狀態(tài)防止逃逸. 可傳統(tǒng)測試人員的活動是試圖找到已經(jīng)逃逸的Bug. 這兩種活動都是必要的,
而且毫不沖突, 互為補充.
那么測試人員在新的特性還沒開發(fā)完成之前做什么呢? 除了提前寫測試用例, 無論是自動化的還是非自動化的,
而需要測試人員參加的一項重要活動, 就是參與特性驗收條件的制定. 之前經(jīng)常發(fā)生開發(fā)人員按照自己的理解去編碼, 測試人員按照自己的理解去測試, 直到開發(fā)完成,
測試過程中才發(fā)現(xiàn)理解的不一致, 開始產(chǎn)生爭執(zhí)并阻塞等待業(yè)務(wù)分析人員(如果幸運的話)或者行政主管(如果開發(fā)過程混亂的話)的仲裁.
解決辦法就是就在開始開發(fā)新特性前的一剎那, 由業(yè)務(wù)分析人員, 測試人員, 開發(fā)人員進行一次討論, 就驗收條件達成一致并形成記錄,
然后測試人員和開發(fā)人員分頭去寫測試和實現(xiàn).
Q: 之前會有一個階段, 就是一組相關(guān)的特性開發(fā)完成后, 測試人員接手測試, 幾輪Bug修復過去后,
產(chǎn)品基本穩(wěn)定就可以發(fā)布了. 現(xiàn)在測試人員提前介入到每個迭代中, 針對單個特性進行測試, 那如何保證產(chǎn)品集成起來的質(zhì)量?
A: 跟以前一樣, 該有那么個集成測試階段還得有那么個集成測試階段, 取決于產(chǎn)品當時的質(zhì)量狀態(tài).
并不是說有了迭代級別, 單個特性級別的測試就不需要發(fā)布級別的集成測試了, 兩者沒有任何矛盾.
Q: 那么測試人員提前進入迭代有什么好處?
A: 盡早發(fā)現(xiàn)問題, 降低修復錯誤的成本. 有幾種手段,
一是前面提到與業(yè)務(wù)人員和開發(fā)者一起討論驗收條件, 這樣就能防止理解偏差而導致的返工. 二是開發(fā)完成立即測試, 發(fā)現(xiàn)問題立即反饋,
這樣開發(fā)人員對代碼依然印象深刻,能快速定位和修復錯誤. 這樣流入最后集成測試階段的Bug就會少, 會縮短最后的集成測試時間, 保證產(chǎn)品更平穩(wěn)的發(fā)布.
Q: 有時候后續(xù)的特性會影響前面的特性, 那么迭代過程中測試人員只測單個特性,
怎么保證以前的特性依然工作?
A: 幾個手段. 測試盡量自動化, 以便能夠持續(xù)集成. 再就是做好依賴管理, 每當一個新特性完成,
就應(yīng)該能夠發(fā)現(xiàn)它影響的其它特性, 看看是否應(yīng)該補充一些集成測試.
Q: 有時候開發(fā)人員完成一個特性時已接近迭代結(jié)束, 測試人員沒有時間進行充分測試, 怎么辦?
A: 下個迭代測唄, 并且在計算開發(fā)速度時, 只應(yīng)該計算本迭代通過測試人員驗收的特性,
那些僅僅是開發(fā)人員完成, 沒有經(jīng)過測試人員充分測試的特性不計在內(nèi). 這種情況是不可避免的. 但我們能通過一些手段讓測試與開發(fā)更加同步, 盡量縮短滯后性,
包括讓測試人員與開發(fā)人員更緊密合作, 盡量讓測試用例自動化等.
Q: 我還是覺得在開發(fā)迭代過程中, 測試人員的工作量不飽滿.
A: 如果這不是您的感覺, 而是事實, 并且前面測試人員必須要做的工作也都做了, 還是不飽滿,
那么恭喜你, 可以省下一些測試人員, 去做別的事了. 但不推薦的是, 不要讓測試人員同時為兩個團隊工作. 這會大大增加溝通的成本. 你會經(jīng)常發(fā)現(xiàn),
當你的開發(fā)者想找測試人員協(xié)助時, 卻找不到人了, 于是你的團隊便被堵塞在那里. 而測試人員本身的Context切換也是痛苦的.
Q: 你們說驗收測試應(yīng)該由客戶來編寫, 可在我們這里根本不可能.
A: 驗收, 當然是由客戶來驗收, 這在理論上是毫無疑問的, 而且肯定在各行各業(yè)發(fā)生著.
只是具體到測試用例的編寫和執(zhí)行, 無論是自動化的還是非自動化的, 都需要掌握一定的技術(shù), 需要周密的思考, 需要專門的時間, 客戶可能無法同時滿足這幾個條件,
我們要盡力爭取, 爭取不到, 便只好通過更充分的交流來彌補越俎代庖的失真. 這時業(yè)務(wù)分析人員和測試人員要通力合作, 完成驗收測試的編寫.
Q: 你們說你們之前的項目產(chǎn)品代碼和測試代碼的比例大約 1:3, 這不是平白增加了 3
倍的工作量嗎?
A: 是增加了 3 倍的代碼量而不是工作量. 它節(jié)省了你幾十人做幾個月龐大的預(yù)先設(shè)計的工作量,
節(jié)省了你詳細設(shè)計每個模塊并為之編寫幾百頁詳設(shè)文檔的時間, 節(jié)省了無數(shù)不眠之夜通宵Debug的時間, 它節(jié)省了集成階段修復難以計數(shù)的Bug的工作量,
甚至它縮減了你產(chǎn)品代碼的數(shù)量, 大量的重復代碼被消除了, 大量過度設(shè)計的復雜代碼被廢除了, 你的代碼更易理解了, 添加新特性更容易了, 發(fā)現(xiàn)的Bug更易定位了,
以致于大大減少了長達數(shù)年的生命周期內(nèi)維護的工作量. 有點夸張了? 可這就是 TDD
和敏捷開發(fā)帶給我們的好處(如果你已經(jīng)實踐了)和vision(如果你還在觀望)
Q: 我們也做單元測試, 但是是先寫產(chǎn)品代碼后寫測試的. 難道非得 TDD, 非得測試先行嗎?
A: 沒什么事是非做不可的. 取決于你要什么. TDD 只是以可驗證的方式迫使你將質(zhì)量內(nèi)建在思維中,
長期的測試先行將歷練你思維的質(zhì)量. 而事后的單元測試只是惶恐的跟隨者.