이 책은 이미 HTTP 핸들러 테스트하기에 대한 챕터를 가지고 있지만, 여기에서는 그것들을 디자인하는 것에 대해 더 광범위한 논의를 할 것이기에 테스트가 간단하다.
현실적인 예시를 살펴보고 단일 책임 원칙 및 관심사의 분리와 같은 원칙을 적용하여 설계 방식을 어떻게 개선할 수 있는지를 살펴보겠다. 이러한 원칙들은 인터페이스와 의존성 주입 (dependency injection) 을 사용하여 실현할 수 있다. 이와 더불어 우리는 핸들러 테스트가 사실 꽤 사소하다는 점을 보여줄 것이다.
Go 커뮤니티에 흔히 올라오는 질문을 그림화 한 것
Go 커뮤니티에서 HTTP 핸들러 테스트에 대한 질문은 되풀이 되는 것으로 보이는데, 이는 사람들이 이를 설계하는 방법을 잘못 이해하고 있다는 점을 의미한다고 개인적으로 생각한다.
따라서 사람들이 겪는 어려움은 대체로 실제 테스트 작성이 아닌 코드 디자인에 대한 것이다. 이 책에서 자주 강조하지만:
만약 당신이 테스트로 인해 고통을 받는다면 이를 인지하고 당신의 코드 디자인에 대해 생각해 보자.
func Teapot(res http.ResponseWriter, req *http.Request) {
res.WriteHeader(http.StatusTeapot)
}
func TestTeapotHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
res := httptest.NewRecorder()
Teapot(res, req)
if res.Code != http.StatusTeapot {
t.Errorf("got status %d but wanted %d", res.Code, http.StatusTeapot)
}
}
우리의 함수를 테스트하기 위해 호출 해보자.
테스트를 위해 http.ResponseRecorder 를 http.ResponseWriter 인수로 전달하고 함수는 이를 사용하여 HTTP 응답을 작성한다. 기록자 (recorder)는 전송된 내용을 기록 (또는 spy on) 한 다음 assertions 을 작성한다.
핸들러에서 ServiceThing 호출하기
TDD 튜토리얼에 대한 일반적인 불만은 항상 "너무 단순" 하고 "충분히 현실적" 이지 못하다는 것이다. 이에 대한 내 대답은 다음과 같다 :
당신이 언급한 예제처럼 당신의 모든 코드가 읽고 테스트하기가 간단하다면 좋지 않을까?
이는 우리가 직면한 가장 큰 과제 중 하나로서 항상 노력해야 한다. 이와 같은 코드를 디자인하는 것이 가능 (반드시 쉽다고는 할 수 없지만) 하므로 우리는 좋은 소프트웨어 엔지니어링 원칙을 연습하고 적용하여 해당 코드 디자인이 읽고 테스트하기 쉬울 수 있도록 해야 한다.
이전의 핸들러가 수행하는 작업의 요점을 되풀이하자면:
HTTP 응답을 작성하고 헤더, 상태 코드 등의 전송
요청 본문을 User로 디코딩
데이터베이스에 연결 (및 관련 모든 세부 정보)
데이터베이스 질의 및 결과에 따라 일부 비즈니스 로직 적용
암호 생성
레코드 삽입
개인적으로 원하는 좀 더 이상적인 관심사의 분리는 다음과 같다:
요청 본문을 User로 디코딩.
UserService.Register(user) 의 호출 (ServiceThing 에 해당한다).
해당 호출에서 오류가 발생한다면 (주어진 예시에서는 항상 400 BadRequest 을 전송하는데 이는 옳지 않다고 생각한다) 지금은500 Internal Server Error 에 대한 포괄적인 catch 핸들러를 가지도록 하겠다. 모든 오류에 대해 500 을 반환하는 것은 끔찍한 API 된다는 점을 분명히 하고 싶다. 이후 우리는 아마도 error types 에서 조금 더 정교한 에러 핸들러를 작성할 수 있을 것이다.
해당 호출에서 오류가 발생하지 않았다면 응답 본문으로 ID와 함께 201 Created 을 전송한다 (위의 3번과 같이 간단히 임시적으로 말이다).
간결함을 위해 이곳에서는 일반적인 TDD 절차들을 다루지 않겠다. 원한다면 다른 챕터에서 예제를 찾아보자.
새로운 디자인
type UserService interface {
Register(user User) (insertedID string, err error)
}
type UserServer struct {
service UserService
}
func NewUserServer(service UserService) *UserServer {
return &UserServer{service: service}
}
func (u *UserServer) RegisterUser(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
// request parsing and validation
var newUser User
err := json.NewDecoder(r.Body).Decode(&newUser)
if err != nil {
http.Error(w, fmt.Sprintf("could not decode user payload: %v", err), http.StatusBadRequest)
return
}
// call a service thing to take care of the hard work
insertedID, err := u.service.Register(newUser)
// depending on what we get back, respond accordingly
if err != nil {
//todo: handle different kinds of errors differently
http.Error(w, fmt.Sprintf("problem registering new user: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, insertedID)
}
우리의 RegisterUser 메서드는http.HandlerFunc의 모양과 일치하므로 이제 다음으로 넘어가 보자. 인터페이스로 캡처되는 UserService 에 대한 종속성을 가지는 새로운 유형 UserServer 에 대한 메서드를 첨부해두었다.
인터페이스는 우리의 HTTP 문제가 어느 특정한 구현체에서 분리될 수 있게 하는 환상적인 방법이다. 해당 의존성에 대해 메서드를 간단히 호출 할 수 있으며 우리는 사용자가 어떻게 등록되는지 신경 쓸 필요가 없다.
유저 등록과 관련된 특정 구현 세부 사항을 분리했으므로 이제 핸들러 코드 작성이 간단하며 앞에서 설명한 책임들을 준수한다.
테스트!
우리의 테스트는 이제 간단해졌다.
type MockUserService struct {
RegisterFunc func(user User) (string, error)
UsersRegistered []User
}
func (m *MockUserService) Register(user User) (insertedID string, err error) {
m.UsersRegistered = append(m.UsersRegistered, user)
return m.RegisterFunc(user)
}
func TestRegisterUser(t *testing.T) {
t.Run("can register valid users", func(t *testing.T) {
user := User{Name: "CJ"}
expectedInsertedID := "whatever"
service := &MockUserService{
RegisterFunc: func(user User) (string, error) {
return expectedInsertedID, nil
},
}
server := NewUserServer(service)
req := httptest.NewRequest(http.MethodGet, "/", userToJSON(user))
res := httptest.NewRecorder()
server.RegisterUser(res, req)
assertStatus(t, res.Code, http.StatusCreated)
if res.Body.String() != expectedInsertedID {
t.Errorf("expected body of %q but got %q", res.Body.String(), expectedInsertedID)
}
if len(service.UsersRegistered) != 1 {
t.Fatalf("expected 1 user added but got %d", len(service.UsersRegistered))
}
if !reflect.DeepEqual(service.UsersRegistered[0], user) {
t.Errorf("the user registered %+v was not what was expected %+v", service.UsersRegistered[0], user)
}
})
t.Run("returns 400 bad request if body is not valid user JSON", func(t *testing.T) {
server := NewUserServer(nil)
req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader("trouble will find me"))
res := httptest.NewRecorder()
server.RegisterUser(res, req)
assertStatus(t, res.Code, http.StatusBadRequest)
})
t.Run("returns a 500 internal server error if the service fails", func(t *testing.T) {
user := User{Name: "CJ"}
service := &MockUserService{
RegisterFunc: func(user User) (string, error) {
return "", errors.New("couldn't add new user")
},
}
server := NewUserServer(service)
req := httptest.NewRequest(http.MethodGet, "/", userToJSON(user))
res := httptest.NewRecorder()
server.RegisterUser(res, req)
assertStatus(t, res.Code, http.StatusInternalServerError)
})
}
이제 우리의 핸들러는 특정 스토리지 구현에 연결되지 않았다. 이는 MockUserService를 작성하여 간단하고 빠른 단위 테스트를 만들어 특정 책임들을 수행하는 데 도움이 된다.
데이터베이스 코드는 어떤가? 당신은 요령을 피우고 있다!
이는 모두 매우 의도적이다. 우리는 비즈니스 로직, 데이터베이스, 연결 등과 관련된 HTTP 핸들러를 원하지 않는다.
이렇게 함으로써 핸들러를 지저분한 사항으로부터 독립 했으며 더불어 더 관련 없는 HTTP 사항들과 결합하지 않기 때문에 지속성 레이어와 비즈니스 로직을 더 쉽게 테스트 할 수 있도록 하였다.
이제 우리가 해야 할 일은 우리가 사용하려는 데이터베이스를 사용하여 UserService 를 구현하는 것이다.
type MongoUserService struct {
}
func NewMongoUserService() *MongoUserService {
//todo: pass in DB URL as argument to this function
//todo: connect to db, create a connection pool
return &MongoUserService{}
}
func (m MongoUserService) Register(user User) (insertedID string, err error) {
// use m.mongoConnection to perform queries
panic("implement me")
}
우리는 이것을 개별적으로 테스트 할 수 있고 만약 main 이 만족스럽다면 우리가 작동하는 애플리케이션을 위해 두 유닛을 함께 snap 할 수 있다.