1. 程式人生 > 程式設計 >Test-Driven Development(TDD) in Go

Test-Driven Development(TDD) in Go

TDD,也就是測試驅動開發(Test-Driven development),是一種“測試先行”的程式設計方法論,其基本流程圍繞著測試->編碼(重構)->測試的迴圈展開。TDD的概念已不新鮮,但似乎並沒有得到大範圍的推廣應用,或許是因為其成本太高,亦或許是因為開發人員的排斥,但這並不能掩蓋TDD自身的優點和獨到之處。在嘗試用Go語言實踐TDD開發一段時間後,我發現Go程式很適合使用TDD來構建——Go語言對測試的原生支援以及完善的測試類庫框架使得TDD的實施成本相對較低,這相當於放大了TDD的收益。在此向廣大gopher們安利一波,說不定你也會愛上它。本篇將從實際業務視角觸發,通過一個示例來演示如何運用TDD來構建我們的Go程式。

本篇中的程式碼的完整示例可以在這裡找到:tdd-example

TDD三原則

  1. 除非為了通過一個單元測試,否則不允許編寫任何產品程式碼。
  2. 在一個單元測試中只允許編寫剛好能夠導致失敗的內容。
  3. 一次只能寫通過一項單元測試的產品程式碼,不能多寫。
  1. You are not allowed to write any production code unless it is to make a failing unit test pass.
  2. You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
  3. You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

根據三原則,TDD的開發過程描述如圖:

在下面的示例中,將遵循上述的三原則,圍繞著這五個步驟,展示如何使用TDD來開發我們的Go程式。

軟體設計沒有銀彈,三原則是TDD思想的一種體現,並不是不可打破的教條。當你使用TDD已有些時日,或已領略到更好的方法,完全可以另闢蹊徑。但當我們在剛開始熟悉一項新技術時,遵循原則往往才是最快的上手辦法。

示例

需求背景

某外賣平臺為了提供更優質的配送服務,決定在外賣小哥主動搶單的基礎上增加主動派單,功能概述如下:接收訂單派送請求,按排程規則,為使用者選擇一名外賣小哥進行配送,並通知外賣小哥取餐。在需求研討會後,產品經理給出了第一版需求:

  1. 僅從距離商戶5公里內的外賣小哥當中選擇配送員。
  2. 對於購買了準時寶的使用者,優先選擇訂單配送數量最少的小哥配送。
  3. 對於其他使用者,隨機分配一名小哥服務。
  4. 當外賣小哥當前配送訂單數>=10後,將不再分配新訂單。

整理測試用例

根據TDD的三原則,我們需要先寫測試方法,所以首先我們需要整理出測試用例。這部分工作可以由開發與測試共同協作完成。在對需求梳理了一番後,整理出如下測試用例:

  1. 商戶5公里內沒有外賣小哥存在時,返回錯誤,不執行後續派單操作。
  2. 商戶5公里內所有的外賣小哥配送訂單數全部>=10時,返回錯誤,不執行後續派單操作。
  3. 如下單使用者購買了準時寶,選擇一個訂單最少的小哥,通知小哥取餐。
  4. 如下單使用者未購買準時寶,隨機分配一名小哥服務,通知小哥取餐。

識別依賴,抽象成介面

分析需求和測試用例,識別其中的依賴,並將其抽象成介面。通常來說我們可以先將最容易抽象的依賴——網路,I/O以及中介軟體等外部依賴抽象成介面。假設該訂單配送模組使用MongoDB(支援地理位置索引)來儲存外賣小哥的實時位置;使用訊息佇列來通知外賣小哥取餐。即該功能包含資料庫與訊息佇列兩個依賴,我們將上述依賴定義成如下介面:

// DeliverBoyRepository 外賣小哥倉儲介面
type DeliverBoyRepository interface {
	// GetNearBy 獲取指定shopID內distance公里範圍內的外賣小哥列表
	GetNearBy(shopID int,distance int) ([]*DeliveryBoy,error)
}
// DeliveryBoy 表示一個外賣小哥
type DeliveryBoy struct {
	ID int
	OrderNum int // 正在配送的訂單數
}

