스프링 공부/TDD

스프링 부트 테스트 코드에서 @Value가 작동하지 않는 문제 해결법

모항 2024. 6. 13. 02:38

문제상황

실제 서버를 작동시켰을 때에는 잘 작동하던 @Value가, 테스트 코드를 돌릴 때에는 작동하지 않는 문제가 발생했다!

문제가 발생한 코드는 다음과 같다.

 

Account.java

(테스트의 대상이 되는 코드)

package 내 프로젝트의 패키지 경로;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account {

    @Transient
    @Value("${numbers.account.nickname-max}")
    private static Long NICKNAME_MAX_LENGTH;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "account_id", nullable = false, updatable = false)
    private Long accountId;

    @Column(nullable = false)
    private String kakaoId;

    @Column(nullable = false)
    private String nickname;

    @Column(nullable = false)
    private String profileImageUrl;

    @Builder
    public Account(String kakaoId, String nickname, String profileImageUrl) {
        validateKakaoId(kakaoId);
        validateNickname(nickname);
        this.kakaoId = kakaoId;
        this.nickname = nickname;
        this.profileImageUrl = profileImageUrl;
    }

    public void updateNickname(String newNickname) {
        validateNickname(newNickname);
        this.nickname = newNickname;
    }

    public void updateProfileImageUrl(String newUrl) {
        this.profileImageUrl = newUrl;
    }

    private void validateKakaoId(String kakaoId) {
        try{
            Integer.parseInt(kakaoId);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("[ERROR] 카카오회원 ID가 정수가 아닙니다!");
        }
    }

    private void validateNickname(String nickname) {
        if(nickname.length()> NICKNAME_MAX_LENGTH)
            throw new IllegalArgumentException("[ERROR] 닉네임이 너무 깁니다!");
    }
}

 

AccountTest.java

(Account.java를 테스트하는 테스트 코드)

package 내 프로젝트의 패키지 경로;

import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class AccountTest {

    @Value("${numbers.account.nickname-max}")
    private Long NICKNAME_MAX_LENGTH;

    @Test
    void 새회원생성_성공테스트() {
        // given
        String KAKAO_ID = "12345678";
        String NICKNAME = "nickname";
        String PROFILE_IMAGE_URL = "example.com";
        // when
        Account account = Account.builder()
                .kakaoId(KAKAO_ID)
                .nickname(NICKNAME)
                .profileImageUrl(PROFILE_IMAGE_URL)
                .build();
        // then
        SoftAssertions softly = new SoftAssertions();
        softly.assertThat(account.getKakaoId()).isEqualTo(KAKAO_ID);
        softly.assertThat(account.getNickname()).isEqualTo(NICKNAME);
        softly.assertThat(account.getProfileImageUrl()).isEqualTo(PROFILE_IMAGE_URL);
        softly.assertAll();
    }

    @Test
    void 새회원생성_실패테스트_카카오ID가_정수가_아님() {
        // given
        String KAKAO_ID = "abcd";
        String NICKNAME = "nickname";
        String PROFILE_IMAGE_URL = "example.com";
        // when, then
        assertThrows(IllegalArgumentException.class,
                () -> Account.builder().kakaoId(KAKAO_ID).nickname(NICKNAME).profileImageUrl(PROFILE_IMAGE_URL).build());
    }

    @Test
    void 새회원생성_실패테스트_닉네임_글자수_초과() {
        // given
        String KAKAO_ID = "12345678";
        String concatedString = "";
        for (int i=0; i< NICKNAME_MAX_LENGTH+1; i++) {
            concatedString = concatedString.concat("a");
        }
        String TOO_LONG_NICKNAME = concatedString;
        String PROFILE_IMAGE_URL = "example.com";
        // when, then
        assertThrows(IllegalArgumentException.class,
                () -> Account.builder().kakaoId(KAKAO_ID).nickname(TOO_LONG_NICKNAME).profileImageUrl(PROFILE_IMAGE_URL).build());
    }

    @Test
    void 닉네임수정_성공테스트() {
        // given
        String OLD_NICKNAME = "old";
        String NEW_NICKNAME = "new";
        Account account = Account.builder()
                .kakaoId("12345678")
                .nickname(OLD_NICKNAME)
                .profileImageUrl("example.com")
                .build();
        // when
        account.updateNickname(NEW_NICKNAME);
        // then
        assertThat(account.getNickname()).isEqualTo(NEW_NICKNAME);
    }

    @Test
    void 회원정보수정_실패테스트_닉네임_글자수_초과() {
        // given
        String OLD_NICKNAME = "old";
        String concatedString = "";
        for (int i=0; i< NICKNAME_MAX_LENGTH+1; i++) {
            concatedString = concatedString.concat("a");
        }
        String NEW_NICKNAME = concatedString;
        Account account = Account.builder()
                .kakaoId("12345678")
                .nickname(OLD_NICKNAME)
                .profileImageUrl("example.com")
                .build();
        // when, then
        assertThrows(IllegalArgumentException.class, () -> account.updateNickname(NEW_NICKNAME));
    }
}

 

