DI 장에서 Greet 함수를 가진 HTTP 서버를 짰던 기억이 날 것이다. net/http 패키지의 ResponseWriter는 Writer도 구현되어 있다. 따라서 fmt.Fprintf를 이용해 문자열을 HTTP response로 보낼 수 있다.
funcPlayerServer(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "20")}
이제 테스트를 통과할 것이다.
비계(scaffolding)를 완성하라
이제는 실제 애플리케이션으로 연결해야 한다. 이것이 중요한 이유는 (번역: 이 부분 이해가 잘 안됨)
실제 작동하는 소프트웨어 를 가지게 될 것이고, 이를 위한 테스트를 짜지는 않을 것이다. 작동하는 코드를 보는건 좋다.
리팩터링을 하는 건, 프로그램의 구조를 바꾸는 것과 같다. 변경사항는 점진적인 개발의 하나로서 애플리케이션에 반영될 것이다.
main.go 파일을 만들고 코드를 작성하자.
packagemainimport ("log""net/http")funcmain() { 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로 타입 컨버젼이 되었다.)
typeHandlerFuncfunc(ResponseWriter, *Request)
문서를 보면 HandlerFunc는 이미 ServeHTTP 메서드가 구현되어 있다. PlayerServer를 HandlerFunc로 타입 컨버젼하면 Handler를 구현한 셈이 된다.
http.ListenAndServe(":5000"...)
ListenAndServe는 Handler가 리스닝할 포트를 지정한다. 이미 리스닝중인 포트라면 error를 리턴한다. 에러는 if문을 이용해서 에러를 잡고 로깅을 할 수 있다.
또 다른 테스트를 작성해서 하드 코딩된 값보다 나은 구현을 해보자.
Write the test first
다른 player의 승점을 확인하는 테스트를 작성할 것이다. 이 테스트는 하드코딩한 코드로는 통과하지 못한다.
아직까지도 저장소를 만들지 않았다는 것에 주목하자. 어떻게든 컴파일 성공부터 하는 것이다.
컴파일이 되도록 한 다음에, 테스트를 통과하게 하는 거다. 이 순서대로 코딩하는 습관이 몸에 베어야 한다.
컴파일도 되지 않았는데 (stub 저장소 같은) 기능을 추가하는 것은 훨씬 복잡한 컴파일 문제를 만들 수 있다.
이제 main.go 는 컴파일 되지 않을것이다.
funcmain() { 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.gotypeStubPlayerStorestruct { 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.gotypeInMemoryPlayerStorestruct{}func (i *InMemoryPlayerStore) GetPlayerScore(name string) int {return123}funcmain() { 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 처리를 먼저하는게 지금까지 구현한 것과 연관도 있어서 적절하게 느껴진다. 나머지는 이후에 구현한다.
저장소에서 승점을 가져올 수 있게 되었으니 이제 새로운 승점을 저장할 수 있게 만들어보자.
Write the test first
//server_test.gofuncTestStoreWins(t *testing.T) { store :=StubPlayerStore{map[string]int{}, } server :=&PlayerServer{&store} t.Run("it returns accepted on POST", func(t *testing.T) { request, _ := http.NewRequest(http.MethodPost, "/players/Pepper", nil) response := httptest.NewRecorder() server.ServeHTTP(response, request)assertStatus(t, response.Code, http.StatusAccepted) })}
특정 라우트로 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를 구분하여 해결해보자.
./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.got.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)iflen(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.gofunc (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로 옮겼다.
테스트는 통과했지만 아직은 소프트웨어가 작동하지 않는다. PlayStore를 제대로 구현하지 않았기 때문이다. 하지만 핸들러에 집중했기에 어떤 인터페이스가 필요한지 명확히 할 수 있었다. 실행 시작부에서부터 디자인했다면 쉽지 않았을 것이다.
InMemoryPlayerStore 부터 테스트를 짤 수도 있었다. 하지만 InMemoryPlayerStore는, 데이터베이스와 같이, 제대로 승점을 저장하도록 변경할 때까지 임시로 사용하는 것이다.
이제 PlayerServer와 InMemoryPlayerStore 사이의 integration test 를 짜서, 기능을 끝낼 것이다. 이 테스트를 통해, InMemoryPlayStore를 바로 테스트 하는 것과 달리, 애플리케이션이 제대로 동작한다는 확신을 얻을 수 있다. 그 뿐 아니라, PlayStore를 데이터베이스로 구현하게 될 때에 같은 integration test로 테스트 할 수 있다.
통합 테스트
통합 테스트는 시스템의 큰 범위를 테스트하기에 유용하지만 염두에 둘 것이 있다.
작성하기 어렵다.
실패하면 원인을 알기 어렵기에 수정도 어럽다. (통합 테스트의 컴포넌트 사이의 버그인 경우가 많다)
테스트 수행이 느린 경우가 있다. (데이터베이스와 같은 "진짜" 컴포넌트를 사용하기 때문이다)
이런 이유로 테스트 피라미드 를 알아볼 것을 추천한다.
Write the test first
간결하게, 리팩터링이 끝난 최종 통합테스트를 보여주겠다.
//server_integration_test.gofuncTestRecordingWinsAndRetrievingThem(t *testing.T) { store :=InMemoryPlayerStore{} server :=PlayerServer{&store} player :="Pepper" server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player)) server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player)) server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player)) response := httptest.NewRecorder() server.ServeHTTP(response, newGetScoreRequest(player))assertStatus(t, response.Code, http.StatusOK)assertResponseBody(t, response.Body.String(), "3")}
통합하려는 두 개의 컴포넌트를 생성한다. InMemoryPlayerStore 와 PlayerServer.
세 개의 request를 보내어 player의 세 개의 승점을 저장하게 한다. 통합이 잘 되었는지 여부와는 무관하기에 Status code는 일단 무시하자.
response 변수에 response를 저장해서 player의 승점을 확인한다.
Try to run the test
--- FAIL: TestRecordingWinsAndRetrievingThem (0.00s)
server_integration_test.go:24: response body is wrong, got '123' want '3'
Write enough code to make it pass
조금의 자유를 누려보자. 테스트 없이는 부담스러울 정도의 코드를 짜본다.
이렇게 할 수도 있다! 제대로 동작하는지 확인하는 테스트들이 있긴 하지만, 우리가 작업해온 InMemoryPlayerStore 와는 상관이 없다.
이러다가 구현이 꼬여버렸다면 이전의 마지막으로 되돌리면 된다. 그러고 다시 InMemoryPlayerStore 주위의 구체적인 유닛 테스트를 좀 더 짜보면서 해법을 찾아내자.
//in_memory_player_store.gofuncNewInMemoryPlayerStore() *InMemoryPlayerStore {return&InMemoryPlayerStore{map[string]int{}}}typeInMemoryPlayerStorestruct { store map[string]int}func (i *InMemoryPlayerStore) RecordWin(name string) { i.store[name]++}func (i *InMemoryPlayerStore) GetPlayerScore(name string) int {return i.store[name]}
데이터를 저장해야 하기에 map[string]int 를 InMemoryPlayerStore 구조체에 추가했다.
편의를 위해 저장소를 초기화하는 NewInMemoryPlayerStore 를 추가하고, 통합 테스트가 이를 사용하게 수정한다.