北京軟件開發(fā)公司全棧測試:平衡單元測試和端到端測試全棧開發(fā)人員的特點是能夠從頭到尾交付并發(fā)布一個特性。教程和書籍常常側(cè)重于搭建全棧開發(fā)環(huán)境和讓測試能夠進(jìn)行所需要的“管件(plumbing)”(我綜合運(yùn)用了Angular、Rails、Bootstrap和Postgres)。但對于如何貫穿整個Web開發(fā)棧進(jìn)行應(yīng)用程序測試,卻常常缺少指導(dǎo)。讓我們深入研究下這篇文章。我們將學(xué)習(xí)如何充分利用端到端測試,包括對測試什么以及如何保證那些測試的可靠性和可維護(hù)性進(jìn)行指導(dǎo)。我們還將談及單元測試以及它們在端到端測試策略中的作用。但首先,我們要理解編寫測試的根本目的。
從根本上講,測試是為了確保應(yīng)用程序的行為符合開發(fā)者的意愿。它們是自動化的腳本,執(zhí)行代碼并檢查其行為是否符合預(yù)期。測試編寫得越好,就越可以依賴它們?yōu)椴渴鸢殃P(guān)。如果測試不充分,就需要一個QA團(tuán)隊或者發(fā)布有缺陷的軟件(兩者均意味著用戶獲得價值的速度比理想情況慢許多)。如果測試充分,就可以自信而快速地發(fā)布,不需要批準(zhǔn)或者像QA那樣緩慢的人工過程。
對于編寫的測試,還必須權(quán)衡未來的可維護(hù)性。應(yīng)用程序會變,因此測試也會變。在理想情況下,測試的修改與軟件的修改是成正比的。如果你修改了一條錯誤信息,那么你不會希望大量重寫測試套件。但是,如果你徹底地修改了一個用戶流程,那么可以預(yù)料,將有大量的測試需要重寫。
實際上,這意味著你無法將所有測試都作為端到端的全面集成測試,但是你也不能只進(jìn)行少得可憐的單元測試。這就關(guān)乎如何達(dá)成那種平衡。
測試的類型
測試的種類很多,但對于本文而言,我們就談?wù)搩深悾憾说蕉藴y試和單元測試。
端到端測試模擬用戶行為。在Web應(yīng)用程序中,他們會啟動服務(wù)器,打開瀏覽器,到處點擊,斷言瀏覽器中發(fā)生了特定的事情,讓我們相信功能可以正常運(yùn)行。這些測試會給我們巨大的信心,但是它們緩慢而脆弱,并且同用戶界面緊密地耦合在了一起。
單元測試根據(jù)代碼單元的公共API運(yùn)行它們。這些測試需要創(chuàng)建一個類的實例,使用特定的輸入調(diào)用它的方法,斷言被調(diào)用的方法達(dá)到了預(yù)期的效果(通常是返回了預(yù)期的輸出)。這些測試快速而穩(wěn)定,并且不會同系統(tǒng)的其他部分緊密地耦合在一起。不過,它們無法讓你相信整個系統(tǒng)可以正常運(yùn)行——只是測試過的代碼單元可以正常運(yùn)行。
構(gòu)建一項特性的任務(wù)就是要在兩類測試之間找到恰當(dāng)?shù)钠胶恻c。如果端到端測試太多,那么未來修改應(yīng)用程序就會痛苦而緩慢。如果太少,那么一些不易覺察的缺陷就會進(jìn)入到生產(chǎn)環(huán)境,即使快速測試套件的代碼覆蓋率為100%。
從用戶體驗入手
你的軟件是向某個用戶提供服務(wù),因此,那個用戶應(yīng)該推動你的工作。我不建議使用測試來設(shè)計用戶體驗,因此,要在編寫測試之前弄清楚用戶將如何使用軟件(要么通過試驗性代碼,要么同一名設(shè)計師一起工作)。一旦弄清楚了,就可以開始工作了。
在理想情況下,你將為用戶體驗的某個部分創(chuàng)建端到端的測試,并編寫代碼讓其通過測試。在編寫那些代碼的時候,你會創(chuàng)建單元測試,具體化需要創(chuàng)建或修改(通常是后者)的代碼的規(guī)范。
問題是,編寫沒有用戶界面工件(HTML)可供參考的、端到端的失敗測試很難。這是因為,大部分端到端測試的形式都是:
找到頁面上的某個元素;
通過某種方式同它交互;
證實交互成功;
重復(fù)上述過程直到測試結(jié)束。
這意味著,圍繞要發(fā)生交互的用戶界面元素(DOM對象),你需要有一些規(guī)范。當(dāng)把以JavaScript為基礎(chǔ)的交互設(shè)計考慮在內(nèi)時,如果不實際地構(gòu)建界面,至少是部分地構(gòu)建,就更難測試了。
為此,要讓一個粗略的UI輪廓在瀏覽器中運(yùn)行起來。使用預(yù)先準(zhǔn)備好的數(shù)據(jù),并且不需要考慮備選流程——一次專注于一件事。它運(yùn)行起來以后,就可以編寫測試了。
在這樣做的時候,有兩點需要考慮:這個特性需要測試嗎?如果需要,該如何測試?
測試什么
雖然在編程上沒有愉快路徑,但用戶經(jīng)歷的代碼路徑要比代碼的可能路徑少許多。例如,當(dāng)用戶購買一款產(chǎn)品,根據(jù)用戶地址、選擇的發(fā)貨方式或者以前的購買歷史,我們可能會用不同的方式處理訂單。在所有情況下,用戶的體驗都是一樣的,這樣,在用戶看來,流程只有一個。
這時,你的目標(biāo)是測試所有的用戶流程。你需要一個測試套件,模擬一個用戶做你想要并希望他做的事,并斷言你想要提供給該用戶的所有體驗都工作正常。
假如你已經(jīng)知道要測試什么,那應(yīng)該如何進(jìn)行呢?
如何進(jìn)行端到端測試
如果修改了一個流程,那么就要修改那個流程的測試。由于端到端測試模擬用戶活動,所以不需要為想要斷言的每件事情都編寫一個測試。如果用戶應(yīng)該在結(jié)算界面上看到三段重要的信息,就不需要編寫三個測試——一個測試檢查所有三段信息就足夠了。因此,當(dāng)修改一個現(xiàn)有的用戶體驗時,要找一個現(xiàn)有的、可以改進(jìn)的測試。
否則,就需要一個新的測試。記住,你的目標(biāo)是模擬用戶要做的事情。務(wù)必要對如何組織測試中的導(dǎo)航和行為開誠布公。用戶真地會直接導(dǎo)航到某些深層鏈接嗎?或者他們會點擊某個公用的開始頁面從而到達(dá)他們需要到達(dá)的地方嗎?
這很難做,尤其是通常要使用較少的標(biāo)記實現(xiàn)該功能。測試需要定位特定的DOM元素同其交互,而準(zhǔn)確找到你想要同其交互的元素并不總是很簡單(或者可能)。你需要“標(biāo)識(signpost)”。
標(biāo)識是專門插入DOM中用于定位感興趣的元素的。要盡早確定這些標(biāo)識如何發(fā)揮作用。不應(yīng)該使用原本用于樣式化的CSS類來定位DOM元素。這樣做意味著前端開發(fā)人員改變類名就會破壞測試。也不應(yīng)該使用被JavaScript代碼使用的CSS類或數(shù)據(jù)屬性(比如前綴為js-的類)。這會帶來同樣的破壞。
使用前綴為test-的CSS類或者前綴為data-test-的屬性是兩種常用的技術(shù):
這可能看上去讓人不舒服……也確實是。但是,與將測試耦合到內(nèi)容或者展示類相比,這就不那么令人討厭了。這里,你需要尋求一種平衡——不要盲目地使用data-test屬性標(biāo)記每個元素。例如,如果你想點擊一個購買特定產(chǎn)品的按鈕,那么你真正需要的只是定位某個包含那款產(chǎn)品及購買按鈕的元素。
添加data-test-product屬性后,你就能夠使用一個像[data-test-product='1234'] input[type='submit']這樣的CSS選擇器定位產(chǎn)品1234的購買按鈕了。
這意味著你必須修改只為測試而存在的標(biāo)記,就是說,為了獲得你提供給他們的用戶體驗,用戶要下載一些他們不需要的字節(jié)。這是一種平衡,但比糟糕的測試覆蓋率(對用戶的傷害遠(yuǎn)遠(yuǎn)超過了HTML中多一些額外的字節(jié))要好。只是得恰到好處。
當(dāng)頁面上有改變頁面內(nèi)容而又不重新加載的交互(換句話說,使用JavaScript)時,這項技術(shù)就更加重要了。
處理交互
當(dāng)每次點擊都重新加載頁面時,端到端測試更可靠,因為底層工具知道要等待一個頁面重新加載。當(dāng)用戶交互只是改變DOM時,難度就大了,因為工具不知道什么“事情”正在發(fā)生,也就無法“等待事情完成”。
當(dāng)測試需要同一個不會根據(jù)用戶動作重新加載的頁面交互時,就需要一種方法能夠在開始斷言發(fā)生了什么之前等待DOM操作完成。如果不等待,那么如果測試開始斷言時DOM還沒有更新,測試就會無謂地失敗。
就像在標(biāo)記中使用標(biāo)識定位要操作的DOM元素一樣,我們也可以把它們用在這里。任何新增或變化的標(biāo)記都應(yīng)該有某種在交互失敗或沒有發(fā)生的情況下不會出現(xiàn)的標(biāo)識。換句話說,你不必為了等待DOM事件而在測試中進(jìn)行休眠調(diào)用——DOM中應(yīng)該包含可供測試顯式等待的標(biāo)識。
例如,假設(shè)我們想要測試一個動作為用戶生成了一條成功的消息。假設(shè)實現(xiàn)方法是發(fā)出一個AJAX請求,當(dāng)調(diào)用結(jié)束時向DOM中插入一條消息。一個基本的實現(xiàn)可以像下面這樣做:
function purchase(productId) {
$.post(
"/products/",
{ "id": productId }
).done(function() {
$(".header").html(
"
Your order was placed
");
}).fail(function() {
$(".header").html(
"
There was a problem
");
});
你可以通過配置讓測試等待一個使用了CSS類alert-success的元素出現(xiàn),然后斷言它的內(nèi)容。這意味著,如果頁面需要任何其他使用那個類的元素,那么測試就會不可靠或被破壞。雖然你可以將其限制在HTML頭里,但這只是緩兵之計。
作為替代,可以使用data-test-屬性:
function purchase(productId) {
$.post(
"/products/",
{ "id": productId }
).done(function() {
$(".header").html(
"
Your order was placed
");
}).fail(function() {
$(".header").html(
"
There was a problem
");
});
雖然這增加了標(biāo)記的字節(jié),但它讓你可以編寫一個能夠不受某些視覺變化影響的可靠測試。只要頁面流程是在一次成功的購買后顯示一條消息,那么可視化實現(xiàn)就可以修改而又不破壞測試。這是你想要的,這是一種權(quán)衡。你也可以犧牲掉這份自信,創(chuàng)建較小較起碼的標(biāo)記,但當(dāng)顯示效果變化時,你要么花時間修復(fù)測試,被迫手動QA,要么就發(fā)布沒有經(jīng)過充分測試的軟件。
如今的端到端測試工具,如Capybara,包含你需要的所有功能。它提供了方法,可以在繼續(xù)測試過程之前等待DOM元素出現(xiàn),斷言頁面特定部分的內(nèi)容,同表單元素交互。大多數(shù)其他Web應(yīng)用程序棧都提供了類似的工具。不管怎樣,你可以將測試庫與像PhantomJS這樣的無界面瀏覽器結(jié)合,從而使端到端測試出奇地快速可靠。
還有一點值得注意,就是在一個分布式的環(huán)境中如何完成這項工作。
當(dāng)“應(yīng)用”多于一個
當(dāng)對單個整體系統(tǒng)進(jìn)行測試時,上述技術(shù)就完全夠用了。然而,如果是對一個較為分散的系統(tǒng)進(jìn)行測試,情況就要復(fù)雜些了。假設(shè)你正致力于一個面向客戶的應(yīng)用程序,但它必須從另一個系統(tǒng)獲取庫存數(shù)據(jù)。你如何為此編寫一個測試呢?
首先,記住你在測試什么。端到端測試是測試用戶交互。這意味著,端到端測試不用負(fù)責(zé)斷言遠(yuǎn)程服務(wù)的功能,也不用負(fù)責(zé)斷言應(yīng)用程序正確地消費(fèi)了那個遠(yuǎn)程服務(wù)。
測試服務(wù)消費(fèi)的較佳方式是使用“消費(fèi)者驅(qū)動的契約(consumer-driven contracts)”,這是一種單元測試的形式(至少在這篇博文中我所做的寬泛界定中是這樣)。
對于在端到端測試中如何模擬遠(yuǎn)程服務(wù),至此仍然沒有定論。你可以搭建該服務(wù)的一個實際版本,但這并不是很好。你較終不得不管理那個服務(wù)的內(nèi)部數(shù)據(jù)存儲以及它所依賴的服務(wù)。那會使復(fù)雜性迅速增加,難以管理。
一個常見的選擇是使用一個HTTP層的模擬系統(tǒng)。在Ruby中,VCR是一款具備這種功能的工具。你錄制同真實服務(wù)交互以建立HTTP協(xié)議往返的過程,在隨后運(yùn)行測試時,模擬系統(tǒng)會回放錄制好的交互,而不必使用網(wǎng)絡(luò)。如果單元測試覆蓋了服務(wù)的正確消費(fèi),那么這對于端到端測試就會很有效。
另一個選擇是搭建一個經(jīng)過簡化的模擬服務(wù),該服務(wù)返回預(yù)先準(zhǔn)備好的數(shù)據(jù)。應(yīng)用會像平常一樣進(jìn)行HTTP調(diào)用,但調(diào)用的是一個預(yù)先準(zhǔn)備好、只向應(yīng)用返回靜態(tài)已知數(shù)據(jù)的服務(wù)。這需要提前做些配置,但對簡單的服務(wù)交互很有效。如果應(yīng)用程序需要在服務(wù)中存儲狀態(tài),并有一個漫長的往返“對話”,那么這項技術(shù)就要難一些了。
我的建議是首先嘗試模擬HTTP,因為那既簡單又快捷。
現(xiàn)在,我們知道在端到端測試中測試什么以及如何測試,那么單元測試呢?
單元測試
回想一下,對于什么應(yīng)該進(jìn)行端到端的測試,我們的標(biāo)準(zhǔn)是用戶流程。其思想是,雖然整個系統(tǒng)有許多可能的邏輯流程,但能對用戶體驗產(chǎn)生影響的要少很多。單元測試就是要測試那些邏輯流程的剩余部分。
這讓我們可以快速可靠地斷言系統(tǒng)大部分功能的正確行為。換句話說,雖然我們可以使用端到端測試斷言整個系統(tǒng)中每個可能的流程,但那沒有必要,而且會非常緩慢和脆弱。
例如,假設(shè)一個結(jié)算功能有兩個用戶流程:一個是購買成功,一個是購買失敗,用戶必須重試。那會有兩個端到端測試。讓我們進(jìn)一步假設(shè),后臺有如下可能性:
客戶的信用卡正確扣款;
與客戶銀行的通信存在問題,但我們想假裝它是成功的,并在稍后扣款;
客戶的信用卡被拒絕;
客戶的信用卡過期。
這是四個流程,所以我們希望有四個單元測試可以斷言其中每一種情況都得到了正確處理。是的,會有重復(fù)覆蓋。在端到端測試中,我們可能會創(chuàng)建成功扣款和拒絕兩個測試來處理該功能的兩個用戶流程,因此,當(dāng)編寫單元測試時,我們的覆蓋率就會超過理論上的需要。
再一次,這是一種權(quán)衡,但重要的是,單元測試可以很好地覆蓋你的類。這就允許它們改變位置、用途,而且更容易修改。
關(guān)于如何編寫單元測試,有許多許多的理論,遠(yuǎn)遠(yuǎn)超出了我們這里的討論范圍。我的建議是采用一種對你有用同時也容易跟別人解釋的技術(shù),并一直使用。
對于單元測試,較困難的部分是決定代碼設(shè)計要在多大程度上為測試考慮。這就類似我們?nèi)绾螢榱藴y試向HTML中增加屬性和其他標(biāo)識——那些工件只是因為我們要測試而存在。在編寫單元測試時,你會面臨同樣的選擇。
例如,假設(shè)Purchaser類實現(xiàn)了信用卡扣款代碼。假設(shè)它將使用第三方提供的AwesomePayments進(jìn)行實際地扣款。
class Purchaser
def charge(purchase)
AwesomePayments.charge(purchase.customer.id,purchase.amount)
rescue => ex
try_again_later(purchase.id)
end
# ...
end
上述代碼清晰易懂,在不需要單元測試的情況下,這可能是較理想的設(shè)計了。然而,為了讓測試更簡單,我們可能想控制AwesomePayments的實例:
class Purchaser
def initialize(awesome_payments = AwesomePayments)
@awesome_payments = awesome_payments
end
def charge(purchase)
@awesome_payments.charge(purchase.customer.id,purchase.amount)
rescue => ex
try_again_later(purchase.id)
end
end
現(xiàn)在,就可以在測試時傳入AwesomePayments的模擬實現(xiàn),從而更好地控制測試。測試已經(jīng)影響了我們的設(shè)計(雖然這里的影響比較小)。你甚至可以說,這個類就是更好的代碼。但情況并非總是如此。
我會使用同你處理端到端測試一樣的標(biāo)準(zhǔn):做讓生活更輕松的事,但不要做過頭,務(wù)必要恰到好處。