배경
gqlgen 으로 구성된 API 서버의 엔드포인트(= query / mutation) 을 테스트해보자
해결
클라이언트 설정
gqlgen에서 테스트용 클라이언트 패키지를 제공한다.
테스트용 클라이언트를 만들고 POST 요청을 보낼 수 있도록 구성되어있다.
func TestResolver(t *testing.T) {
// 테스트 클라이언트가 접근할 서버(httpHandler)를 정의한다.
srv := handler.NewDefaultServer(apiServer.NewExecutableSchema(apiServer.Config{Resolvers: &Resolver{}}))
}
// 테스트 클라이언트를 정의한다.
c := client.New(srv)
t.Run("Create User", func(t *testing.T) {
... // 여기에 테스트 코드 작성
})
- 이런식으로 서버를 등록하고, 클라이언트가 그 서버에 요청을 보낼 수 있도록 등록해주면 된다.
테스트 코드
func TestResovler(t *testing.T) {
srv := handler.NewDefaultServer(apiServer.NewExecutableSchema(apiServer.Config{Resolvers: &Resolver{}}))
c := client.New(srv)
t.Run("login", func(t *testing.T) {
testUserId := "test"
testUserPw := "pw"
testUserJwt := GenerateJwt(testUserId, myDecrpytFunc(testUserPw)
var resp struct {
Login string
}
queryStr := fmt.Sprintf(`
mutation Login {
login(
input: {
id: "%s",
pw: "%s",
}
)
}`, testUserId, testUserPw)
c.MustPost(queryStr, &resp)
actualToken := resp.Login
require.Equal(t, testUserJwt, actualToken)
})
}
input으로 id와 pw를 받고 바로 string으로 access token을 응답하는 로그인 API이다.
c 로 초기화한 client의
MustPost
메서드에 graphql의 쿼리를 넣고, 응답을 받을 구조체를 넣어준다.주의사항!! 이때 응답을 받을 resp 구조체는 대문자로 시작해야된다..
위와 같은 응답값이 있다면, resp 구조체는 아래의 컨벤션으로 작성되어야 한다.
var resp struct {
login string
}
var resp struct {
data struct {
login string
}
}
# 이 아니라,
var resp struct {
Login string
}
# 이어야 한다.
var resp map[string]interface{}
# 이렇게 빈 인터페이스도 언패킹이 가능하긴 하다.
- 모르겠으면 이 이슈를 참고해보자
다시 코드로 돌아가서,
c.MustPost(query, &resp)
코드를 통해 테스트 클라이언트는 등록된 테스트 서버에 요청을 보내게 된다.MustPost
메서드는 테스트 진행시 편의를 위해 err 발생시 panic을 띄워주는 메서드로, 이게 불편하다면Post
메서드를 사용해도 된다.// github.com/99designs/gqlgen/client/client.go의 일부 코드 // MustPost is a convenience wrapper around Post that automatically panics on error func (p *Client) MustPost(query string, response interface{}, options ...Option) { if err := p.Post(query, response, options...); err != nil { panic(err) } } // Post sends a http POST request to the graphql endpoint with the given query then unpacks // the response into the given object. func (p *Client) Post(query string, response interface{}, options ...Option) error { respDataRaw, err := p.RawPost(query, options...) if err != nil { return err } // we want to unpack even if there is an error, so we can see partial responses unpackErr := unpack(respDataRaw.Data, response) if respDataRaw.Errors != nil { return RawJsonError{respDataRaw.Errors} } return unpackErr }
테스트는 잘 작동한다.
=== RUN TestResolvers === RUN TestResolvers/Login --- PASS: TestResolvers (0.07s) --- PASS: TestResolvers/Login (0.02s) PASS
테스트 클라이언트에 context 주입하기
로그인이 필요한 API 엔드포인트를 테스트하는 상황을 가정해보자.
일반적으로는 http middleware를 서버에 등록해두고, 인증이 필요한 API에 요청이 올때마다 미들웨어가 access_token을 확인하여 유저 정보를 불러오고 이를 context에 넣어 다음 요청에 넘겨주는 방식으로 처리 할 것이다. 예를 들면 이렇게 ! gqlgen authentication 예제 링크
하지만 gqlgen 테스트 클라이언트는 실제로 HTTP 요청을 보내는 것이 아니기 떄문에 테스트 서버에 미들웨어를 등록해도 사용할 수 없다고 한다!! 참고
그래서 Test Client의 요청에 직접 인증에 필요한 유저 context를 넣어주기로 했다.
- 먼저 테스트 클라이언트는 Option Pattern 을 활용해 여러 Option 값을 가변인자로 받을 수 있게 설계되어있다. 아래의 코드에서 볼 수 있듯,
New
메서드로 테스트 클라이언트를 생성할때 option 값으로 Request 구조체에 해당하는 값들을 넣어줄 수 있다. 여기서 HTTP 필드에 컨텍스트를 넘겨줄 수 있는데, 그 이유는 HTTP 필드의 타입이 http.Request의 포인터이기 때문이다.
// github.com/99designs/gqlgen/client/client.go의 일부 코드
type (
// Client used for testing GraphQL servers. Not for production use.
Client struct {
h http.Handler
opts []Option
}
// Option implements a visitor that mutates an outgoing GraphQL request
//
// This is the Option pattern - https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
Option func(bd *Request)
// Request represents an outgoing GraphQL request
Request struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
OperationName string `json:"operationName,omitempty"`
HTTP *http.Request `json:"-"`
}
// Response is a GraphQL layer response from a handler.
Response struct {
Data interface{}
Errors json.RawMessage
Extensions map[string]interface{}
}
)
// New creates a graphql client
// Options can be set that should be applied to all requests made with this client
func New(h http.Handler, opts ...Option) *Client {
p := &Client{
h: h,
opts: opts,
}
return p
}
- 그렇다면 먼저 Option 자리에서 클라이언트에 context를 주입해주는 코드를 작성하자.
func addContext(user *domain.UserDAO) client.Option {
return func(bd *client.Request) {
ctx := bd.HTTP.Context()
ctx = context.WithValue(ctx, "userAuthCtx", user)
bd.HTTP = bd.HTTP.WithContext(ctx)
}
}
유저 모델을 인자로 받아. HTTP의 컨텍스트에 우리 서버가 정의한 인증절차에 맞게 유저 컨텍스트를 주입한다. 그리고 bd.HTTP에 이 컨텍스트가 들어있는 bd.HTTP를 할당해준다. 3. 이제 이 addContext를 테스트 클라이언트에 넣어주면 된다.
// Test UpdateUserInfo
t.Run("UpdateUserInfo", func(t *testing.T) {
var resp struct {
UpdateUserInfo struct {
Id string
Mobile string
Name string
Email string
}
}
//var resp map[string]interface{}
name := "테스트"
email := "test@gqltest.com"
testUser, _ := repo.User.GetByEmail(email)
queryStr := fmt.Sprintf(`
mutation UpdateUserInfo {
updateUserInfo(
input: {
uuid: "%s"
mobile: "%s"
name: "%s"
email: "%s"
}
) {
id, uuid, mobile, name, email
}
}
`, testUserUUID, testUserMobile, name, email)
// MustPost 요청의 Option 인자로 위에서 정의한 addContext 함수를 넣어주면 된다!!
c.MustPost(queryStr, &resp, addContext(testUser))
require.Equal(t, resp.UpdateUserInfo.Name, name)
require.Equal(t, resp.UpdateUserInfo.Email, email)
})
}
- 이렇게 하면
MustPost
를 호출하는 테스트 클라이언트가 테스트서버에 testUser 즉, 임의의 유저 모델이 포함된 컨텍스트를 함께 넘겨주게 되어 정상적으로 인증절차를 수행할 수 있게 된다!
궁금한건 댓글로 남겨달라.