문제상황
실제 서버를 작동시켰을 때에는 잘 작동하던 @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이 발생한다.
문제해결 조건
나는 다음의 두 가지 조건을 지키며 이 문제를 해결하고 싶었다.
- NICKNAME_MAX_LENGTH의 값의 결정과 관련된 코드는 오로지 Account.java와 application.yml에만 있어야 한다.
- 프로덕션 코드에서는 사용되지 않으면서 오로지 테스트만을 위해 작성되는 코드는 없어야 한다.
그 이유는 다음과 같다.
닉네임의 최대 길이 값은 회원정보 도메인에만 관련된 매직 넘버이다. 따라서 나는 프로젝트의 모든 클래스 중 오직 회원정보 도메인, 즉 오직 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의 정확한 작동 시점과 테스트 코드의 실행 방식에 대해 좀 더 공부하고 이 부분에 대해 추가하겠다.