前置说明
本文以及接下来的文章都是来自 https://quii.gitbook.io/learn-go-with-tests/ 这个系列文章的。
主要分析说明的部分是 Build An Application
部分。
这并不是原文的翻译,而是记录一些自己的东西。混合式笔记,实现书中代码,加上我的思考
正文开始
上一篇最后还留下了我的几个疑问,看看今天是否解开了。而且今天有了新的需求,新建一个 /league
的路径,返回所有玩家列表,并且返回 JSON
。从测试开始吧。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
func TestLeague(t *testing.T) {
store := StubPlayerScore{}
server := &PlayerServer{&store}
t.Run("it returns 200 on /league", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/league", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatusCode(t, response.Code, http.StatusOK)
})
}
|
运行测试,虽然通过了,但是返回的结果却跟日期不符。我们想要返回 200,却返回 404。这个 404 是从哪来的呢,通过之前的代码可以看到,我们在 GET 请求的时候执行的是获取玩家胜场,如果玩家不存在就返回 404 了。好了我们昨天的问题出现了,只要是 GET 就会获取玩家胜场,没办法区分路径是否正确。好了,继续吧,golang 有一个 ServeMux 作为内置的路由,ServeMux 允许将 http.Handlers
指向到特定的请求路径。开始编写代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
router := http.NewServeMux()
router.Handle("/league", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
router.Handle("/players/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
player := strings.TrimPrefix(r.URL.Path, "/players/")
switch r.Method {
case http.MethodGet:
p.showScore(w, r, player)
case http.MethodPost:
p.processWin(w, r, player)
}
}))
router.ServeHTTP(w, r)
}
|
在 ServeHTTP
的方法中,我们拿到了内置的 ServeMux
然后声明了,路径和对应路径的处理方法。最后让 router
来执行 ServeHTTP
接手处理每个请求。现在测试已经可以正常通过并且没有问题了。但是代码还是有些耦合的,处理 玩家 和处理 整个比赛数据的处理方法都混合在一起,这明显是可以优化的。看看优化后的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
router := http.NewServeMux()
router.Handle("/league", http.HandlerFunc(p.leagueHandler))
router.Handle("/players/", http.HandlerFunc(p.playersHandler))
router.ServeHTTP(w, r)
}
func (p *PlayerServer) leagueHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (p *PlayerServer) playersHandler(w http.ResponseWriter, r *http.Request) {
player := strings.TrimPrefix(r.URL.Path, "/players/")
switch r.Method {
case http.MethodGet:
p.showScore(w, r, player)
case http.MethodPost:
p.processWin(w, r, player)
}
}
|
把代码优化后,每个方法的长度变短了,各个方法的逻辑也都很清晰了,自己处理自己的职责。现在代码正常运行了,有问题么,虽然没有问题,但是效率会有问题,每次请求来了,都要初始化一次路由。并且设置路径和处理方法,这明显是可以优化的。如果路由只设置一次,每次请求来只要处理的请求,而不用处理初始化就好了。那么怎么处理呢,就如同获取用户名那样就好了,把他提升到上级调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
type PlayerServer struct {
Store PlayerStore
router *http.ServeMux
}
func NewPlayerServer(store PlayerStore) *PlayerServer {
p := &PlayerServer{
store,
http.NewServeMux(),
}
p.router.Handle("/league", http.HandlerFunc(p.leagueHandler))
p.router.Handle("/players/", http.HandlerFunc(p.playersHandler))
return p
}
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.router.ServeHTTP(w, r)
}
|
把 Server
增加 ServeMux
属性,然后增加了一个初始化的方法,在初始化的时候就把 router
都设置好,然后在 ServeHTTP
的时候只要让路由处理就好了。这样就解决掉了多次初始化的问题,别忘了修改后执行测试看看是否 ok 。继续优化代码,可以看到 ServeHTTP 依然调用 ServeHTTP,那么是否有优化空间呢,PlayerServer 是我们封装的 Handler
,当时封装为 Handler 的是为什么呢,那么既然都有 ServeHTTP 方法那么就是说明 ServeMux
也实现了 Handler
方法。那么就可以继续了,注意这里有一个细节,就是隐含继承。其实也不能说是继承,应该说是 struct
的嵌入。看文档 https://golang.org/doc/effective_go#embedding 这个也是本篇文章后面说的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
type PlayerServer struct {
Store PlayerStore
http.Handler
}
func NewPlayerServer(store PlayerStore) *PlayerServer {
p := new(PlayerServer)
p.Store = store
router := http.NewServeMux()
router.Handle("/league", http.HandlerFunc(p.leagueHandler))
router.Handle("/players/", http.HandlerFunc(p.playersHandler))
p.Handler = router
return p
}
|
然后我们也删除了 ServeHTTP
方法。原文的内容应该多读读,以及 Effective_go 中的说明。继续测试吧。测试 JSON 输出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
func TestLeague(t *testing.T) {
store := StubPlayerScore{}
server := NewPlayerServer(&store)
t.Run("it returns 200 on /league", func(t *testing.T) {
request, _ := http.NewRequest(http.MethodGet, "/league", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
var got []Player
err := json.NewDecoder(response.Body).Decode(&got)
if err != nil {
t.Fatalf("Unable to parse response from server %q into slice of Player, '%v'", response.Body, err)
}
assertStatusCode(t, response.Code, http.StatusOK)
})
}
|
目前是报错的,这里测试的是解析 json 是否会出错。文章有些,这里比较的不是字符串,因为我们要测试的不是输出的字符串,而是要研究数据。是否正确所以,要把 json 解析为测试的数据结构
目前还是会报错的,没有 Player
模型声明,我们加好
1
2
3
4
|
type PlayerServer struct {
Store PlayerStore
http.Handler
}
|
此时,我们的解析应该是报错的,因为我们目前的返回没有任何内容,所以解析是出错的。现在我们修改代码,让测试可以正确通过。
1
2
3
4
5
6
7
8
9
|
func (p *PlayerServer) leagueHandler(w http.ResponseWriter, r *http.Request) {
leagueTable := []Player{
{"Chris", 20},
}
json.NewEncoder(w).Encode(leagueTable)
w.WriteHeader(http.StatusOK)
}
|
构造 Player
Slice 并且用 Encoder 编码,Encoder 需要有一个输出位置,这里我们向 Response 写出内容。现在测试可以通过了。继续优化代码,现在代码是硬编码的,实际上不应该这样,因为要从一个数据源获取的,现在先把硬编码部分提取出来。
1
2
3
4
5
6
7
8
9
10
|
func (p *PlayerServer) leagueHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(p.getLeagueTable())
w.WriteHeader(http.StatusOK)
}
func (p *PlayerServer) getLeagueTable() []Player {
return []Player{
{"Chris", 20},
}
}
|
继续编写测试。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
t.Run("it returns the league table as JSON", func(t *testing.T) {
wantedLeague := []Player{
{"Cleo", 32},
{"Chris", 20},
{"Tiest", 14},
}
store := StubPlayerScore{nil, nil, wantedLeague}
server := NewPlayerServer(&store)
request, _ := http.NewRequest(http.MethodGet, "/league", nil)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
var got []Player
err := json.NewDecoder(response.Body).Decode(&got)
if err != nil {
t.Fatalf("Unable to parse response from server %q into slice of Player, '%v'", response.Body, err)
}
assertStatusCode(t, response.Code, http.StatusOK)
if !reflect.DeepEqual(got, wantedLeague) {
t.Errorf("got %v want %v", got, wantedLeague)
}
})
type StubPlayerScore struct {
scores map[string]int
winCalls []string
league []Player
}
|
给测试用 Store 增加一个 league 参数用来测试,运行测试代码,没有报错,但是没有测试通过,返回的跟预期不符,因为我们是硬编码的输出。优化代码。
1
2
3
4
5
6
7
8
9
10
|
type PlayerStore interface {
GetPlayerScore(name string) int
RecordWin(name string)
GetLeague() []Player
}
func (p *PlayerServer) leagueHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(p.Store.GetLeague())
w.WriteHeader(http.StatusOK)
}
|
给 Store 接口增加 GetLeague
方法,用于获取全部的用户以及积分。修改 leagueHandler
方法,从 Store 获取数据。不采用硬编码。现在运行代码会报错了,因为我们的所有 Store 都没有实现新增加的方法。所以补全方法,先从测试开始。
1
2
3
4
5
6
|
func (s *StubPlayerScore) GetLeague() []Player {
return s.league
}
func (i *InMemoryPlayerStore) GetLeague() []Player {
return nil
}
|
给测试用和正式 Store 都增加方法。现在测试已经可以跑通了,继续优化代码吧。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
t.Run("it returns the league table as JSON", func(t *testing.T) {
wantedLeague := []Player{
{"Cleo", 32},
{"Chris", 20},
{"Tiest", 14},
}
store := StubPlayerScore{nil, nil, wantedLeague}
server := NewPlayerServer(&store)
request := newLeagueRequest()
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
got := getLeagueFromResponse(t, response.Body)
assertStatusCode(t, response.Code, http.StatusOK)
assertLeague(t, got, wantedLeague)
})
func getLeagueFromResponse(t testing.TB, body io.Reader) (league []Player) {
t.Helper()
err := json.NewDecoder(body).Decode(&league)
if err != nil {
t.Fatalf("Unable to parse response from server %q into slice of Player, '%v'", body, err)
}
return
}
func assertLeague(t testing.TB, got, want []Player) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v want %v", got, want)
}
}
func newLeagueRequest() *http.Request {
req, _ := http.NewRequest(http.MethodGet, "/league", nil)
return req
}
|
把一些耦合的代码,独立出来。运行测试依然可以通过。接下来继续判断返回的 Content-Type
。
1
2
3
|
if response.Result().Header.Get("content-type") != "application/json" {
t.Errorf("response did not have content-type of application/json, got %v", response.Result().Header)
}
|
运行就报错了,修改代码
1
2
3
4
5
|
func (p *PlayerServer) leagueHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
json.NewEncoder(w).Encode(p.Store.GetLeague())
w.WriteHeader(http.StatusOK)
}
|
在返回的时候设置 content-type
。运行测试,通过了。注意,这个 header 的输出要在 encoder 之前。否则,会写失败的。优化代码,每次写 application/json 都是生成新代码,是可以优化的。
1
2
3
4
5
6
7
|
const jsonContentType = "application/json"
func (p *PlayerServer) leagueHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", jsonContentType)
json.NewEncoder(w).Encode(p.Store.GetLeague())
w.WriteHeader(http.StatusOK)
}
|
这样每次使用都是初始化的值,而不是每次都新申请一个字符串了。优化代码,把判断 content-type 独立出来
1
2
3
4
5
|
func assertContentType(t *testing.T, response *httptest.ResponseRecorder, want string) {
if response.Result().Header.Get("content-type") != want {
t.Errorf("response did not have content-type of %s, got %v", want, response.Result().Header)
}
}
|
至此,所有的基础测试都已经 ok 了,现在开始要进入集成测试了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
func TestRecordingWinsAndRetrievingThem(t *testing.T) {
store := NewInMemoryPlayerStore()
server := NewPlayerServer(store)
player := "Pepper"
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
t.Run("get score", func(t *testing.T) {
response := httptest.NewRecorder()
server.ServeHTTP(response, newGetScoreRequest(player))
assertStatusCode(t, response.Code, http.StatusOK)
assertResponseBody(t, response.Body.String(), "3")
})
t.Run("get league", func(t *testing.T) {
response := httptest.NewRecorder()
server.ServeHTTP(response, newLeagueRequest())
assertStatusCode(t, response.Code, http.StatusOK)
got := getLeagueFromResponse(t, response.Body)
want := []Player{
{"Pepper", 3},
}
assertLeague(t, got, want)
})
}
|
把之前的代码放到新的 Run,通用的部分放入外部,然后在增加新的 get league 测试。运行报错,出错,因为在前面我们把那个方法返回 nil。现在修改代码。另外这里还是可以回想一下 nil 为什么可以解析,以及昨天的 nil append 问题。
1
2
3
4
5
6
7
8
9
10
11
|
func (i *InMemoryPlayerStore) GetLeague() []Player {
var players []Player
for name, winners := range i.store {
players = append(players, Player{
Name: name,
Wins: winners,
})
}
return players
}
|
把 store 的内容转换为 []Player 。运行测试通过了。
总结
今天,速度比昨天快多了,酣畅淋漓,一步步的进化得到最终的结果。今天我没有留下什么问题。明天继续看看又有什么新的东西。