測試驅動開發在專案中的實踐

lanzhiheng發表於2020-12-07

好久沒有動筆寫文章了,今天來寫點什麼。這篇文章主要簡單談談最近把測試驅動開發應用在公司專案中的心得體會。原文連結: https://www.lanzhiheng.com/posts/tdd-in-our-project


好久沒有動筆寫文章了,今天來寫點什麼。這篇文章主要簡單談談最近把測試驅動開發應用在公司專案中的心得體會。雖說主要技術棧是Ruby一系,但相信對其他的領域也有一定的參考價值吧。

前言

近期一直都在給公司的新產品添磚加瓦,眼看著第一版即將釋出,也稍微能喘口氣寫點兒什麼。第一次獨自用Ruby On Rails編寫專案程式碼,內心多少有一點忐忑。與在豆廠時期不同,周邊沒有許多已成規範的東西,技術選型上也沒那麼多理所當然,這一切得從0開始。

這其中各有各的好,有大量前輩的經驗可以借鑑,以及可複用的程式碼,在某種程度上能夠讓自己少走些彎路,降低技術決策的成本。但如果沒能站在巨人的肩膀上,那就只能自己去做些嘗試,看看什麼才是適合自己當前專案的,自由度會稍微高一些。多少讓我想起《解憂雜貨店》中的劇情,一張白紙,也意味著可以隨意塗鴉。為了保證程式碼質量,也降低組員們介入測試時的工作量,我決定給專案引入測試驅動的開發方式。

個把月過去,不知不覺已經累計了大概有500多個測試用例,總體效果還是比較滿意的:

500-test.png

如何寫測試

編寫測試大概有以下兩個比較極端的場景:

1. 自頂向下

從開發者的層面指的其實就是端對端的測試。我們可以通過程式碼來模擬瀏覽器的行為,藉此測試已有的業務邏輯。這種方式的好處在於能夠模擬真實使用者,一些繁瑣的點選操作完全由機器來取代,降低日後迴歸測試的成本。然而這類測試往往編寫難度較大,“殺雞用牛刀”-大概就是這種感覺。想象一下假設你要用這種方法來測試介面,那麼你可能需要:

(用程式碼的方式)開啟瀏覽器 --> 在瀏覽器輸入介面的URL --> 點選確認 --> 等待頁面載入 --> 檢查頁面內容(或者返回的資料)是否符合預期

看到這裡也許有人會問:“等等,為什麼我測試個介面要開啟瀏覽器?”。對啊,為什麼我們要開啟瀏覽器?簡單點不好嗎?

2. 自底向上

直接從終端視角來編寫測試“價效比”不高,很多時候你的網站還沒活到能見到“終端”的階段就已經夭折,而在這之前測試幾乎不能給開發過程帶來任何收益。或許可以換個角度,從最底層寫起。也就是我們常說的單元測試。這種測試編寫起來十分簡便,熟練編寫不僅能加快開發的速度,還能保證交付程式碼的質量。不過許多人剛開始寫這種測試很容易就會迷失,因為自己也抓不準哪一個模組才是最有測試價值的,於是他們就想朝著測試覆蓋率百分百的目標前進。最終的結果就是身心俱疲。

考慮下面這個程式碼

class A
def end_method
return_a + return_b + return_c + return_d
end

private

def return_a
'a'
end

def return_b
'b'
end

def return_c
'c'
end

def return_d
'd'
end
end

完美主義者會給每一個底層方法都加上測試,最後大概會是這樣子:

RSpec.describe do
describe 'method' do
let(:instance) { A.new }

it 'test size private methods' do
expect(instance.send(:return_a)).to eq('a')
expect(instance.send(:return_b)).to eq('b')
expect(instance.send(:return_c)).to eq('c')
expect(instance.send(:return_d)).to eq('d')
end

it 'test public method' do
expect(instance.end_method).to eq('abcd')
end
end
end

其實在務實主義者眼中測試只需要

RSpec.describe do
describe 'method' do
let(:instance) { A.new }

it 'test public method' do
expect(instance.end_method).to eq('abcd')
end
end
end

