OAuth2 단위 테스트시 @Value 주입

구범모·2024년 1월 26일
0

문제상황

@Component
@RequiredArgsConstructor
public class GoogleOAuth2 implements OAuth2 {
    @Value("${spring.security.oauth2.client.registration.google.url}")
    private String GOOGLE_SNS_LOGIN_URL;

    @Value("${spring.security.oauth2.client.registration.google.client-id}")
    private String GOOGLE_SNS_CLIENT_ID;

    @Value("${spring.security.oauth2.client.registration.google.redirect-uri}")
    private String GOOGLE_SNS_CALLBACK_URL;

    @Value("${spring.security.oauth2.client.registration.google.client-secret}")
    private String GOOGLE_SNS_CLIENT_SECRET;

    @Value("${spring.security.oauth2.client.registration.google.scope}")
    private String GOOGLE_DATA_ACCESS_SCOPE;

    @Value("${spring.security.oauth2.client.registration.google.token-url}")
    private String GOOGLE_TOKEN_REQUEST_URL;

    @Value("${spring.security.oauth2.client.registration.google.user-info-url}")
    private String GOOGLE_USER_INFO_REQUEST_URL;

    private final ObjectMapper objectMapper;

    private final RestTemplate restTemplate;

우리의 소셜로그인 관련 통신을 담당하는 GoogleOAuth2 클래스이다.

해당 클래스를 단위테스트로 테스트하려 했으나, 다음과 같은 문제가 있다.

  1. @Value를 사용하기 위해선, OAuth2의 구현체, 즉 GoogleOAuth2 클래스를 Bean으로 등록한 이후, IoC 컨테이너에서 의존관계 주입을 하여 테스트를 해야 한다. (@Value어노테이션은 별도의 설정이 없다면 의존관계 주입 시에 yml파일을 읽어 환경변수를 Bean의 필드에 할당한다.)
  2. 하지만 나는 MockitoExtension을 이용하여 단위테스트를 진행할 것이므로, 따로 환경변수의 주입을 받을 수 없다.

또한, 전제조건은 다음과 같다.

  1. 단위테스트이므로, 실제 환경변수들의 정확한 값이 필요하지 않다. 즉, GOOGLE_SNS_CALLBACK_URL의 정확한 값이 필요하지 않고, 아무런 String값이라도 상관 없이 메소드가 원하는 로직만 수행하여 결과값을 리턴하면 된다.
    • 메소드를 테스트하기 위해서는, 정확한 환경변수는 필요 없지만 null값은 허용되지 않는다. 따라서, 필드 주입이 필요하다

따라서 위의 문제점을 해결하고자, 전제조건까지 고려하여 다음과 같은 해결방안들을 생각해 보았다.

해결방안

  1. Setter를 사용하여 각 환경변수들을 주입한다.
  • 단점 : 프로덕션 코드가 변경될 뿐만 아니라, setter의 사용은 곧 변경가능성을 의미하므로 최대한 쓰지 않는다.
  1. Reflection을 이용하여 각 환경변수들을 주입한다.
  • 장점 : 프로덕션 코드의 변경이 없다.
  • 단점 : 여기에 정리해 두었듯이, 리플렉션을 사용할 시 컴파일러의 최적화를 이용하지 못해 성능이 떨어지고, 테스트 코드를 잘못 작성하더라도 컴파일 에러로 알아차릴 수 없다.
  1. 생성자를 따로 만들어서 각 환경변수를 주입한다.
  • 단점 : 프로덕션 코드가 변경된다.
  1. SpringBootTest로 바꾸어 실제 Bean주입을 받음과 동시에 환경변수 주입까지 받는다.
  • 장점 : 프로덕션 코드의 변경이 없다.
  • 단점 : Spring Context의 모든 Bean을 불러와야 하므로, 테스트의 성능이 떨어진다.

내 생각

나는 테스트 코드 작성을 위하여 프로덕션 코드를 변경하는 것은 좋지 않다고 생각한다.

또한 동시에 들었던 생각이 테스트하기 어려운 코드는 곧 잘못 작성된 코드이다. 라는 생각이 떠올랐지만, 당장 어떤 식으로 코드를 수정할지 감이 오질 않았다.

따라서 프로덕션 코드를 변경하지 않으면서 테스트 성능을 최대한 좋게 가져가기 위해 2,4번을 고민하였고

모든 Bean을 가져오는 것 보단 Reflection을 이용하는 것이 더 낫다고 판단하여 최종적으로 2번을 선택하였다.

테스트 코드

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith({MockitoExtension.class})
class GoogleOAuth2ServiceTest {