// Notifier 訊息佇列介面
type Notifier interface {
	// 通知指定外賣小哥取餐
	NotifyDeliveryBoy(boyID int,orderID int)
}
複製程式碼

使用結構體包裹所有依賴

使用一個結構體將剛才定義的介面封裝起來,下面的程式碼片段將上述介面包裹在Handler結構體中,通過NewHandler注入依賴(建構函式注入)。其中的Handle方法便是待測方法。

// Handler 主動派單業務處理結構體
type Handler struct {
	boyRepo DeliverBoyRepository
	notifier Notifier
}
// NewHandler 使用建構函式將依賴注入
func NewHandler(d DeliverBoyRepository,n Notifier) *Handler {
	return &Handler{
		boyRepo:d,notifier:n,}
}

// Request 表示一個要處理的請求
type Request struct {
	// OrderID 訂單ID
	OrderID int
	// ShopID 商戶ID
	ShopID int
	// Insured 是否購買“準時寶”
	Insured bool
}

// Handle 訂單配送邏輯處理
func (h *Handler) Handle(req *Request) (err error) { // <--- 待測方法
	return nil
}

複製程式碼

至此我們的待測方法已經準備好,下面開始正式進入TDD的編碼迴圈。

測試->編碼(重構)->測試迴圈

為每一個用例編寫測試方法,然後再編寫業務程式碼使測試通過。我們從第一個用例開始:商戶5公里內沒有外賣小哥存在時,返回錯誤,不執行後續派單操作。在此我們使用gomocktestify來幫助我們快速編寫測試程式碼。對它們不熟悉的小夥伴也不用擔心,並不會對理解本示例造成太大困擾。

搞定Go單元測試(二)—— mock框架(gomock)
搞定Go單元測試(三)—— 斷言(testify)

1. 寫測試

// 1. 商戶5公里內沒有外賣小哥存在時,返回錯誤,不執行後續派單操作
func TestHandler_Handle_NoBoy(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer  ctrl.Finish()
	a := assert.New(t)

	// 使用gomock 來mock依賴
	d := NewMockDeliveryBoyRepository(ctrl)
	n := NewMockNotifier(ctrl)
	h := NewHandler(d,n)
	
	req := &Request{
		OrderID:1,ShopID:2,}
	// 5公里內沒有外賣小哥
	d.EXPECT().GetNearBy(req.ShopID,5).Return(nil,errors.New("o no..5公里內沒有外賣小哥"))
	err := h.Handle(req)
	a.Error(err)
}
複製程式碼

2. 執行測試,得到失敗結果

=== RUN   TestHandler_Handle_NoBoy
--- FAIL: TestHandler_Handle_NoBoy (0.00s)
    handler_test.go:29: 
        	Error Trace:	handler_test.go:29
        	Error:      	An error is expected but got nil.
        	Test:       	TestHandler_Handle_NoBoy
    handler_test.go:30: missing call(s) to *handler.MockDeliveryBoyRepository.GetNearBy(is equal to 2,is equal to 5) D:/yushen/gopath/src/github.com/DrmagicE/tdd-example/handler/handler_test.go:27
    handler_test.go:30: aborting test due to missing call(s)
FAIL
複製程式碼

失敗結果告訴我們兩個資訊:

  1. 期望Handle方法應該返回一個error但是返回了nil
  2. 缺少了對GetNearBy(2,5)的方法呼叫

3. 寫業務程式碼

接下來,我們編寫業務程式碼通過上面的測試方法,切記不要寫多,只寫對應測試用例的程式碼:

// Handle 訂單配送邏輯處理
func (h *Handler) Handle(req *Request) (error) {
	 _,err := h.boyRepo.GetNearBy(req.ShopID,5)
	return err
}
複製程式碼

4. 測試通過

再次執行測試,確保測試用例通過:

=== RUN   TestHandler_Handle_NoBoy
--- PASS: TestHandler_Handle_NoBoy (0.00s)
PASS
複製程式碼