把公有的方法測試好足矣,有些時候甚至公有方法都不需要寫測試(尺度還需要開發者自己把控)。測試覆蓋率100%除了能夠帶來圍觀群眾們的驚歎,並不能帶來任何實質的收益。相反,不必要的測試太多,還會帶來更高的維護成本,試想如果你給一個簡單的方法編寫了十幾個用例,試問誰敢去碰那個方法?並不是這個方法本身有多難大家不敢去改,而是沒有人願意同步去調整那十幾個測試用例。

既然兩種極端都不可取,那麼我就採取一個折中的方案。接下來我想分享我自己在專案中所採用的測試策略。說不定能夠給予那些遲遲不敢寫測試的同學一點勇氣吧?

測試策略

介紹策略之前先來簡單說說專案背景。我司目前的專案主要是小程式,也就是說我後端這邊需要做的東西有兩個

  1. 日常運營所需的後臺管理系統。
  2. 小程式應用所需要的介面。

技術棧為Ruby On Rails。以下方略大多以Ruby為背景,不過大體策略應該都是通用的。

1. 有選擇地去測試

當一個專案日趨龐大之後,構建專案所需要的類會慢慢增多,相應的,他們所包含的方法也越來越多。如果不加分辨地對所有方法加以測試覆蓋,那麼不但會給自己造成心理負擔,還會佔用掉編寫更有價值測試的時間。減少對不必要測試的編寫,也就是為那些真正重要的事情騰出時間。從長遠來看那個執行復雜運算的方法,出錯的機率更大,帶來的損失可能也更直接。反之,像下面這種測試又有多大必要呢?

def return_true
true
end

RSpec.describe do
it 'test true' do
expect(return_true).to be(true)
end
end

對於類中的私有方法,除非真的十分複雜,否則個人不建議為他們編寫測試。從實用的角度來看,私有方法往往是為了被類中的其他方法呼叫而存在的,他們的最終歸宿就是服務於公有方法。那麼換個角度想,公有方法在某種程度上其實就是私有方法的測試用例。與其累死累活地為每一個私有方法編寫測試,還不如給那些暴露出去的公有方法多測試幾組邊界情況。

如果在許多場景下公有方法都能夠得到正確的結果,那麼服務於他的私有方法一般也不會錯哪裡去。哪怕真的錯了,到時候再給那個出錯的方法補測試即可,保證下次不犯同樣的錯誤。也就是我接下來要說的,錯誤導向測試。

2. 錯誤導向測試

第一次寫測試,很容易會陷入到“不知道應該測試什麼的窘境”。接下來,為了讓自己看起來很充實,我們會不加分辨地給所有方法加上單元測試,以求測試覆蓋率100%,這就是所謂的“戰略上的懶惰,戰術上的勤奮”吧?如果你是在一個通過程式碼行數來定工資的公司工作,這種做法無疑是明智的。然而如果專案在快速迭中,公司吃了上頓不知道有沒有下頓,那我們們還是別這樣做的好。

我推薦錯誤導向的測試。也就是說對一些自己有把握的方法,在一開始可以不用給他們加測試。然而這個方法一旦暴雷,那麼修bug的時候就要給它加上測試,避免它下一次再暴雷(可參考《程式設計師修煉之道》一書對測試的闡述)。筆者也用這種策略補了很多測試程式碼。比如:

  • 前端跟我說介面漏了幾個欄位?馬上編寫測試保證介面會返回相應的欄位。
  • 介面接收引數的時候沒有做空判斷,導致傳入空值的時候會報500的異常?立馬編寫引數為空值的測試用例,然後修改程式碼讓這個測試通過。
  • 資料庫出現了跟預期不一致的資料?在測試中構造這種奇葩的資料,嘗試入庫,預期入庫失敗。然後調整校驗程式碼,讓測試通過。

這種針對系統薄弱的地方來編寫測試的策略,編寫起來更省力,所帶來的效益也更高,最起碼能做到孔子說的“不貳過”吧?同樣,那些選擇了測試後行的同學我也建議採用這種方式去補測試,這樣更有針對性,畢竟每天都有bug要修,修的bug多了,有價值的測試也越來越多。

