阅读 268

你听说过测试驱动开发吗?

TDD 是什么

根据维基百科的定义,测试驱动开发(Test-driven development 简称为 TDD)是一种软件开发过程,这种软件开发过程依赖于对一个非常短的开发循环周期的重复执行。先把需求转换成非常具体的测试用例,然后对软件进行编码让测试用例通过,最后对软件进行改进重构,消除代码的重复,保持代码整洁。没有测试验证的功能,不为会为其编写代码。

TDD 是由多个非常短的开发循环周期组成,一个 TDD 开发循环包括如下的3个步骤:

  1. 编写测试,让测试运行失败,此时代码处于红色状态。
  2. 编写生产代码,让测试通过,此时代码处于绿色状态。
  3. 重构代码,消除重复,此时代码处于重构状态。

这3个步骤就是人们常说的红、绿、重构循环,这就是一个完整的 TDD 开发循环周期。

TDD 起源于极限编程中的测试先行编程原则,最早由 Kent Beck 提出。TDD 是一种编程技巧,TDD 的主要目标是让代码整洁简单无 Bug。世界著名的软件大师 Kent Beck、Martin Fowler、Robert C. Martin 均表示支持 TDD 开发模式,他们甚至和 David Heinemeier Hansson 就 TDD 本身以及 TDD 对软件设计开发的影响有过深入的讨论:Is TDD Dead?

TDD 的优点

TDD 的优点有很多,下面列出几点我认为比较重要的优点:

  • 开发人员更了解业务,更懂得任务分解:由于测试用例需要从用户或者使用者的角度来进行描述,这就要求开发人员能更加充分的了解业务,只有更充分的了解业务,才能写好测试用例,而且由于测试应该尽量小,这也就会促使我们把开发任务分解的更小,只有把任务分解的更小,我们才能达到 TDD 理想的小步快跑的状态。
  • 代码测试覆盖率高,bug 少:由于先写测试,然后才能写生产代码,只有所有测试通过开发人员才能提交代码,这就会使得代码的测试覆盖率非常高,代码测试覆盖率高能表明我们的代码是经过充分测试的,这样生产中会碰到的 bug 就会相对少许多。
  • 更自信的重构:由于代码的测试覆盖率高,每个功能都有对应的测试代码,开发人员可以更大胆进行重构,因为有充分的测试代码,当我们重构时,如果破坏了原有的功能,测试就会马上失败,这可以让开发人员在开发阶段就能发现问题,问题越早发现,修复的成本就越低。开发人员不会因为修改代码导致其他功能的损坏却不能及时发现,引发生产 bug 而变得畏手畏脚,开发人员重构代码也会变得非常自信。
  • 代码整洁易扩展:由于 TDD 开发循环中,我们在不断重构代码,消除代码的坏味道,这会让我们得到更加整洁的代码,为了让软件更加容易测试,这会让我们更深入地思考评估我们的软件架构,从而改善优化我们的软件架构,让软件更加的灵活易扩展。
  • 不会出现生产无用的代码:由于我们先把需求转换成测试用例,并且我们只为通过测试来编写最少的代码,这样我们几乎不会编写出生产无用的代码,我们所有的代码都是为相应的需求来服务的。

TDD 开发循环

一个完整的 TDD 开发循环如下图所示:

  1. 编写测试,测试应该尽量小。运行测试,测试会失败,编译不通过也是一种失败,如果测试没有失败,这表明这个测试没有任何意义,因为这个测试既没有帮助我们实现需求,也没有帮助我们修复 bug 完善代码。这可能是如下的原因导致的:
    • 我们在上一次 TDD 循环中,生产代码编写的太多,已经把这次的测试需要测试的功能实现了。
    • 我们在之前的测试中忽略了这一次测试中应该测试的部分。
  2. 编写最少的代码让测试通过。为了尽量脱离测试无法通过的状态中,此步骤中可以使用特殊的方法,比如使用伪实现直接返回常量结果值,然后在重构阶段逐渐替换常量为真正的实现。
  3. 重构代码,减少代码中的重复代码,清除代码中的坏味道。清除生产代码与测试间的重复设计。这一步骤非常的重要,没有这一步骤的 TDD 开发是没有灵魂的 TDD 开发模式,并且可能导致你得到一个比不使用 TDD 开发模式开发出来的还要糟糕的软件。
  4. 重复上述步骤。

TDD 开发原则

