時(shí)至今日,我寫(xiě)自動(dòng)化測(cè)試也已經(jīng)有些年頭了,我不得不承認(rèn)當(dāng)它能使代碼更容易維護(hù),因此我依然對(duì)這項(xiàng)技術(shù)著迷。本文中,我希望分享一些我的經(jīng)驗(yàn),以及我從他人或自己的一次次嘗試中所吸取的教訓(xùn)。
這些年,我聽(tīng)到了許多關(guān)于寫(xiě)自動(dòng)化測(cè)試的好的 (以及不好的) 理由。從積極的方面來(lái)說(shuō),寫(xiě)自動(dòng)化測(cè)試能夠:
的確,你可以說(shuō)這些都是對(duì)的,但是我想提出一個(gè)關(guān)于這些理由的另一個(gè)視角 —— 一個(gè)統(tǒng)一的視角
自動(dòng)化測(cè)試唯一的理由是它讓我們能在將來(lái)修改我們的代碼。
換句話說(shuō):
一個(gè)測(cè)試能夠體現(xiàn)回報(bào)價(jià)值的時(shí)候僅僅是當(dāng)我們想修改我們的代碼的時(shí)候。
讓我們看一看這個(gè)經(jīng)典論斷是如何支持我們前面提到的理由的:
是的,上面所有的理由在某些方面是對(duì)的,但是這些理由適用于我們開(kāi)發(fā)者的就是自動(dòng)化測(cè)試能夠讓我們修改代碼。
注意,我在這里不會(huì)寫(xiě)關(guān)于測(cè)試的設(shè)計(jì)所能得到的反饋,比如 TDD。那可以成為一個(gè)單獨(dú)的話題。我們將要談?wù)摰臏y(cè)試是已經(jīng)寫(xiě)好的測(cè)試。
看起來(lái)好像寫(xiě)測(cè)試和如何寫(xiě)測(cè)試應(yīng)該以修改作為動(dòng)機(jī)。
一個(gè)簡(jiǎn)單的考慮這個(gè)問(wèn)題的方法是在寫(xiě)測(cè)試的時(shí)候,向你的測(cè)試提出下面兩個(gè)問(wèn)題:
“如果我修改了我的生產(chǎn)代碼,測(cè)試是會(huì)失敗 (還是通過(guò)) 呢?”
“那是一個(gè)讓測(cè)試失敗 (或者通過(guò)) 的好的理由么?”
如果你發(fā)現(xiàn)那是一個(gè)讓測(cè)試失敗 (或者通過(guò)) 的不好的理由,那么請(qǐng)修正它。
那樣,將來(lái)你修改你的代碼的時(shí)候,你的測(cè)試只會(huì)因?yàn)楹玫睦碛啥ㄟ^(guò)或者失敗,這會(huì)比因?yàn)椴缓玫睦碛啥〉墓殴值臏y(cè)試得到的回報(bào)要好。
現(xiàn)在,你可能仍然會(huì)問(wèn):“什么才是最重要的?”
讓我們用另一個(gè)問(wèn)題來(lái)回答這個(gè)問(wèn)題:當(dāng)我們修改代碼的時(shí)候,測(cè)試為什么會(huì)出錯(cuò)?
我們都認(rèn)同的一個(gè)觀點(diǎn)是我們進(jìn)行測(cè)試的主要原因是為了能夠輕松地修改代碼。如果是那樣的話,那些失敗的測(cè)試是如何幫助我們的?那些失敗的測(cè)試除了是噪音之外什么也不是 —— 它們甚至?xí)璧K著我們完成工作。那么,怎樣做測(cè)試才能幫助我們呢?
這取決于我們修改代碼的理由。
首先,起點(diǎn)必須是測(cè)試全部是綠色的,也就是說(shuō)所有的測(cè)試都已經(jīng)通過(guò)。
如果你想通過(guò)修改代碼來(lái)修改它們的行為 (也就是,修改代碼做的事情),你需要:
在這一過(guò)程的結(jié)尾,我們又回到了起點(diǎn)——所有的測(cè)試都通過(guò)了,如果需要,我們已經(jīng)準(zhǔn)備好了再次開(kāi)始。
因?yàn)槟阒滥男y(cè)試失敗了以及哪些代碼的修改使得它們又通過(guò)了,你會(huì)很有信心,因?yàn)槟阒恍薷牧四阆胍薷牡牟糠帧_@就是自動(dòng)化測(cè)試如何幫助我們通過(guò)修改代碼來(lái)修改代碼的行為的。
注意,看到一個(gè)測(cè)試失敗是正常的,因?yàn)樗俏覀冋诟碌男袨橄鄬?duì)應(yīng)的測(cè)試。
同樣,起點(diǎn)應(yīng)該是測(cè)試全是綠色的。
如果希望修改一段代碼的實(shí)現(xiàn)讓它變得更簡(jiǎn)單,高效,易于擴(kuò)展等等 (也就是說(shuō),修改怎么做,而不是做什么),應(yīng)該遵循接下來(lái)的原則:
在不觸及測(cè)試的前提下修改你的代碼。
當(dāng)修改后的代碼已經(jīng)簡(jiǎn)單、快速、更靈活時(shí),你的測(cè)試應(yīng)該仍然是綠色的。在重構(gòu)的時(shí)候,測(cè)試應(yīng)該只在代碼出錯(cuò)的時(shí)候失敗,例如修改了代碼的外部行為。當(dāng)發(fā)生這種情況時(shí),你應(yīng)該退回到那個(gè)錯(cuò)誤然后回到綠色的狀態(tài)
因?yàn)槟愕臏y(cè)試總是在綠色的狀態(tài),你知道你沒(méi)有破壞任何事情。這就是自動(dòng)化測(cè)試如何讓我們修改我們的代碼的方式。
在這種情況下,看到測(cè)試失敗是不應(yīng)該的。因?yàn)檫@意味著:
我希望測(cè)試在上面的情形下能夠幫助我們。所以讓我們來(lái)看一些具體的能讓我們的測(cè)試更有效的 tips。
在討論如何寫(xiě)測(cè)試之前,我想迅速地回顧一些優(yōu)秀實(shí)踐。有 5 條被認(rèn)為是每個(gè)測(cè)試都應(yīng)該遵守的基本原則。便于記憶這 5 條規(guī)則的縮寫(xiě)是: F.I.R.S.T.
測(cè)試應(yīng)該:
更多關(guān)于這些規(guī)則的內(nèi)容,你可以閱讀 Tim Ottinger 和 Jeff Langr 的這篇文章。
如何將測(cè)試的結(jié)果收益最大化?一言以蔽之:
不要將測(cè)試和實(shí)現(xiàn)細(xì)節(jié)耦合在一起
私有方法意味著私有。如果你感到有必要測(cè)試一個(gè)私有方法,那么那個(gè)私有方法一定含有概念性錯(cuò)誤,通常是作為私有方法,它做的太多了, 從而違背了單一職責(zé)原則
今天:假設(shè)你的類有一個(gè)私有方法。它做了太多的事情,所以你決定測(cè)試它。你僅僅為了測(cè)試,就讓那個(gè)方法變成公有的。它本來(lái)只被同一個(gè)類的其他的公有方法在內(nèi)部使用。然后你為這個(gè)私有 (從技術(shù)上來(lái)說(shuō)現(xiàn)在公有了的) 方法編寫(xiě)測(cè)試。
明天:因?yàn)樾枨笊系囊恍┳兓?(這完全是有可能的),你決定修改這個(gè)方法。你發(fā)現(xiàn)一些同事在其他的類中使用了這個(gè)方法,因?yàn)樗麄冋f(shuō) “這個(gè)方法做了我想要的事情”。畢竟,它是公有的,不是么?這個(gè)私有方法不是公有 API 的一部分。你要想修改這個(gè)方法就不得不破壞你同事的代碼。
應(yīng)該做什么:將私有方法抽離到一個(gè)單獨(dú)的類中,給這個(gè)類一個(gè)定義良好的約定,然后單獨(dú)地測(cè)試它。當(dāng)其他的測(cè)試代碼依賴這個(gè)新的類的時(shí)候,如果有必要的話,你可以進(jìn)行置換測(cè)試。
那么,我們?nèi)绾螠y(cè)試一個(gè)類的私有方法呢?通過(guò)這個(gè)類的公有 API。永遠(yuǎn)通過(guò)公有 API 測(cè)試你的代碼。程序的公有 API 定義了一個(gè)約定,它是一組關(guān)于你的程序?qū)?yīng)于不同輸入時(shí)定義良好的一組期望。私有 API (私有方法或者整個(gè)類) 并沒(méi)有定義約定,并且可以不經(jīng)通知自行修改,所以你的測(cè)試 (或者你的同事) 不能依賴于它們。
通過(guò)這種方法測(cè)試你的私有方法,你可以自由地修改你的 (真正的) 私有代碼,并且通過(guò)劃分成只做一件事情,并經(jīng)過(guò)正確測(cè)試的小的類,來(lái)提升代碼的設(shè)計(jì)。
Stub 私有方法和測(cè)試私有方法具有相同的危害,更重要的是,stub 私有方法將會(huì)使程序難以調(diào)試。通常來(lái)說(shuō),用于 stub 的庫(kù)會(huì)依賴于一些不尋常的技巧來(lái)完成工作,這使得發(fā)現(xiàn)一個(gè)測(cè)試為什么會(huì)失敗變的困難。
同樣,當(dāng)我們 stub 一個(gè)方法的時(shí)候,我們必須依據(jù)它做出的約定來(lái)進(jìn)行。但是私有方法沒(méi)有指定的約定的 —— 畢竟,這也是為什么它們是私有的原因。由于私有方法的行為可以不經(jīng)通知自行修改,你的 stub 可能與實(shí)際情況背道而馳,但是你的測(cè)試仍然會(huì)通過(guò)。這是多么的可怕啊,讓我們來(lái)看一個(gè)例子:
今天:一個(gè)類的公有方法依賴于該類的一個(gè)私有方法。這個(gè)私有方法 foo 永遠(yuǎn)不會(huì)返回空。為公有方法編寫(xiě)的測(cè)試為了方便起見(jiàn),我們 stub 出了私有方法。當(dāng) stub foo 方法的時(shí)候,你永遠(yuǎn)不會(huì)考慮到 foo 返回為空的情況,因?yàn)楝F(xiàn)在這種情況永遠(yuǎn)不會(huì)發(fā)生。
明天:這個(gè)私有方法被修改了,現(xiàn)在它返回空了。它是一個(gè)私有方法,所以這沒(méi)什么問(wèn)題。為公有方法編寫(xiě)的測(cè)試不會(huì)相應(yīng)地被修改 (“我正在修改一個(gè)私有方法,所以我為什么要更新我的測(cè)試?”)。公有方法現(xiàn)在在私有方法返回空的情況下會(huì)出錯(cuò),但是測(cè)試仍然會(huì)通過(guò)!
這實(shí)在太可怕了。
應(yīng)該做什么:由于 foo 做的事情太多了,所以應(yīng)該將它抽離至一個(gè)新的類,然后單獨(dú)地測(cè)試它。然后,在測(cè)試的時(shí)候,為那個(gè)新類提供一個(gè)置換。
第三方代碼不應(yīng)該在你的測(cè)試中直接出現(xiàn)。
今天:你的網(wǎng)絡(luò)部分的代碼依賴于著名的 HTTP 庫(kù) LSNetworking.為了避免使用實(shí)際的網(wǎng)絡(luò) (為了讓你的測(cè)試更快速更可信),你 stub 了那個(gè)庫(kù)中的方法 -[LSNetworking makeGETrequest:],沒(méi)有通過(guò)實(shí)際的網(wǎng)絡(luò)合適地替代了它的行為 (它通過(guò)一個(gè)封裝好的響應(yīng)調(diào)用了執(zhí)行成功的回調(diào))。
明天:你需要使用一個(gè)替代品來(lái)取代 LSNetworking (可能是 LSNetworking 已經(jīng)不再維護(hù)或者是你需要換成一個(gè)更先進(jìn)的庫(kù),因?yàn)樗泻芏嗄阈枰男绿匦缘鹊?。這是一次重構(gòu),所以你不應(yīng)該修改測(cè)試。你替換了庫(kù)。你的測(cè)試會(huì)失敗,因?yàn)橐蕾嚨木W(wǎng)絡(luò)沒(méi)有被 stub (-[LSNetworking makeGETrequest:]不會(huì)被調(diào)用)。
應(yīng)該做什么:測(cè)試中,依靠 stubbing 傘 (umbrella stubbing) 來(lái)替代那個(gè)庫(kù)的全部功能。
stubbing 傘 (一個(gè)我剛剛發(fā)明的術(shù)語(yǔ)) 包括了對(duì)于所有你的代碼可能用到的方式 -- 不管事現(xiàn)在還是將來(lái) -- 的 stub。它們可以通過(guò)良好聲明的 API 完成一些任務(wù),而不去關(guān)心實(shí)現(xiàn)的細(xì)節(jié)。
正如上面的那個(gè)例子,你的代碼今天可能依賴于 "HTTP 庫(kù) A",但是還是有別的可能的方式發(fā)起 HTTP 請(qǐng)求,不是么?比如 "HTTP 庫(kù) B"。
舉個(gè)例子,我的一個(gè)開(kāi)源項(xiàng)目 Nocilla 就為網(wǎng)絡(luò)代碼提供 stubbing 傘的解決方案。通過(guò) Nocilla 你可以不依賴任何 HTTP 庫(kù),以聲明的方式 stub HTTP 請(qǐng)求。Nocilla 可以 stubbing 的任何一個(gè) HTTP 庫(kù),所以你不會(huì)將測(cè)試和實(shí)現(xiàn)細(xì)節(jié)耦合在一起。這使得你能夠在不修改測(cè)試的情況下切換網(wǎng)絡(luò)框架。
另一個(gè)例子是 stub 日期,在大多數(shù)編程語(yǔ)言中都有很多中方法獲取當(dāng)前時(shí)間,但是像 TUDelorean 這樣的庫(kù)可以 stub 每一個(gè)與日期相關(guān)的 API,所以你可以模仿一個(gè)不同的系統(tǒng)日期用來(lái)測(cè)試。這讓你你不用修改測(cè)試就可以重構(gòu)不同的日期 API 的實(shí)現(xiàn)細(xì)節(jié)。
除了 HTTP 和日期,在擁有各種各樣 API 的其他領(lǐng)域,你可以用類似的方式來(lái)實(shí)現(xiàn) stubbing 傘,或者你可以創(chuàng)建你自己的開(kāi)源解決方案并分享到社區(qū),這樣其他人就可以正確地編寫(xiě)測(cè)試了。
這部分和前一點(diǎn)關(guān)系密切,但是這部分的情況更普遍。我們的生產(chǎn)代碼通常依賴于某些事情的完成。比如,一個(gè)依賴能夠幫助我們查詢數(shù)據(jù)庫(kù)。通常這些依賴提供了多種方法來(lái)實(shí)現(xiàn)相同的事情,或者說(shuō)至少是實(shí)現(xiàn)相同的外部行為;在我們的數(shù)據(jù)庫(kù)的例子中,你可以使用 find 方法通過(guò) ID 來(lái)獲取一條記錄,或者使用 where 子句獲取相同的記錄。當(dāng)我們僅僅 stub 可能的機(jī)制中的一個(gè)的時(shí)候,問(wèn)題就出現(xiàn)了。如果我們僅僅 stub 了 find 方法 (我們的生產(chǎn)代碼使用的機(jī)制),但是沒(méi)有 stub 其他的可能性,比如 where 子句,當(dāng)我們決定使用 where 子句取代 find 方法來(lái)重構(gòu)我們的實(shí)現(xiàn)的時(shí)候,我們的測(cè)試就會(huì)失敗,即使代碼的外部行為并沒(méi)有修改。
今天:UsersController 類依賴于 UserRepository 類從數(shù)據(jù)庫(kù)中取得用戶。你正在測(cè)試 UsersController 并且你為了以確定的方式更快地運(yùn)行,你 stub 了 UsersRepository 的 find 方法,這實(shí)在是太棒了。
明天:你決定使用 UsersRepository 的新的可讀性更高的查詢語(yǔ)法來(lái)重構(gòu) UsersController,因?yàn)檫@是一次重構(gòu),所以不應(yīng)該觸及測(cè)試。為了找到感興趣的記錄,你使用了可讀性更高的 where 方法更新了 UsersController?,F(xiàn)在你的測(cè)試會(huì)失敗,因?yàn)闇y(cè)試 stub 了 find 方法,但是沒(méi)有 stub where 方法。
stubbing 傘在某些情況下能幫上忙,但是對(duì)于 UsersController 類的這種情形,沒(méi)有可以替代的庫(kù)能夠從我的數(shù)據(jù)庫(kù)中獲取我的用戶。
應(yīng)該做什么:以測(cè)試為目的,為同一個(gè)類創(chuàng)建可替代的實(shí)現(xiàn),并將它作為置換來(lái)使用。
繼續(xù)我們的例子,我們應(yīng)該提供一個(gè) InMemoryUsersRepository。這個(gè)在內(nèi)存中的替代方案,除了它為提高測(cè)試速度而把數(shù)據(jù)保存在內(nèi)存中之外,它應(yīng)該遵守原始的 UsersRepository 類的每一個(gè)單一方面的約定。這意味著,當(dāng)你重構(gòu) UsersRepository 的時(shí)候,你使用在內(nèi)存中這個(gè)版本做了同樣的事情。為了讓它更清楚:是的,現(xiàn)在你不得不為同一個(gè)類維護(hù)兩套不同的實(shí)現(xiàn)。
現(xiàn)在你可以將這個(gè)輕量級(jí)的版本的依賴作為置換對(duì)象提供給測(cè)試。好的事情是這是一個(gè)完整的實(shí)現(xiàn),所以當(dāng)你決定將實(shí)現(xiàn)從一個(gè)方法移動(dòng)到另一個(gè)方法 (在我們的例子中是從 find 移動(dòng)到 where) 的時(shí)候,正在使用的置換對(duì)象將會(huì)支持新的方法,并且當(dāng)重構(gòu)的時(shí)候,測(cè)試也不會(huì)失敗。
維護(hù)一個(gè)類的另一個(gè)版本沒(méi)有什么問(wèn)題。根據(jù)我的經(jīng)驗(yàn),它最終只會(huì)需要很少的努力,就能得到很大的回報(bào)。
你同樣可以將類的輕量級(jí)版本作為生產(chǎn)代碼的一部分,就像 Core Data 使用棧的內(nèi)存版本一樣。這樣做可能對(duì)某些人有作用。
構(gòu)造函數(shù)定義的是實(shí)現(xiàn)細(xì)節(jié),你不應(yīng)該測(cè)試構(gòu)造函數(shù),這是因?yàn)槲覀冋J(rèn)同測(cè)試應(yīng)該與實(shí)現(xiàn)細(xì)節(jié)解耦這一觀點(diǎn)。
而且,構(gòu)造函數(shù)不應(yīng)該包含行為,所以沒(méi)有值得測(cè)試的東西。這是因?yàn)槲覀冋J(rèn)同測(cè)試應(yīng)該只對(duì)代碼的行為進(jìn)行這一觀點(diǎn)。
今天:你有一個(gè) Car 類,并包含一個(gè)構(gòu)造函數(shù)。一旦一個(gè) Car 被創(chuàng)建了,你測(cè)試它的 Engine 不為空 (因?yàn)槟阒罉?gòu)造函數(shù)創(chuàng)建了一個(gè)新的 Engine 并將它賦給了變量 _engine)。
明天:Engine 類創(chuàng)建起來(lái)變得代價(jià)很高,所以你決定使用延遲初始化 (lazily initialize),在第一次調(diào)用 Engine 的 getter 方法時(shí)才初始化 Engine (這是很好的)。 現(xiàn)在為 Car 類的構(gòu)造函數(shù)編寫(xiě)的測(cè)試出問(wèn)題了,即便 Car 類運(yùn)行良好,但 Car 并沒(méi)有包括 Engine。另一個(gè)可能是你的測(cè)試不會(huì)失敗,因?yàn)闇y(cè)試包含 Engine 的 Car 類會(huì)觸發(fā) Engine 的延遲加載。所以我的問(wèn)題是:為什么還要測(cè)試?
應(yīng)該做什么:當(dāng)使用不同的方法創(chuàng)建類的時(shí)候測(cè)試公有 API 的行為。一個(gè)愚蠢的例子:測(cè)試當(dāng) list類被創(chuàng)建并且沒(méi)有包含條目的時(shí)候,list 類的 count 方法的行為。注意,你測(cè)試的是 count 的行為而不是構(gòu)造函數(shù)的行為。
思考一下,類含有多個(gè)構(gòu)造函數(shù)的情形,這可能意味著你的類做了太多事情了。試著將它們拆分成更小的類,但是如果有足夠充分的理由使你的類含有多個(gè)構(gòu)造函數(shù),那么依然遵循同樣的建議。保證你的測(cè)試的是那個(gè)類的公有 API。在這種情況下,使用每一個(gè)構(gòu)造函數(shù)去測(cè)試 (也就是說(shuō),當(dāng)類處在一種初始化狀態(tài)下時(shí),它的行為就是那種狀態(tài)下的;當(dāng)類處在另一種初始化狀態(tài)下時(shí),它的行為是另一種狀態(tài)下的)
編寫(xiě)測(cè)試是一項(xiàng)投資 —— 我們需要花時(shí)間編寫(xiě)和維護(hù)它們。我們可以證明這種投資有回報(bào)的唯一方法就是我們期望節(jié)省時(shí)間。將實(shí)現(xiàn)細(xì)節(jié)和測(cè)試耦合在一起會(huì)減少測(cè)試帶來(lái)的回報(bào),使得那些投資變得不合算,甚至在某些情況下變得一文不值。
在編寫(xiě)測(cè)試、重構(gòu)以及修改系統(tǒng)行為的時(shí)候,檢查你的測(cè)試在面對(duì)錯(cuò)誤的原因時(shí)是失敗還是通過(guò),然后退一步問(wèn)問(wèn)自己,那些測(cè)試是否能夠最大化你投資的成果。