테스트코드

외부로부터 의존하지 않는 테스트 ... OAuth 로그인 테스트도 할 수 있다! (WireMock)

donggi 2023. 3. 7. 17:21

안녕하세요. 저는 개인 프로젝트에서 OAuth를 이용해 로그인을 구현하고 있습니다.

OAuth를 구현하고 테스트를 통해 OAuth 로그인의 동작을 확인하고자 했는데, 이때 OAuth API 서버와의 테스트를 어떻게 해야 하나 고민이었습니다.

 

고민한 결과 OAuth API 서버와 통신하여 테스트하거나, 해당 API 서버를 목으로 대체하여 데이터를 확인하는 방법이 있겠다는 생각을 했고, 또한 Postman과 같은 API 도구를 이용하여 테스트해 볼 수도 있었습니다. 

 

하지만 OAuth 로그인을 테스트하기 위해 매번 Spring 서버를 띄워서 Postman에서 확인하는 건 비효율적이며 실제 OAuth API 서버와 통신하여 테스트하고자 한다면 API 서버는 제가 제어할 수 있는 대상이 아니기에 외부적인 요인으로 테스트 결과가 달라지게 되는 건 테스트를 할 때 좋은 방법이 아니라고 생각되었습니다. 그리하여 API 서버를 목으로 대체하여 테스트하기로 결정하게 되었습니다.

 

WireMock으로 API 서버를 대체

OAuth 로그인 흐름은 여기서 자세히 다루지는 않겠지만 FeignClient로 두 번의 OAuth API 서버와 통신하여 로그인 유저의 정보를 받아오게 됩니다. 이 두 번의 통신을 위한 목 서버가 필요한데 저는 WireMock을 이용해보았습니다. (FeignClient 란 Netflix에서 개발한 HTTP Client이며, HTTP 요청을 간편하게 만들어서 보낼 수 있는 객체라고 생각하시면 됩니다.)

 

WireMock은 HTTP 기반의 API를 테스트하기 위한 오픈소스 라이브러리입니다. WireMock은 Stubbing을 지원하는데, 이 'stub'을 호출하면 미리 정의한 데이터를 반환할 수 있습니다.

 

WireMock은 어떻게 사용할까

먼저 WireMock을 사용하기 위해선 의존성을 추가해주어야 합니다. 

testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner'

 

의존성을 추가하고 이번에 WireMock은 OAuth API 서버를 요청받고 응답하는 용도로 사용되기 때문에 응답 값에 사용될 json 파일을 만들어주었습니다. 저는 OAuth 로그인을 카카오로 하였기 때문에 카카오 OAuth 로그인 문서에서 OAuth 서버가 어떻게 응답해주고 있는지 확인하였습니다. (json 파일은 test/resources 아래에 만들어주었습니다) 

 

카카오 OAuth 서버에서 응답해 주는 데이터의 형식은 아래와 같습니다.

{
  "id": 123456789,
  "kakao_account": {
    "profile_needs_agreement": false,
    "profile": {
      "nickname": "donggi",
      "thumbnail_image_url": "http://yyy.kakao.com/img_110x110.jpg",
      "profile_image_url": "http://yyy.kakaoo.com/img_110x110.jpg",
      "is_default_image": false
    },
    "name_needs_agreement": false,
    "name": "홍길동",
    "email_needs_agreement": false,
    "is_email_valid": true,
    "is_email_verified": true,
    "email": "donggi@gmail.com",
    "age_range_needs_agreement": false,
    "age_range": "20~29",
    "birthday_needs_agreement": false,
    "birthday": "1130",
    "gender_needs_agreement": false,
    "gender": "female"
  }
}

 

이제 이 WireMock을 어떤 요청이 오면 어떤 응답을 할지 stubbing 해주어야 합니다. stub 객체는 OAuthMocks라는 클래스에서 만들어주고 있습니다. OAuthMocks 클래스 소스코드는 아래와 같습니다.

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static java.nio.charset.Charset.defaultCharset;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.util.StreamUtils.copyToString;

import java.io.IOException;
import java.io.InputStream;
import org.springframework.http.MediaType;

public class OAuthMocks {

    public static void setupResponse() throws IOException {
        setupMockTokenResponse();
        setupMockUserInformationResponse();
    }

    public static void setupMockTokenResponse() throws IOException {
        stubFor(post(urlEqualTo("/?client_id=clientId&redirect_uri=redirectUri&code=code"))
            .willReturn(aResponse()
                .withStatus(OK.value())
                .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                .withBody(getMockResponseBodyByPath("payload/oauth-token-response.json"))
            )
        );
    }

    public static void setupMockUserInformationResponse() throws IOException {
        stubFor(get(urlEqualTo("/v2/user/me"))
            .withHeader("Authorization", equalTo("bearer accessToken"))
            .willReturn(aResponse()
                .withStatus(OK.value())
                .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                .withBody(getMockResponseBodyByPath("payload/oauth-login-response.json"))
            )
        );
    }