測試通過後,再開始寫第二個測試用例,然後緊接著相應的業務程式碼,如此往復迴圈,直至所有的測試用例都測試通過。完整的測試方法請參看示例原始碼,就不在此展開。

5. 重構

隨著測試程式碼->業務程式碼迴圈的增加,業務程式碼也不斷的增加,如有必要,我們需要對業務程式碼進行重構。單元測試能保障舊有邏輯不被重構破壞。剛開始的重構可能只是涉及到if...else,for ... range改動,或是將可複用程式碼封裝成函式等。但隨著業務發展,上述的Handle()方法的程式碼越來越多,在適當的時候,我們需要抽象出新的介面層。舉個栗子,隨著上述外賣平臺發展壯大,訂單排程規則會越來越複雜,比如將訂單配送路線和訂單使用者的集中程度作為訂單排程的依據時,我們可以抽象出新的介面層:

// FactorsCalculator 計算各種配送因子
type FactorsCalculator interface {
	// GetDirectionFactor 分析小哥訂配送路線,得到路線因子
	GetDirectionFactor(boyID int,orderID int) int
	//  GetUserLocationFactor 分析小哥訂單的使用者集中度,得到使用者集中度因子
	GetUserLocationFactor(boyID int,orderID int)  int
}
複製程式碼

對於Handle而言,其測試用例無需理解具體如何計算路線因子和使用者集中度因子,只需mock其介面即可。對於因子計算正不正確,這是實現FactorsCalculator介面的模組所需要考慮的問題。(分層,分模組測試)

TDD能帶給我們哪些好處

1. 加深對需求的理解
從上面的示例中可以發現,先寫測試要求我們需要先整理測試用例,這就意味著開發必須將需求消化後才能編寫業務程式碼。不僅開發對需求的理解更深,TDD還能促進測試,產品以及其他團隊角色對需求達成共識,避免出現以下尷尬的場面:

測試:開發同學,我發現了一個BUG!
開發:No...No...No,你用例有問題,這是正常情況,不信我們去問產品
產品:emm..好像我當初不是這樣設計的

2.提高編碼效率和程式質量
我們每天都在寫BUG,雖然無法杜絕BUG的產生,但使用TDD我們可以將大部分BUG扼殺在開發編碼階段。眾所周知,在軟體開發生命週期中,BUG發現的越晚,其代價也就越高。TDD能顯著提高我們的程式質量,為我們節省大量成本。雖然短期內使用TDD可能會導致開發效率小幅度下降,但這點小損失相比因為BUG而引起的損失可以忽略不計。而且一旦熟悉TDD後,編碼效率可謂是隻增不減。

3. 有助程式設計
好的設計應當是逐步演進而來的,沒有誰能一開始就將程式的結構和層次設計清楚,設計的越多就越容易“過度設計”。如果使用TDD,程式的設計隨著不斷的重構而不斷的演進的,可以避免“過度設計”,且讓程式一直保持其鬆耦合度和靈活性。

4. 有一定的檔案價值
開發最討厭的兩件事:

  1. 閱讀沒有檔案的程式碼
  2. 為自己的程式碼寫檔案

不可否認檔案是不可或缺的,但同時維護一份檔案並保持其準確性和實時性的代價是相當高的。如果有一種檔案會自己隨著程式的更新而更新,而且準確性也有保障,豈不美哉?你還在為寫檔案而煩惱嗎?那就讓TDD來幫你吧!

檔案是軟體不可或缺的一部分。正如軟體的其它部分一樣,它也得經常進行測試,這樣才能保證它是準確的並且是最新的。實現這個最有效的方法就是將這個可執行的檔案能夠整合到你的持續整合系統裡面。TDD是這個方向的不二選擇。從較低層面來看的話,單元測試就非常適合作為這個檔案。另一方面來說的話,在功能層面來說BDD是一個很好的方式,它可以使用自然語言來進行描述,這保證了檔案的可讀性。
www.51testing.com/html/54/n-8…

其他參考

測試驅動開發實踐 - Test-Driven Development

為什麼你無法說服你的同事使用TDD?

測試即是檔案