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