    private static String getMockResponseBodyByPath(String path) throws IOException {
        return copyToString(getMockResourceStream(path), defaultCharset());
    }

    private static InputStream getMockResourceStream(String path) {
        return OAuthMocks.class.getClassLoader().getResourceAsStream(path);
    }

}

WireMock으로 요청할 stub 객체를 만들었으니 이제 테스트 코드에서 실행해 보겠습니다.

 

WireMock을 이용하여 OAuth 로그인 테스트

OAuth 로그인 테스트에 사용된 소스코드는 다음과 같습니다.

import static commaproject.be.commaserver.integration.auth.stub.OAuthMocks.setupResponse;
import static org.assertj.core.api.SoftAssertions.assertSoftly;

import commaproject.be.commaserver.integration.InitIntegrationTest;
import commaproject.be.commaserver.service.LoginService;
import commaproject.be.commaserver.service.dto.LoginInformation;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;

@ActiveProfiles("test")
@AutoConfigureWireMock(port = 0)
@TestPropertySource(properties = {
    "kakao.access-token-uri=http://localhost:${wiremock.server.port}",
    "kakao.user-information-uri=http://localhost:${wiremock.server.port}"
})
@SpringBootTest
public class OauthLoginIntegrationTest extends InitIntegrationTest {

    @Autowired
    private LoginService loginService;

    @BeforeEach
    void setUp() throws IOException {
        setupResponse();
    }

    @Test
    @DisplayName("인가 코드로 auth 서버에서 유저 정보를 가져와 UserRepository에 유저를 저장하고 로그인에 성공한다")
    void oauth_login_success() {
        LoginInformation loginInformation = loginService.login("code");

        assertSoftly(softly -> {
            softly.assertThat(loginInformation.getUsername()).isEqualTo("donggi");
            softly.assertThat(loginInformation.getUserImageUri()).isEqualTo("http://yyy.kakaoo.com/img_110x110.jpg");
            softly.assertThat(loginInformation.getEmail()).isEqualTo("donggi@gmail.com");
        });
    }
}

OAuthMocks 클래스에서 WireMock 서버에 특정 요청이 왔을 때 어떤 값을 반환할지에 대한 stub 객체를 만들어 놓습니다. OAuth 로그인을 테스트하는 OauthLoginIntegrationTest 클래스에서 선언부에 작성된 어노테이션 중 WireMock과 관련된 어노테이션은 다음과 같습니다.

 

@AutoConfigureWireMock(port = 0)

  • 해당 어노테이션에 port 값을 0으로 지정하게 되면 WireMock 서버를 랜덤한 포트로 접근하게 됩니다
  • 특정 포트로 접근하지 않고 어떤 포트로 접근하여도 동일한 결과를 반환하게 하기 위하여 랜덤한 포트로 접근하도록 하였습니다

 

@TestPropertySource

  • application context가 로드되기 전에 Spring 환경에 추가될 프로퍼티 값을 지정해 줍니다
  • FeignClient가 WireMock 서버로 접근하도록 url을 매핑해 주었습니다

 

로그인 메서드를 통해 생성된 LoginInformation 값을 검증해 봅니다.

테스트를 실행했을 때 출력되는 콘솔 화면을 통해 WireMock으로 요청을 보내고 응답받는 걸 확인할 수 있습니다. 미리 지정해 놓은 로그인 응답 값을 아래의 Matched response definition의 body에서 확인할 수 있습니다.

느낀점

이번 프로젝트에서 OAuth 로그인을 구현하고, OAuth API 서버를 WireMock으로 대신하여 테스트해 보는 경험을 해보았습니다. WireMock으로 목 서버를 stubbing 하고, 응답 값을 제공하기 위한 json 파일을 만들면서 OAuth 서버(카카오)가 응답을 어떻게 하고 있는지 공식 문서를 더 살펴보게 되었습니다.

 

WireMock은 이외에도 MSA에서 구성 요소 간 상호 작용을 시뮬레이션해볼 수도 있습니다. 아직 MSA에 대한 지식이 많지 않지만 추후에 학습하여 MSA 구조로 프로젝트를 해본다면 그때도 WireMock을 활용하여 테스트해 볼 수 있을 것 같습니다.

 

긴 글 읽어주셔서 감사합니다. 제 글에 잘못된 내용이 있거나 궁금한 점이 있으시다면 편하게 댓글 달아주세요! 피드백은 다음 글을 작성하는데 많은 도움이 됩니다 😁😁

 

참조

WireMock을 이용한 테스트 작성기 - 호키포키

https://wiremock.org/docs/

FeignClient 적용기 - 호키포키