닉네임의 최대길이 제한이 몇 글자인지가 추후에 바뀔 수 있기 때문에, application.yml에 다음과 같이 적어두고 편하게 관리하려고 @Value 어노테이션을 사용하였다.

 

numbers:
  account:
    nickname-max: 20

 

Account.java의 @Value 어노테이션은, 실제 서버를 돌렸을 때에는 정상적으로 작동한다.

 

그러나 AccountTest.java를 돌려보면, Account.java 내의 @Value 어노테이션과 AccountTest.java 내의 @Value 어노테이션 둘 모두가 작동하지 않아, 두 클래스 내의 NICKNAME_MAX_LENGTH 값이 모두 null이 되어버린다.

 

그래서 테스트 실행 시에 NullPointerException이 발생한다.

 

 

 

 


문제해결 조건

나는 다음의 두 가지 조건을 지키며 이 문제를 해결하고 싶었다.

 

  1. NICKNAME_MAX_LENGTH의 값의 결정과 관련된 코드는 오로지 Account.java와 application.yml에만 있어야 한다.
  2. 프로덕션 코드에서는 사용되지 않으면서 오로지 테스트만을 위해 작성되는 코드는 없어야 한다.

 

그 이유는 다음과 같다.

 

닉네임의 최대 길이 값은 회원정보 도메인에만 관련된 매직 넘버이다. 따라서 나는 프로젝트의 모든 클래스 중 오직 회원정보 도메인, 즉 오직 Account 내부에서만 NICKNAME_MAX_LENGTH 값이 관리되도록 하고 싶다.

 

다른 사람, 혹은 먼 미래의 나 자신이 이 코드를 유지보수할 때,

닉네임의 최대 길이 값을 바꾸려면 어느 코드를 고쳐야 할까? -> 닉네임은 회원정보인 Account에 포함된 필드니까 Account.java를 까보면 되겠구나!

 

이렇게 합리적이고 편한 절차를 통해 닉네임의 최대 길이 관련 변수를 찾아낼 수 있도록 하기 위함이다.

 

 

사실 위의 조건을 지키지 않으면 다음과 같은 간단한 방법으로 문제를 해결할 수 있다.

예를 들어, AccountTest 측에서 Account 생성 시에 NICKNAME_MAX_LENGTH 값을 주입하는 방식이나,

Account.java 내에, 외부에서 NICKNAME_MAX_LENGTH 값을 변경할 수 있는 setter를 추가하는 방식 등이다.

 

그러나 이 두 방식 모두, 내가 정한 위의 두 가지 조건에 맞지 않다.

 

나는 Account 외부의 다른 클래스에서 NICKNAME_MAX_LENGTH를 주입하는 일이 전혀 없도록 코드를 작성하고 있다. 오직 yml 파일에서만 받아올 수 있다.

