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