Golang gqlgen 이용하여 API 엔드포인트 테스트하기

Golang gqlgen 이용하여 API 엔드포인트 테스트하기

배경

gqlgen 으로 구성된 API 서버의 엔드포인트(= query / mutation) 을 테스트해보자

해결

클라이언트 설정

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를 넣어주기로 했다.


  1. 먼저 테스트 클라이언트는 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
}
  1. 그렇다면 먼저 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)
    })
}
  1. 이렇게 하면 MustPost 를 호출하는 테스트 클라이언트가 테스트서버에 testUser 즉, 임의의 유저 모델이 포함된 컨텍스트를 함께 넘겨주게 되어 정상적으로 인증절차를 수행할 수 있게 된다!

궁금한건 댓글로 남겨달라.