3. 儘可能少針對頁面佈局寫測試

相對於後端的業務邏輯,其實前端的頁面結構更改的的機率會大很多。假設你針對一個頁面寫了大量的斷言(rspec-html-matchers瞭解一下)

RSpec.describe do
it do
expect('<p class="qwe rty" id="qwerty">Paragraph</p>').to have_tag('p', :with => { :class => 'qwe rty' })
expect('<p class="
qwe rty" id="qwerty">Paragraph</p>').to have_tag('p', :with => { :class => 'rty qwe' })
expect('<p class="
qwe rty" id="qwerty">Paragraph</p>').to have_tag('p', :with => { :class => ['rty', 'qwe'] })
expect('<p class="
qwe rty" id="qwerty">Paragraph</p>').to have_tag('p', :with => { :class => ['qwe', 'rty'] })
end
end

上面的案例,不僅僅檢測了元素的存在,還把類名都匹配出來。我感覺rspec-html-matchers的維護者用這些程式碼來做例子可能只是為了展示這個庫的能力,應該不會希望使用者在專案裡面這樣去寫測試。不然的話要是剛好要改個類名,我得調整多少測試?這種程式碼在筆者看來也是不健壯的。想想我要刪掉一個按鈕都要去改一下測試是多難受的事情。

頁面佈局,按鈕有無這種場景,除非自己真的很閒,否則就別給他們寫測試了。多一個少一個按鈕對系統來說並不會造成太大的影響,要去維護這堆測試卻特別累人。還不如參考2的建議,等到出了真正的bug的時候再針對他們來寫一個測試?比如在訂單的某個狀態下應該顯示XX按鈕,這次我漏了,那麼修復bug的時候就給這種場景加上測試,保證下次不出錯即可。

4. 給所有介面都加上測試用例

介面的種類有很多,但目前最基礎的無非就是CRUD。我們要麼就是通過介面來獲取後臺的資訊,要麼就是通過介面來修改後臺的資料。那麼介面的測試過程或許可以簡化成:

  1. 組裝請求連結。
  2. 利用客戶端傳送相關的請求。
  3. 檢查返回的狀態碼,如果有必要再檢查返回的資料正確性。
  4. 檢查請求所造成的副作用。

對介面的測試,我採用的框架是rswag。它很好的貫徹了,測試即文件這一理念。用它來寫測試還能順便生成Swagger介面文件,真的不能更棒了。比如我要測試獲取部落格詳情資料的介面,那麼可以這樣去寫

describe 'Blogs API' do
path '/blogs/{id}' do
get 'Retrieves a blog' do
tags 'Blogs'
produces 'application/json'
parameter name: :id, in: :path, type: :string

response '200', 'blog found' do
let(:id) { Blog.create(title: 'foo', content: 'bar').id }
run_test! do |response|
# Check the field of result
end
end

response 404', 'blog not found' do
let(:id) { 'fake-id' }
run_test!
end
end
end
end

程式碼做的事情很簡單,就是測試/blogs/{id}這個模式的URL,傳送GET請求的場景。可以在請求傳送之前通過RSpec的語法設定資源的id,當執行run_test!的時候框架會自動幫你傳送請求。如上面所期待的,我們期望找到資源的時候得到狀態碼200,而找不到資源的時候接收404狀態碼。

如果需要進一步檢測請求的響應情況,則可以在run_test!後面加上程式碼塊,並解構response。這種測試的組裝方式一開始看起來是很奇葩,我第一感覺就是巢狀層數太多了。後來寫習慣了就好,也慢慢理解它為何這樣做。不過這個框架給我的感覺就是文件用例有點少,不少資源/引數的定義方式都要去參考Swagger。這些大家用到的時候再慢慢去探索吧。

目前我用這種方式給我司的專案提供了大概55個介面,bug率比想象中少。而且關鍵的是,在開發介面的過程中,幾乎沒有開啟過瀏覽器,都是直接寫測試,寫業務,跑測試。不得不說其實工作效率要比開瀏覽器逐個場景去校驗高效得多。

5. 測試最好不要後行

我們經常會說:“等有空再去補測試吧。”然而,多年的工作經驗告訴我,補測試這個事情,其實是下下策。下面對比以下兩個流程

測試先行: 想想怎麼寫測試 -> 寫測試 -> 寫程式碼 -> 跑測試 -> 完工
測試後行: 研究已有程式碼 -> 想想怎麼寫測試 -> 寫測試 -> 跑測試 -> 調整測試以適應程式碼 -> 完工

研究已有的程式碼,意味著你要重新去審閱過去的程式碼,不管這段程式碼是不是自己寫的,要重新理解並補上測試都是需要時間的。而且往往如果測試不通過,我們並不敢第一時間去改程式碼,因為我們害怕弄壞原來已經跑著正常的業務程式碼,多數情況下就是不斷改測試,來“適應”已有的程式碼。不折騰幾番之後根本就不知道,到底是原來的程式碼有問題,還是自己寫的測試有問題。然而這些都是測試後行的時間成本。

個人感覺,除非是客戶要求,或者錢給夠,否則測試後行真的很難。而且過程極其痛苦。如果你真的有寫測試的打算,還不如一開始就把用例給加上。

推薦技術

1. 資料構造

一開始專案比較簡單的時候我是直接採用了Rails自帶的fixture功能,也就是測試開始之前先把必要的資料入庫,然後依賴這些資料來寫測試。但後面發現,這種方式雖然測試執行的速度很可觀,但是依賴性太強,寫起來不是特別方便。筆者更傾向於跑測試的時候針對特定用例去構造資料,個人推薦用factory_bot。雖然這樣測試執行起來要慢一些,但是寫起來就很舒服了(似乎有點像動態語言跟靜態語言之爭)。這一個月大概寫了550個測試用例,全面跑完花費時間大概是5-7分鐘,還有很大的優化空間。

2. 測試框架

單純地測試API的話其實Rails自帶的測試套件就能夠滿足要求。本質上我們只需要

  1. 組裝api
  2. 傳送請求
  3. 檢測響應狀態碼,還有響應體。

不需要太多花裡胡哨的功能,我之所以後面用RSpec,主要是因為我還要給前端小夥子提供Swagger的API文件,而剛好它的倉庫rswag又是依賴了RSpec,所以就乾脆整個專案都換成RSpec了。然而它並非什麼必需品,RubyChina的Homeland專案至今都沒有引入RSpec,測試寫起來也沒什麼毛病。

3. CI服務

個人覺得更好的開發流程肯定還是有人相互Review程式碼。程式碼推送到託管平臺之後有CI服務來幫忙跑測試,測試不過的一律不予合併。合併之後自動部署到測試環境。寫了測試之後才覺得CI是如此重要,起碼它能幫你把測試跟部署的工作都放到雲端。你自己的機子就專門用於開發,並測試那些關鍵的模組就好,並不需要跑全域性測試。

我們的專案託管在Github上,是採用了CircleCI來跑測試,你也可以跟社群一樣採用Travis來跑。不過個人覺得有時間的話嘗試自建或許更好一些。筆者本來也以為CircleCI的免費版都夠我用了,無奈隨著用例越來越多,CircleCI所提供的每週信用額度在週三就消耗殆盡(週日清0),導致後面幾天只能在本地去跑測試了,後面會嘗試使用自建的CI服務。

結語

以上就是近期嘗試測試驅動開發的心得體會。反正目前看來還是覺得挺不錯的,希望能夠堅持下去。很多人會擔心加了測試之後會降低開發的速度,其實從個人的使用角度來看倒還好。特別是編寫介面的時候,不用開啟瀏覽器就幾乎能完成所有介面的開發,而且測試用例就能幫我檢測一些欄位的正確性(規避了肉眼檢測的疏忽),許多重構程式碼時的暴雷都能很好地幫我檢測出來。

雖說前期學習成本有點大,不過堅持一段時間之後好處漸漸顯現出來了,Bug率也明顯降低。建議有條件的同學可以在專案中試試,我覺得它的壞處大概就是熟悉了這一套流程之後,會養成習慣,一旦不寫測試都不知道怎麼寫程式碼了。

相關文章