그러므로, 본 프로젝트에서 사용하는 Account의 생성자는 NICKNAME_MAX_LENGTH를 매개변수로 받지 않는다.

NICKNAME_MAX_LENGTH의 값을 변경하는 setter 또한 사용하지 않는다.

 

즉, 첫 번째 방법을 사용하려면, NICKNAME_MAX_LENGTH를 매개변수로 받지 않는 기존의 생성자 대신, 오로지 테스트에만 사용될 생성자를 새로 만들어야 하는 것이고

두 번째 방법을 사용하려면, 프로덕션 코드에 존재하지 않던 setter를 오로지 테스트만을 위해 새로 만들어야 하는 것이다.

 

 

 

 


해결법

Account.java

(테스트의 대상이 되는 코드)

  • @Value 어노테이션을 삭제한다.
  • 대신 초기화 블록(AccountPropertyInitializer)을 이용하여, yml파일에 명시된 NICKNAME_MAX_LENGTH의 값을 주입한다.
package 내 프로젝트의 패키지 경로;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account {

    @Transient
    private static Long NICKNAME_MAX_LENGTH;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "account_id", nullable = false, updatable = false)
    private Long accountId;

    @Column(nullable = false)
    private String kakaoId;

    @Column(nullable = false)
    private String nickname;

    @Column(nullable = false)
    private String profileImageUrl;

    @Builder
    public Account(String kakaoId, String nickname, String profileImageUrl) {
        validateKakaoId(kakaoId);
        validateNickname(nickname);
        this.kakaoId = kakaoId;
        this.nickname = nickname;
        this.profileImageUrl = profileImageUrl;
    }

    public void updateNickname(String newNickname) {
        validateNickname(newNickname);
        this.nickname = newNickname;
    }

    public void updateProfileImageUrl(String newUrl) {
        this.profileImageUrl = newUrl;
    }

    private void validateKakaoId(String kakaoId) {
        try{
            Integer.parseInt(kakaoId);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("[ERROR] 카카오회원 ID가 정수가 아닙니다!");
        }
    }

    private void validateNickname(String nickname) {
        if(nickname.length()> NICKNAME_MAX_LENGTH)
            throw new IllegalArgumentException("[ERROR] 닉네임이 너무 깁니다!");
    }

    @Component
    public static class AccountPropertyInitializer {
        @Autowired
        public AccountPropertyInitializer(Environment environment) {
            NICKNAME_MAX_LENGTH = Long.parseLong(environment.getProperty("numbers.account.nickname-max"));
        }
    }
}

 

 

 

AccountTest.java

(Account.java를 테스트하는 테스트 코드)

  • 테스트가 Spring 컨텍스트 내에서 실행되도록, @SpringBootTest 어노테이션을 붙인다.
  • 테스트가 프로덕션 코드와 동일한 yml 파일을 참조하도록, @TestPropertySource 어노테이션으로 yml 파일의 경로를 지정해준다.
package 내 프로젝트의 패키지 경로;

import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@TestPropertySource(properties = {
        "spring.config.location = src/main/resources/application.yml"
})
class AccountTest {

    @Value("${numbers.account.nickname-max}")
    private Long NICKNAME_MAX_LENGTH;

    @Test
    void 새회원생성_성공테스트() {
        // given
        String KAKAO_ID = "12345678";
        String NICKNAME = "nickname";
        String PROFILE_IMAGE_URL = "example.com";
        // when
        Account account = Account.builder()
                .kakaoId(KAKAO_ID)
                .nickname(NICKNAME)
                .profileImageUrl(PROFILE_IMAGE_URL)
                .build();
        // then
        SoftAssertions softly = new SoftAssertions();
        softly.assertThat(account.getKakaoId()).isEqualTo(KAKAO_ID);
        softly.assertThat(account.getNickname()).isEqualTo(NICKNAME);
        softly.assertThat(account.getProfileImageUrl()).isEqualTo(PROFILE_IMAGE_URL);
        softly.assertAll();
    }

