前置说明

本文以及接下来的文章都是来自 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 。运行测试通过了。

总结

今天,速度比昨天快多了,酣畅淋漓,一步步的进化得到最终的结果。今天我没有留下什么问题。明天继续看看又有什么新的东西。