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