    @InjectMocks
    GoogleOAuth2 googleOAuth2;

    @Mock
    ObjectMapper objectMapper;

    @Mock
    RestTemplate restTemplate;

    String GOOGLE_SNS_LOGIN_URL = "GOOGLE_SNS_LOGIN_URL";

    String GOOGLE_SNS_CLIENT_ID = "GOOGLE_SNS_CLIENT_ID";

    String GOOGLE_SNS_CALLBACK_URL = "GOOGLE_SNS_CALLBACK_URL";

    String GOOGLE_SNS_CLIENT_SECRET = "GOOGLE_SNS_CLIENT_SECRET";

    String GOOGLE_DATA_ACCESS_SCOPE = "GOOGLE_DATA_ACCESS_SCOPE";

    String GOOGLE_TOKEN_REQUEST_URL = "GOOGLE_TOKEN_REQUEST_URL";

    String GOOGLE_USER_INFO_REQUEST_URL = "GOOGLE_USER_INFO_REQUEST_URL";

    @BeforeAll
    void setUp() {
        ReflectionTestUtils.setField(googleOAuth2, "GOOGLE_SNS_LOGIN_URL", GOOGLE_SNS_LOGIN_URL);
        ReflectionTestUtils.setField(googleOAuth2, "GOOGLE_SNS_CLIENT_ID", GOOGLE_SNS_CLIENT_ID);
        ReflectionTestUtils.setField(googleOAuth2, "GOOGLE_SNS_CALLBACK_URL", GOOGLE_SNS_CALLBACK_URL);
        ReflectionTestUtils.setField(googleOAuth2, "GOOGLE_SNS_CLIENT_SECRET", GOOGLE_SNS_CLIENT_SECRET);
        ReflectionTestUtils.setField(googleOAuth2, "GOOGLE_DATA_ACCESS_SCOPE", GOOGLE_DATA_ACCESS_SCOPE);
        ReflectionTestUtils.setField(googleOAuth2, "GOOGLE_TOKEN_REQUEST_URL", GOOGLE_TOKEN_REQUEST_URL);
        ReflectionTestUtils.setField(googleOAuth2, "GOOGLE_USER_INFO_REQUEST_URL", GOOGLE_USER_INFO_REQUEST_URL);
    }

    @DisplayName("구글OAuth2로 리다이렉트 할 수 있는 url을 반환한다.")
    @Test
    void getOAuth2RedirectUrl_test() {
        // given
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(GOOGLE_SNS_LOGIN_URL + "?")
            .append("scope=" + GOOGLE_DATA_ACCESS_SCOPE + "&")
            .append("response_type=" + "code" + "&")
            .append("redirect_uri=" + GOOGLE_SNS_CALLBACK_URL + "&")
            .append("client_id=" + GOOGLE_SNS_CLIENT_ID);

        // when
        String redirectUrl = stringBuilder.toString();

        // then
        assertThat(googleOAuth2.getOAuth2RedirectUrl()).isEqualTo(redirectUrl);
    }

}

위와 같이 테스트를 작성하여 해결했다.

마주친 에러

  1. targetObject가 명시되어야 한다는 에러.
Either targetObject or targetClass for the field must be specified
java.lang.IllegalArgumentException: Either targetObject or targetClass for the field must be specified

이유

ReflectionTestUtils.setField 메소드를 타고 들어가다 보면, 위와 같이 targetObject 혹은 targetClass가 null이 아니라는 assert문이 존재한다. @BeforeAll 어노테이션이 GoogleOAuth2가 인스턴스화 되기 이전에 실행되어서 그렇다고 생각했다.

이에 대해 https://github.com/mockito/mockito/issues/2563에서 mockito 개발자는 다음과 같이 언급한다.

This is intentional, as we don't want mocks to be shared across individual tests. Therefore, we want mocks to be fresh for each specific test.

If you require a mock to exist in the @BeforeAll, you would manually have to create one. However, we advise against doing so to avoid accidentally sharing stub state across test runs.

개별 테스트에서 mock객체가 공유되는것을 원하지 않고, 일부러 테스트마다 mock객체가 갱신되길 원하여 일부러 @BeforeAll이 작동되지 않도록 개발했다고 한다.

해결

따라서, 테스트의 성능 감소를 무릅쓰고 @BeforeAll → @BeforeEach로 바꾸어 주었다.

profile
우상향 하는 개발자

0개의 댓글