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