根據維基百科的定義,測試驅動開發(Test-driven development 簡稱爲 TDD)是一種軟件開發過程,這種軟件開發過程依賴於對一個很是短的開發循環週期的重複執行。先把需求轉換成很是具體的測試用例,而後對軟件進行編碼讓測試用例經過,最後對軟件進行改進重構,消除代碼的重複,保持代碼整潔。沒有測試驗證的功能,不爲會爲其編寫代碼。html
TDD 是由多個很是短的開發循環週期組成,一個 TDD 開發循環包括以下的3個步驟:前端
這3個步驟就是人們常說的紅、綠、重構循環,這就是一個完整的 TDD 開發循環週期。git
TDD 起源於極限編程中的測試先行編程原則,最先由 Kent Beck 提出。TDD 是一種編程技巧,TDD 的主要目標是讓代碼整潔簡單無 Bug。世界著名的軟件大師 Kent Beck、Martin Fowler、Robert C. Martin 均表示支持 TDD 開發模式,他們甚至和 David Heinemeier Hansson 就 TDD 自己以及 TDD 對軟件設計開發的影響有過深刻的討論:Is TDD Dead?github
TDD 的優勢有不少,下面列出幾點我認爲比較重要的優勢:數據庫
一個完整的 TDD 開發循環以下圖所示:express
TDD 三定律編程
TDD 開發策略api
根據錯誤的狀況,僞實現和明顯實現能夠交替進行,當開發進行順暢時,可使用明顯實現,當開發過程當中常常碰到錯誤時,可使用僞實現,慢慢找回自信,而後再使用明顯實現進行開發。當徹底沒有實現思路或者實現思路不清晰時, 可使用三角法來驅動咱們開發,逐漸理清思路。安全
咱們使用 Go 語言來開發一個簡單的 http 服務來演示 TDD 開發模式。服務支持以下的兩種功能:bash
GET /users/{name}
會返回用戶使用 POST 方法 調用 API 的次數。POST /users/{name}
會記錄用戶的一次 API 調用,把以前的 API 調用次數加1。TDD 示例代碼倉庫地址 github.com/mgxian/tdd-…
測試獲取 will 的 API 調用次數,並驗證響應碼
func TestGetUsers(t *testing.T) {
t.Run("return will's api call count", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/users/will", nil)
response := httptest.NewRecorder()
UserServer(response, request)
got := response.Code
want := http.StatusOK
if got != want {
t.Errorf("got %d, want %d", got, want)
}
})
}
複製代碼
運行測試你會獲得以下所示的錯誤
.\user_test.go:13:3: undefined: UserServer
複製代碼
如今讓咱們添加對UserServer
函數的定義
func UserServer() {}
複製代碼
再次運行測試你會獲得以下的錯誤
.\user_test.go:13:13: too many arguments in call to UserServer
have (*httptest.ResponseRecorder, *http.Request)
want ()
複製代碼
如今讓咱們給函數添加相應的參數
func UserServer(w http.ResponseWriter, r *http.Request) {}
複製代碼
再次運行測試,測試經過了。
測試響應數據
func TestGetUsers(t *testing.T) {
t.Run("return will's api call count", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/users/will", nil)
response := httptest.NewRecorder()
UserServer(response, request)
got := response.Code
want := http.StatusOK
if got != want {
t.Errorf("got %d, want %d", got, want)
}
gotCount := response.Body.String()
wantCount := "6"
if gotCount != wantCount {
t.Errorf("got % q, want % q", gotCount, wantCount)
}
})
}
複製代碼
運行測試,你會獲得以下的錯誤
user_test.go:23: got "", want "6"
複製代碼
func UserServer(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "6")
}
複製代碼
如今測試經過,可是你確定會想罵人了,你這是寫的啥,直接給寫死了返回值?說好的不要寫死呢?先彆着急,因爲咱們沒有存儲數據的地方,如今返回一個固定值讓測試經過,也不能說不是一個好辦法,後面咱們會來解決這個問題的。
咱們儘可能早的把通過驗證的生產代碼,放到主程序中,這樣咱們能夠儘快的獲得一個可運行的軟件,並且後續的程序結構的改動,能夠及時發現。
package main
import (
"log"
"net/http"
)
func main() {
handler := http.HandlerFunc(UserServer)
if err := http.ListenAndServe(":5000", handler); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
複製代碼
如今讓咱們再嘗試獲取 mgxian 的 API 調用數據
t.Run("return mgxian's api call count", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/users/mgxian", nil)
response := httptest.NewRecorder()
UserServer(response, request)
got := response.Code
want := http.StatusOK
if got != want {
t.Errorf("got %d, want %d", got, want)
}
gotCount := response.Body.String()
wantCount := "8"
if gotCount != wantCount {
t.Errorf("got % q, want % q", gotCount, wantCount)
}
})
複製代碼
如今運行測試,你會獲得以下的錯誤
user_test.go:40: got "6", want "8"
複製代碼
如今讓咱們來修復這個錯誤,爲了能讓咱們能根據 user 的不一樣來響應不一樣的內容,咱們須要從 URL 中獲取到 user ,測試驅動着咱們完成接下來的工做。
func UserServer(w http.ResponseWriter, r *http.Request) {
user := r.URL.Path[len("/users/"):]
if user == "will" {
fmt.Fprint(w, "6")
return
}
if user == "mgxian" {
fmt.Fprint(w, "8")
return
}
}
複製代碼
運行測試經過。
根據 user 來響應不一樣內容的邏輯咱們能夠放在一個單獨的函數中去。
func UserServer(w http.ResponseWriter, r *http.Request) {
user := r.URL.Path[len("/users/"):]
apiCallCount := GetUserAPICallCount(user)
fmt.Fprint(w, apiCallCount)
}
func GetUserAPICallCount(user string) string {
if user == "will" {
return "6"
}
if user == "mgxian" {
return "8"
}
return ""
}
複製代碼
重構以後,運行測試,測試經過,咱們觀察到咱們的測試程序有部分代碼是重複的,咱們也能夠進行重構,不只生產代碼須要重構,測試代碼也須要重構。
func TestGetUsers(t *testing.T) {
t.Run("return will's api call count", func(t *testing.T) {
user := "will"
request := newGetUserAPICallCountRequest(user)
response := httptest.NewRecorder()
UserServer(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertCount(t, response.Body.String(), "6")
})
t.Run("return mgxian's api call count", func(t *testing.T) {
user := "mgxian"
request := newGetUserAPICallCountRequest(user)
response := httptest.NewRecorder()
UserServer(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertCount(t, response.Body.String(), "8")
})
}
func newGetUserAPICallCountRequest(user string) *http.Request {
request, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/users/%s", user), nil)
return request
}
func assertStatus(t *testing.T, got, want int) {
t.Helper()
if got != want {
t.Errorf("wrong status code got %d, want %d", got, want)
}
}
func assertCount(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Errorf("got % q, want % q", got, want)
}
}
複製代碼
運行測試,測試經過,測試代碼重構完成。如今讓咱們進一步的思考,咱們的 UserServer 至關於 MVC 模式中的 Controller ,GetUserAPICallCount 至關於 Model ,咱們應該讓它們之間經過 Interface UserStore 來交流,隔離關注點。爲了能讓 UserServer 使用 UserStore 咱們應該把 UserServer 定義爲 struct 類型。
type UserStore interface {
GetUserAPICallCount(user string) int
}
type UserServer struct {
store UserStore
}
func (u *UserServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := r.URL.Path[len("/users/"):]
apiCallCount := u.store.GetUserAPICallCount(user)
fmt.Fprint(w, apiCallCount)
}
複製代碼
運行測試你會獲得以下的錯誤
main.go:9:30: type UserServer is not an expression
複製代碼
修改 main 函數新建立的 UserServer
func main() {
server := &UserServer{}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
複製代碼
修改測試使用新建立的 UserServer
func TestGetUsers(t *testing.T) {
server := &UserServer{}
t.Run("return will's api call count", func(t *testing.T) {
user := "will"
request := newGetUserAPICallCountRequest(user)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertCount(t, response.Body.String(), "6")
})
t.Run("return mgxian's api call count", func(t *testing.T) {
user := "mgxian"
request := newGetUserAPICallCountRequest(user)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertCount(t, response.Body.String(), "8")
})
}
複製代碼
再次運行測試你會獲得以下的錯誤,這是因爲咱們並無傳遞 UserStore 給 UserServer 。
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x18 pc=0x66575f]
複製代碼
編寫一個 stub 類型的 mock 來模擬測試
type StubUserStore struct {
apiCallCounts map[string]int
}
func (s *StubUserStore) GetUserAPICallCount(user string) int {
return s.apiCallCounts[user]
}
複製代碼
修改測試使用咱們 mock 出來的 StubUserStore
func TestGetUsers(t *testing.T) {
store := StubUserStore{
apiCallCounts: map[string]int{
"will": 6,
"mgxian": 8,
},
}
server := &UserServer{&store}
t.Run("return will's api call count", func(t *testing.T) {
user := "will"
request := newGetUserAPICallCountRequest(user)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertCount(t, response.Body.String(), "6")
})
t.Run("return mgxian's api call count", func(t *testing.T) {
user := "mgxian"
request := newGetUserAPICallCountRequest(user)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertCount(t, response.Body.String(), "8")
})
}
複製代碼
再次運行測試,測試所有經過。
爲了使咱們的主程序能正常運行,咱們須要實現一個假的 UserStore
package main
import (
"log"
"net/http"
)
type InMemoryUserStore struct{}
func (i *InMemoryUserStore) GetUserAPICallCount(user string) int {
return 666
}
func main() {
store := InMemoryUserStore{}
server := &UserServer{&store}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
複製代碼
測試一個不存在的用戶
t.Run("return 404 on unknown user", func(t *testing.T) {
user := "unknown"
request := newGetUserAPICallCountRequest(user)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusNotFound)
})
複製代碼
運行測試獲得以下的錯誤
user_test.go:52: wrong status code got 200, want 404
複製代碼
func (u *UserServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := r.URL.Path[len("/users/"):]
apiCallCount := u.store.GetUserAPICallCount(user)
if apiCallCount == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, apiCallCount)
}
複製代碼
運行測試,測試經過。
測試記錄 API 調用次數,驗證響應碼
func TestStoreAPICalls(t *testing.T) {
store := StubUserStore{
map[string]int{},
}
server := &UserServer{&store}
t.Run("return accepted on POST", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodPost, "/users/will", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusAccepted)
})
}
複製代碼
運行測試,你會獲得以下的錯誤
user_test.go:67: wrong status code got 404, want 202
複製代碼
func (u *UserServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
method := r.Method
if method == http.MethodPost {
w.WriteHeader(http.StatusAccepted)
return
}
user := r.URL.Path[len("/users/"):]
apiCallCount := u.store.GetUserAPICallCount(user)
if apiCallCount == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, apiCallCount)
}
複製代碼
運行測試經過。
把處理 post 和 get 請求的業務邏輯封裝到單獨的函數。
func (u *UserServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
u.showAPICallCount(w, r)
case http.MethodPost:
u.processAPICall(w, r)
}
}
func (u *UserServer) showAPICallCount(w http.ResponseWriter, r *http.Request) {
user := r.URL.Path[len("/users/"):]
apiCallCount := u.store.GetUserAPICallCount(user)
if apiCallCount == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, apiCallCount)
}
func (u *UserServer) processAPICall(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
}
複製代碼
運行測試經過。
驗證當使用 POST 方法時,UserStore 是否被調用記錄 API 請求
給咱們以前實現的 StubUserStore 添加 RecordAPICall 函數,記錄並驗證函數的調用。
type StubUserStore struct {
apiCallCounts map[string]int
apiCalls []string
}
func (s *StubUserStore) GetUserAPICallCount(user string) int {
return s.apiCallCounts[user]
}
func (s *StubUserStore) RecordAPICall(user string) {
s.apiCalls = append(s.apiCalls, user)
}
複製代碼
添加測試驗證調用
func TestStoreAPICalls(t *testing.T) {
store := StubUserStore{
map[string]int{},
}
server := &UserServer{&store}
t.Run("record api call when POST", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodPost, "/users/will", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusAccepted)
if len(store.apiCalls) != 1 {
t.Errorf("got %d calls to RecordAPICall want %d", len(store.apiCalls), 1)
}
})
}
複製代碼
運行測試,你會獲得以下的錯誤
user_test.go:63:17: too few values in StubUserStore literal
複製代碼
func TestStoreAPICalls(t *testing.T) {
store := StubUserStore{
map[string]int{},
nil,
}
server := &UserServer{&store}
t.Run("record api call when POST", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodPost, "/users/will", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusAccepted)
if len(store.apiCalls) != 1 {
t.Errorf("got %d calls to RecordAPICall want %d", len(store.apiCalls), 1)
}
})
}
複製代碼
運行測試,你會獲得以下的錯誤
user_test.go:76: got 0 calls to RecordAPICall want 1
複製代碼
給 UserStore 添加相應的函數
type UserStore interface {
GetUserAPICallCount(user string) int
RecordAPICall(user string)
}
複製代碼
因爲編譯器報錯,我須要 InMemoryUserStore 實現相應的函數
func (i *InMemoryUserStore) RecordAPICall(user string) {}
複製代碼
編寫代碼調用 RecordAPICall
func (u *UserServer) processAPICall(w http.ResponseWriter, r *http.Request) {
u.store.RecordAPICall("bob")
w.WriteHeader(http.StatusAccepted)
}
複製代碼
運行測試,測試經過。
驗證 API 調用的用戶記錄
func TestStoreAPICalls(t *testing.T) {
store := StubUserStore{
map[string]int{},
nil,
}
server := &UserServer{&store}
t.Run("record api call when POST", func(t *testing.T) {
user := "will"
request, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/users/%s", user), nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusAccepted)
if len(store.apiCalls) != 1 {
t.Errorf("got %d calls to RecordAPICall want %d", len(store.apiCalls), 1)
}
if store.apiCalls[0] != user {
t.Errorf("did not record correct api call user got %q want %q", store.apiCalls[0], user)
}
})
}
複製代碼
運行測試,你會獲得以下的錯誤
user_test.go:81: did not record correct api call user got "bob" want "will"
複製代碼
func (u *UserServer) processAPICall(w http.ResponseWriter, r *http.Request) {
user := r.URL.Path[len("/users/"):]
u.store.RecordAPICall(user)
w.WriteHeader(http.StatusAccepted)
}
複製代碼
運行測試經過。
從請求中獲取 user 的代碼重複,提取到調用方,以參數形式傳遞。
func (u *UserServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := r.URL.Path[len("/users/"):]
switch r.Method {
case http.MethodGet:
u.showAPICallCount(w, user)
case http.MethodPost:
u.processAPICall(w, user)
}
}
func (u *UserServer) showAPICallCount(w http.ResponseWriter, user string) {
apiCallCount := u.store.GetUserAPICallCount(user)
if apiCallCount == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, apiCallCount)
}
func (u *UserServer) processAPICall(w http.ResponseWriter, user string) {
u.store.RecordAPICall(user)
w.WriteHeader(http.StatusAccepted)
}
複製代碼
運行測試,測試經過,重構完成。
兩個功能已經分別開發完成,咱們如今進行集成測試,因爲集成測試不容易寫,出錯後不易查找,而且因爲可能會使用真實的組件如數據庫,因此可能會運行緩慢。所以集成測試應該儘可能少寫。
func TestRecordAPICallsAndGetThem(t *testing.T) {
store := InMemoryUserStore{}
server := UserServer{&store}
user := "will"
request, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/users/%s", user), nil)
server.ServeHTTP(httptest.NewRecorder(), request)
server.ServeHTTP(httptest.NewRecorder(), request)
server.ServeHTTP(httptest.NewRecorder(), request)
response := httptest.NewRecorder()
request = newGetUserAPICallCountRequest(user)
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertCount(t, response.Body.String(), "3")
}
複製代碼
運行測試,你會獲得以下 的錯誤
server_integration_test.go:25: got "666", want "3"
複製代碼
爲 InMemoryUserStore 編寫具體實現
type InMemoryUserStore struct {
store map[string]int
}
func (i *InMemoryUserStore) GetUserAPICallCount(user string) int {
return i.store[user]
}
func (i *InMemoryUserStore) RecordAPICall(user string) {
i.store[user]++
}
func NewInMemoryUserStore() *InMemoryUserStore {
return &InMemoryUserStore{
store: make(map[string]int),
}
}
複製代碼
集成測試使用 InMemoryUserStore
func TestRecordAPICallsAndGetThem(t *testing.T) {
store := NewInMemoryUserStore()
server := UserServer{store}
user := "will"
request, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/users/%s", user), nil)
server.ServeHTTP(httptest.NewRecorder(), request)
server.ServeHTTP(httptest.NewRecorder(), request)
server.ServeHTTP(httptest.NewRecorder(), request)
response := httptest.NewRecorder()
request = newGetUserAPICallCountRequest(user)
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusOK)
assertCount(t, response.Body.String(), "3")
}
複製代碼
再次運行測試,測試經過。
修改主程序使用 NewInMemoryUserStore 函數。
func main() {
store := NewInMemoryUserStore()
server := &UserServer{store}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
複製代碼
到此一個使用內存來記錄查詢用戶 API 調用次數的程序已經完成,後續步驟你可選擇其餘數據存儲來替換內存存儲進行數據的持久化。只須要實現 UserStore 接口便可。
當你學習了 TDD 以後,你就學會了這種小步快跑的開發方法,你能夠把它應用在你沒有太大自信的關鍵核心組件的開發中,TDD 能幫助你以小步快跑的方式向目標前進,TDD 只是給了你一種小步快跑的能力,你能夠只在關鍵的時候才使用這種能力。學習 TDD 並非爲了讓你在全部涉及到編碼的地方所有使用 TDD 開發模式。
TDD 的關鍵在於驅動(driven),要讓測試驅動咱們來進行功能開發,每寫一個測試,都驅動咱們寫更多的生產代碼,都在向實現咱們的功能的方向前進。
重構是 TDD 中重要的環節,若是沒有重構,你獲得的可能只是由一堆零亂代碼組合的勉強湊合工做的軟件。只有注重重構才能讓咱們的代碼更整潔,更利於後續 TDD 開發模式的正常執行。
TDD 開發模式減輕人開發人員的心智負擔,經過紅、綠、重構循環,開發人員每個階段都只有一個特定的目標,這使得開發人員每一個階段的關注點只有一個,注意力集中。
TDD 開發模式能讓開發人員更自信,因爲咱們的任務分解的小,開發循環比較短,咱們能夠在很短期內得到測試的反饋,咱們幾乎隨時都有可運行的軟件,這給咱們開發人員帶來很強的安全感,這給了咱們自信心。
TDD 不是銀彈,不是全部項目開發均可以使用 TDD 開發模式來進行開發,在測試成本比較高的狀況下就不太適合使用 TDD 開發模式,好比在前端(Web、iOS、Android)的項目開發中,檢查頁面中的元素的位置及大小等操做比較麻煩,就不太適合使用 TDD 開發模式,可是咱們能夠儘可能減小 UI 部分的業務邏輯,UI 只根據其餘模塊處理後的數據來作簡單直接的展現,把 TDD 應用在其餘爲 UI 提供數據的模塊開發中。
TDD 並不是要求咱們很是嚴格的遵循 TDD 三定律,咱們能夠根據特殊狀況,作適當的小調整,可是總體流程與節奏不能有偏離,TDD 三定律並非爲了給你加上了沒法掙脫的枷鎖,它只是給了咱們一個總體指導原則。
要想流暢的使用 TDD 須要不斷的練習,掌握 TDD 的節奏是流暢使用 TDD 關鍵。想要真正學會使用 TDD ,只能練習、練習、再練習。