Test-Driven Development(TDD) in Go

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

本篇中的代碼的完整示例能夠在這裏找到:tdd-examplegit

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的開發過程描述如圖:github

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

軟件設計沒有銀彈,三原則是TDD思想的一種體現,並非不可打破的教條。當你使用TDD已有些時日,或已領略到更好的方法,徹底能夠另闢蹊徑。但當咱們在剛開始熟悉一項新技術時,遵循原則每每纔是最快的上手辦法。數據庫

示例

需求背景

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

  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方法即是待測方法。less

// 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?

測試便是文檔

相關文章
相關標籤/搜索