TDD 三定律

  1. 在编写不能通过的单元测试前,不可编写生产代码。这是 TDD 开发最重要的原则,是 TDD 得以实行的重要指导原则,这条原则包含两层含义:
    • 测试先行,在编写生产代码之前要先编写测试代码。
    • 只有在编写的测试失败的情况下,才能进行生产代码的编写。
  2. 只可编写刚好无法通过的单元测试,不能编译也算是不通过。这条原则指导我们在编写测试时,也应该把测试尽量的拆分的小一些,不要指望一个测试就能完整的测试一整个功能。
  3. 只可编写刚好足以通过当前失败测试的生产代码。这条原则告诉我们要编写尽量少的生产代码,尽快脱离测试失败的状态,这里的尽量少的代码并不是表示让你使用语法糖来达到使用少的代码行数,处理更多的事情的目标,这里尽量少的代码的意思是,只需要编写能通过测试的代码即可,不需要处理所有情况,比如异常情况等。这可以通过后面的测试来驱动我们来写这些处理异常情况的代码。

TDD 开发策略

  1. 伪实现,直接返回常量,并在重构阶段使用变量逐渐替换常量。
  2. 明显实现,由于代码逻辑简单,可以直接写出代码实现。
  3. 三角法,通过添加测试使用其失败,逐渐驱动我们朝目标前进。

根据错误的情况,伪实现和明显实现可以交替进行,当开发进行顺畅时,可以使用明显实现,当开发过程中经常碰到错误时,可以使用伪实现,慢慢找回自信,然后再使用明显实现进行开发。当完全没有实现思路或者实现思路不清晰时, 可以使用三角法来驱动我们开发,逐渐理清思路。

TDD 的难点

  • 任务分解到底需要多细?我们需要把功能分解成多小的任务才合适呢?然后把测试分解多小才合适呢?这是一个比较难的问题,没有人能确切给出答案,一切都需要你自己去体会,去练习,去不断的尝试,去学习,去积累经验。
  • 到底要测试什么?如果我们测试写的不好,很容易造成测试代码需要跟着生产代码被频繁的修改,这样测试不仅没有给我们的代码带来好处,反而给我们的重构带来很多的额外的负担。关于要测试什么,有一句正确但却无法给你具体建议名言:“测试行为,不要测试实现”,这也是需要长时间的去学习,去练习,去体会的。简单来说你应该测试所有公开给别人使用的接口,类,函数等,而内部私有的你可以选择性的测试,具体的关于应该如何写测试,可以观看如下的关于如何测试的公开演讲视频:

TDD 开发示例

我们使用 Go 语言来开发一个简单的 http 服务来演示 TDD 开发模式。服务支持如下的两种功能:

  • GET /users/{name} 会返回用户使用 POST 方法 调用 API 的次数。
  • POST /users/{name} 会记录用户的一次 API 调用,把之前的 API 调用次数加1。

TDD 示例代码仓库地址 github.com/mgxian/tdd-…

任务分解

  • 实现 GET 请求
    • 验证响应码
    • 验证返回 API 调用次数
    • 验证不存在的用户
  • 实现 POST 请求
    • 验证响应码
    • 验证是否调用了记录函数
    • 验证调用记录是否正确
  • 集成测试
  • 完善主程序

实现 GET 请求

先写测试

测试获取 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)
}
复制代码

运行测试,测试通过。

实现 POST 请求

先写测试

测试记录 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 开发模式。

TDD 的关键在于驱动(driven),要让测试驱动我们来进行功能开发,每写一个测试,都驱动我们写更多的生产代码,都在向实现我们的功能的方向前进。

重构是 TDD 中重要的环节,如果没有重构,你得到的可能只是由一堆零乱代码组合的勉强凑合工作的软件。只有注重重构才能让我们的代码更整洁,更利于后续 TDD 开发模式的正常执行。

TDD 开发模式减轻人开发人员的心智负担,通过红、绿、重构循环,开发人员每一个阶段都只有一个特定的目标,这使得开发人员每个阶段的关注点只有一个,注意力集中。

TDD 开发模式能让开发人员更自信,由于我们的任务分解的小,开发循环比较短,我们可以在很短时间内获得测试的反馈,我们几乎随时都有可运行的软件,这给我们开发人员带来很强的安全感,这给了我们自信心。

TDD 不是银弹,不是所有项目开发都可以使用 TDD 开发模式来进行开发,在测试成本比较高的情况下就不太适合使用 TDD 开发模式,比如在前端(Web、iOS、Android)的项目开发中,检查页面中的元素的位置及大小等操作比较麻烦,就不太适合使用 TDD 开发模式,但是我们可以尽量减少 UI 部分的业务逻辑,UI 只根据其他模块处理后的数据来做简单直接的展示,把 TDD 应用在其他为 UI 提供数据的模块开发中。

TDD 并非要求我们非常严格的遵循 TDD 三定律,我们可以根据特殊情况,做适当的小调整,但是整体流程与节奏不能有偏离,TDD 三定律并不是为了给你加上了无法挣脱的枷锁,它只是给了我们一个整体指导原则。

要想流畅的使用 TDD 需要不断的练习,掌握 TDD 的节奏是流畅使用 TDD 关键。想要真正学会使用 TDD ,只能练习、练习、再练习。

后续学习

参考文档

关注下面的标签,发现更多相似文章
评论