    @Test
    void 새회원생성_실패테스트_카카오ID가_정수가_아님() {
        // given
        String KAKAO_ID = "abcd";
        String NICKNAME = "nickname";
        String PROFILE_IMAGE_URL = "example.com";
        // when, then
        assertThrows(IllegalArgumentException.class,
                () -> Account.builder().kakaoId(KAKAO_ID).nickname(NICKNAME).profileImageUrl(PROFILE_IMAGE_URL).build());
    }

    @Test
    void 새회원생성_실패테스트_닉네임_글자수_초과() {
        // given
        String KAKAO_ID = "12345678";
        String concatedString = "";
        for (int i=0; i< NICKNAME_MAX_LENGTH+1; i++) {
            concatedString = concatedString.concat("a");
        }
        String TOO_LONG_NICKNAME = concatedString;
        String PROFILE_IMAGE_URL = "example.com";
        // when, then
        assertThrows(IllegalArgumentException.class,
                () -> Account.builder().kakaoId(KAKAO_ID).nickname(TOO_LONG_NICKNAME).profileImageUrl(PROFILE_IMAGE_URL).build());
    }

    @Test
    void 닉네임수정_성공테스트() {
        // given
        String OLD_NICKNAME = "old";
        String NEW_NICKNAME = "new";
        Account account = Account.builder()
                .kakaoId("12345678")
                .nickname(OLD_NICKNAME)
                .profileImageUrl("example.com")
                .build();
        // when
        account.updateNickname(NEW_NICKNAME);
        // then
        assertThat(account.getNickname()).isEqualTo(NEW_NICKNAME);
    }

    @Test
    void 회원정보수정_실패테스트_닉네임_글자수_초과() {
        // given
        String OLD_NICKNAME = "old";
        String concatedString = "";
        for (int i=0; i< NICKNAME_MAX_LENGTH+1; i++) {
            concatedString = concatedString.concat("a");
        }
        String NEW_NICKNAME = concatedString;
        Account account = Account.builder()
                .kakaoId("12345678")
                .nickname(OLD_NICKNAME)
                .profileImageUrl("example.com")
                .build();
        // when, then
        assertThrows(IllegalArgumentException.class, () -> account.updateNickname(NEW_NICKNAME));
    }
}

 

 


 

(내용 추가가 완료되면 아래는 숨김 처리됩니다)

추가할 내용

이 문제의 발생 원인을 알기 위해서는 다음의 두 가지를 알아야 한다.

  • @Value의 작동 조건과 시점
  • 테스트 코드의 실행 방식

 

스프링 컨텍스트 내에서 빈이 등록될 때 @Value가 작동된다는 것은 알겠다. 기존의 코드는 스프링 컨텍스트와 관계 없이 실행되고 있었고, 그래서 테스트 코드에 @SpringBootTest와 @TestPropertySource를 추가해야 한다. 이건 어렴풋이 이해가 된다.

 

다만, 테스트 코드의 수정을 마치고, Account.java는 수정하지 않은 채로 돌리면, AccountTest.java 내의 @Value는 작동하지만 Account.java 내의 @Value는 여전히 작동하지 않는다.

이런 일이 일어나는 이유가 아직 살짝 이해가 안 된다.

테스트 코드 실행 시에는 프로덕션 코드의 환경설정이 적용이 안 되는 것일까?

그렇다면 수정 완료된 코드에서처럼 Environment 클래스를 사용하면 왜 문제가 해결되는 것일까?

 

본 프로젝트를 마감 시일 내에 완성부터 한 뒤에,

@Value의 정확한 작동 시점과 테스트 코드의 실행 방식에 대해 좀 더 공부하고 이 부분에 대해 추가하겠다.