DI 장에서 Greet 함수를 가진 HTTP 서버를 짰던 기억이 날 것이다. net/http 패키지의 ResponseWriter는 Writer도 구현되어 있다. 따라서 fmt.Fprintf를 이용해 문자열을 HTTP response로 보낼 수 있다.
func PlayerServer(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "20")
}
이제 테스트를 통과할 것이다.
비계(scaffolding)를 완성하라
이제는 실제 애플리케이션으로 연결해야 한다. 이것이 중요한 이유는 (번역: 이 부분 이해가 잘 안됨)
실제 작동하는 소프트웨어 를 가지게 될 것이고, 이를 위한 테스트를 짜지는 않을 것이다. 작동하는 코드를 보는건 좋다.
리팩터링을 하는 건, 프로그램의 구조를 바꾸는 것과 같다. 변경사항는 점진적인 개발의 하나로서 애플리케이션에 반영될 것이다.
main.go 파일을 만들고 코드를 작성하자.
package main
import (
"log"
"net/http"
)
func main() {
handler := http.HandlerFunc(PlayerServer)
if err := http.ListenAndServe(":5000", handler); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
현 시점에서 애플리케이션은 하나의 파일로 구현되어 있다. 하지만 더 큰 프로젝트에서는 여러 파일로 나누고 싶어질 것이다.
애플리케이션을 실행하려면, go build 명령으로 디렉토리 안의 모든 .go 파일들로 프로그램을 빌드한다음 ./myprogram 을 실행하면 된다.
http.HandlerFunc
앞서서 서버를 만들려면 Handler 인터페이스가 필요하다고 했었다. 일반적으로struct를 만들고 ServeHTTP 메서드를 구현하여 인터페이스를 구현한다. 하지만 struct의 용도는 데이터를 담는 것인데 현재는 아무 state가 없기에 struct를 만들기 머뭇거려진다.
HandlerFunc 타입은 평범한 함수들을 HTTP 핸들러로 쓸 수 있게 해주는 어댑터이다. 만약에 f가 적합한 시그니처를 가진 함수라면, HandlerFunc(f)는 f를 호출하는 핸들러이다. (역주: 여기서 f가 타입 HandlerFunc로 타입 컨버젼이 되었다.)
type HandlerFunc func(ResponseWriter, *Request)
문서를 보면 HandlerFunc는 이미 ServeHTTP 메서드가 구현되어 있다. PlayerServer를 HandlerFunc로 타입 컨버젼하면 Handler를 구현한 셈이 된다.
http.ListenAndServe(":5000"...)
ListenAndServe는 Handler가 리스닝할 포트를 지정한다. 이미 리스닝중인 포트라면 error를 리턴한다. 에러는 if문을 이용해서 에러를 잡고 로깅을 할 수 있다.
또 다른 테스트를 작성해서 하드 코딩된 값보다 나은 구현을 해보자.
Write the test first
다른 player의 승점을 확인하는 테스트를 작성할 것이다. 이 테스트는 하드코딩한 코드로는 통과하지 못한다.
어떤 player의 승점이 얼마인지를 제어할 수 있는 저장소 개념이 필요하다. 테스트를 할 때에 임의의 값이 들어가는 건 어색하다
논리적으로 가능한 최소의 단계를 밟으려 하고 있다는 걸 잊지 말자. 일단은 상수값을 리턴하는 것을 개선하는 것에만 집중하자.
Try to run the test
=== RUN TestGETPlayers/returns_Pepper's_score
--- PASS: TestGETPlayers/returns_Pepper's_score (0.00s)
=== RUN TestGETPlayers/returns_Floyd's_score
--- FAIL: TestGETPlayers/returns_Floyd's_score (0.00s)
server_test.go:34: got '20', want '10'
Write enough code to make it pass
//server.go
func PlayerServer(w http.ResponseWriter, r *http.Request) {
player := strings.TrimPrefix(r.URL.Path, "/players/")
if player == "Pepper" {
fmt.Fprint(w, "20")
return
}
if player == "Floyd" {
fmt.Fprint(w, "10")
return
}
}
request의 URL을 보고 어떤 값을 리턴할지를 결정하게 하였다. player 승점의 저장 및 연동을 고려하다 보면 다음 단계는 라우팅 이 될 것같다.
변경된 만큼 승점을 저장하게 하려 했다면 이보다 훨씬 많은 수정을 해야 했을 것이다. **하지만 이런 구현이 우리의 최종 목표를 향한 훨씬 작은, 테스트를 기반으로한 단계이다."
지금은 라우팅 라이브러리를 이용하고 싶은 유혹을 참아내고, 테스트를 통과하는 최소의 단계만을 생각하자.
r.URL.Path 는 request의 경로를 리턴하며, 우리는 strings.TrimPrefix 를 이용해 /players/ 를 잘라내어 요청한 player만을 얻을 수 있다. 단단한 코드라 볼 수는 없지만 당장은 동작한다.
Refactor
승점을 가져오는 부분을 별도 함수로 추출하여 PlayerServer를 단순화 시켜보자.
//server.go
func PlayerServer(w http.ResponseWriter, r *http.Request) {
player := strings.TrimPrefix(r.URL.Path, "/players/")
fmt.Fprint(w, GetPlayerScore(player))
}
func GetPlayerScore(name string) string {
if name == "Pepper" {
return "20"
}
if name == "Floyd" {
return "10"
}
return ""
}
테스트에서 helper를 이용해 반복을 줄일 수 있다. (DRY up - Don't repeat yourself)
아직까지도 저장소를 만들지 않았다는 것에 주목하자. 어떻게든 컴파일 성공부터 하는 것이다.
컴파일이 되도록 한 다음에, 테스트를 통과하게 하는 거다. 이 순서대로 코딩하는 습관이 몸에 베어야 한다.
컴파일도 되지 않았는데 (stub 저장소 같은) 기능을 추가하는 것은 훨씬 복잡한 컴파일 문제를 만들 수 있다.
이제 main.go 는 컴파일 되지 않을것이다.
func main() {
server := &PlayerServer{}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
마침내, 컴파일에 성공하지만, 이번에는 테스트를 실패한다.
=== RUN TestGETPlayers/returns_the_Pepper's_score
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
panic: runtime error: invalid memory address or nil pointer dereference
아직 테스트에 PlayerStore를 전달하지 않았기 때문이다. stub를 만들 차례다.
//server_test.go
type StubPlayerStore struct {
scores map[string]int
}
func (s *StubPlayerStore) GetPlayerScore(name string) int {
score := s.scores[name]
return score
}
테스트를 위해 map으로 빠르고 쉬운 stub key/value 저장소를 만들 수 있다. 저장소를 만들고 PlayerServer로 전달하자.
테스트를 통과했고 코드도 보기 좋아졌다. 저장소 덕분에 코드의 의도 가 선명해졌다. 이런 데이터가 PlayerStore 에 있으니 PlayerServer 를 이용해 원하는 response를 받을 수 있다고 말해주는 것이다.
애플리케이션 실행
모든 테스트를 통과했으니 리팩터링을 완료하기 위해 애플리케이션의 동작을 확인해보자. 프로그램은 시작하겠지만 http://localhost:5000/players/Pepper 로 request를 하면 끔찍한 response를 받을 것이다.
PlayerStore를 전달하지 않았기 때문이다.
아직 의미있는 데이터를 저장하지 않고 있기에 PlayerStore 구현은 조금 곤란하다. 우선은 하드코딩을 해두자.
//main.go
type InMemoryPlayerStore struct{}
func (i *InMemoryPlayerStore) GetPlayerScore(name string) int {
return 123
}
func main() {
server := &PlayerServer{&InMemoryPlayerStore{}}
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
go build 를 실행하고 http://localhost:5000/players/Pepper URL로 request 하면 "123"이 회신된다. 멋지진 않지만 현재로선 이게 최선이다.
다음에 할 만한 것들은 다음과 같다.
player가 존재하지 않을 경우의 처리
POST /players/{name} 에 대한 처리
메인 애플리케이션이 시작했지만 실제 동작하지 않아서 불편함. 문제점을 확인하려면 매번 테스트를 실행하여야 한다.
POST 처리를 하고 싶지만, 존재하지 않는 player 처리를 먼저하는게 지금까지 구현한 것과 연관도 있어서 적절하게 느껴진다. 나머지는 이후에 구현한다.
특정 라우트로 POST를 보낼 경우에, 올바른 status code를 받는지부터 확인하자. GET /players/{name} 와는 다른 종류의 request를 받아서 처리하는 기능을 구현해야 한다. 이게 동작하면 핸들러에서 승점과 연동하는 분을 확인할 것이다.
Try to run the test
=== RUN TestStoreWins/it_returns_accepted_on_POST
--- FAIL: TestStoreWins/it_returns_accepted_on_POST (0.00s)
server_test.go:70: did not get correct status, got 404, want 202
Write enough code to make it pass
테스트부터 만드는 것은 신중하게 문제를 만드는 것이다. if 문으로 request의 method를 구분하여 해결해보자.
//server.go
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusAccepted)
return
}
player := strings.TrimPrefix(r.URL.Path, "/players/")
score := p.store.GetPlayerScore(player)
if score == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, score)
}
Refactor
핸들러가 지저분하게 구현되어 있다. 코드를 나누어 알아보기 편하게 함수들로 만들자.
//server.go
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
p.processWin(w)
case http.MethodGet:
p.showScore(w, r)
}
}
func (p *PlayerServer) showScore(w http.ResponseWriter, r *http.Request) {
player := strings.TrimPrefix(r.URL.Path, "/players/")
score := p.store.GetPlayerScore(player)
if score == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, score)
}
func (p *PlayerServer) processWin(w http.ResponseWriter) {
w.WriteHeader(http.StatusAccepted)
}
ServeHTTP의 라우팅이 좀더 잘 이해된다. 다음 반복에는 processWin 함수 내부의 저장부분을 구현한다.
그 다음엔 서버가 POST /players/{name}를 받으면 PlayerStore가 승점을 저장하라는 요청을 듣는지 체크할 것이다.
Write the test first
RecordWin 메서드를 StubPlayerStore에 추가한 다음 호출해보자.
//server_test.go
type StubPlayerStore struct {
scores map[string]int
winCalls []string
}
func (s *StubPlayerStore) GetPlayerScore(name string) int {
score := s.scores[name]
return score
}
func (s *StubPlayerStore) RecordWin(name string) {
s.winCalls = append(s.winCalls, name)
}
이번에는 호출 횟수를 확인하는 테스트를 가장 처음에 추가해보자. Now extend our test to check the number of invocations for a start
만약 RecordWin을 호출할 수 있게 되면, 인터페이스를 변경해서 PlayerStore의 개념에 대해 PlayerServer를 수정할 필요가 있다.
//server.go
type PlayerStore interface {
GetPlayerScore(name string) int
RecordWin(name string)
}
이렇게 하면 main은 컴파일되지 않는다.
./main.go:17:46: cannot use InMemoryPlayerStore literal (type *InMemoryPlayerStore) as type PlayerStore in field value:
*InMemoryPlayerStore does not implement PlayerStore (missing RecordWin method)
테스트를 실행하면 통과할 것이다. 하지만 RecordWin에 넣으려는 이름이 "Bob"은 아니었으니 테스트를 좀더 다듬어보자.
Write the test first
//server_test.go
t.Run("it records wins on POST", func(t *testing.T) {
player := "Pepper"
request := newPostWinRequest(player)
response := httptest.NewRecorder()
server.ServeHTTP(response, request)
assertStatus(t, response.Code, http.StatusAccepted)
if len(store.winCalls) != 1 {
t.Fatalf("got %d calls to RecordWin want %d", len(store.winCalls), 1)
}
if store.winCalls[0] != player {
t.Errorf("did not store correct winner got %q want %q", store.winCalls[0], player)
}
})
winCalls 슬라이스에는 하나의 원소가 있어야 하고, 그 원소가 player와 같아야 테스트를 통과한다.
Try to run the test
=== RUN TestStoreWins/it_records_wins_on_POST
--- FAIL: TestStoreWins/it_records_wins_on_POST (0.00s)
server_test.go:86: did not store correct winner got 'Bob' want 'Pepper'
Write enough code to make it pass
//server.go
func (p *PlayerServer) processWin(w http.ResponseWriter, r *http.Request) {
player := strings.TrimPrefix(r.URL.Path, "/players/")
p.store.RecordWin(player)
w.WriteHeader(http.StatusAccepted)
}
processWin 메서드의 코드를 수정해서 http.Request를 받아 URL에서 player 이름을 추출하게 하였다. 이제 store 의 RecordWin 메서드를 player 이름으로 호출하고 테스트를 통과할 것이다.
Refactor
DRY(Don't Repeat Yourself). 반복되는 코드를 줄여보자. player 이름을 추출하는 코드를 ServeHTTP로 옮겼다.
//server.go
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
player := strings.TrimPrefix(r.URL.Path, "/players/")
switch r.Method {
case http.MethodPost:
p.processWin(w, player)
case http.MethodGet:
p.showScore(w, player)
}
}
func (p *PlayerServer) showScore(w http.ResponseWriter, player string) {
score := p.store.GetPlayerScore(player)
if score == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, score)
}
func (p *PlayerServer) processWin(w http.ResponseWriter, player string) {
p.store.RecordWin(player)
w.WriteHeader(http.StatusAccepted)
}
테스트는 통과했지만 아직은 소프트웨어가 작동하지 않는다. PlayStore를 제대로 구현하지 않았기 때문이다. 하지만 핸들러에 집중했기에 어떤 인터페이스가 필요한지 명확히 할 수 있었다. 실행 시작부에서부터 디자인했다면 쉽지 않았을 것이다.
InMemoryPlayerStore 부터 테스트를 짤 수도 있었다. 하지만 InMemoryPlayerStore는, 데이터베이스와 같이, 제대로 승점을 저장하도록 변경할 때까지 임시로 사용하는 것이다.
이제 PlayerServer와 InMemoryPlayerStore 사이의 integration test 를 짜서, 기능을 끝낼 것이다. 이 테스트를 통해, InMemoryPlayStore를 바로 테스트 하는 것과 달리, 애플리케이션이 제대로 동작한다는 확신을 얻을 수 있다. 그 뿐 아니라, PlayStore를 데이터베이스로 구현하게 될 때에 같은 integration test로 테스트 할 수 있다.
통합 테스트
통합 테스트는 시스템의 큰 범위를 테스트하기에 유용하지만 염두에 둘 것이 있다.
작성하기 어렵다.
실패하면 원인을 알기 어렵기에 수정도 어럽다. (통합 테스트의 컴포넌트 사이의 버그인 경우가 많다)
테스트 수행이 느린 경우가 있다. (데이터베이스와 같은 "진짜" 컴포넌트를 사용하기 때문이다)