문제상황
// 서비스 클래스
@RequiredArgsConstructor
@Service
public class AdminService {
@Value("${spring.profiles.active}")
private String activeEnv;
// ...서비스 로직
}
// 테스트 클래스
@ExtendWith(MockitoExtension.class)
class AdminServiceTest {
@InjectMocks
private AdminService adminService;
// ...테스트 코드
}
서비스 레이어의 테스트를 진행하기 위해 Mockito를 사용한 테스트를 만들었던 상황입니다.
서비스 클래스에선 현재 활성화된 프로파일의 이름을 사용하고자 @Value를 통해 프로퍼티를 주입받았습니다.
(local, dev, qa, stage, prod를 사용합니다)
@InjectMocks를 사용해 Mock 객체들이 주입될 대상 객체를 지정했습니다.
간단하게 말해 Mock Test에서 테스트할 대상이라고 생각하시면 됩니다.
그런데 Mockito를 사용한 단위 테스트에선 Spring의 Application Context가 로딩되지 않기 때문에 테스트를 수행하면서 activeEnv가 주입되지 않아 NullPointerException이 발생했습니다.
@Value를 통한 프로퍼티 주입은 나쁜 습관이지만 해당 포스트에서 중요한 부분이 아니니 간략하게 알아봅시다. 링크에선 아래 2가지 이유를 들어 @Value가 좋지 못한 Injection이라고 말하고 있습니다.
1. Configuration이 전체 어플리케이션에 흩어져 있게 됩니다.
각 클래스가 @Value로 Configuration의 일부분을 선택적으로 사용하기 때문이죠.
key의 이름을 변경할 경우 cmd + shift + f로 프로젝트 내에서 검색하여 일일이 수정해야 할 지도 모릅니다.
2. Configuration은 다른 클래스들처럼 캡슐화된 서비스로 제공되어야 합니다.
이 경우 책임을 1군데에서 가지게 됩니다.
Spring Boot를 사용할 경우 @ConfigurationProperites로 쉽게 주입받을 수 있습니다.
문제해결
테스트 관심사의 분리를 위해 Mock Test를 진행했던 것인데 Application Context를 불러오면 의미가 없을 것입니다.
Context를 불러오지 않는 방향으로 서비스 클래스의 해당 필드 값을 할당하는 방법에는 여러가지가 있습니다.
첫번째 방법은 서비스 클래스에 Setter를 추가하는 것입니다.
하지만 Setter가 존재한다는 건 인스턴스가 생성된 후 내부의 상태 값을 동적으로 변경할 수 있다는 의미입니다.
도매인 객체에 무분별한 Setter처럼 테스트 통과를 위해 이를 추가하는 건 바람직하지 않다고 보입니다.
두번째 방법은 Reflection을 사용하는 것입니다.
서비스 클래스의 인스턴스가 생성된 후 private 멤버 필드의 값을 동적으로 할당할 수 있습니다.
Apache Commons의 FieldUtils을 사용할 수 있습니다.
@BeforeEach
void setup() throws IllegalAccessException {
// wraps setting a field on a object by reflection
FieldUtils.writeField(adminUserElasticQueryService, "activeEnv", "dev", true);
}
Spring에서 제공하는 ReflectionTestUtils을 사용해도 됩니다.
@Before
public void setUp() {
ReflectionTestUtils.setField(controllerUnderTest,
"myProperty",
"String you want to inject");
}
하지만 리플렉션을 사용하는 건 주의해야 합니다. 아래 포스트에서 @Péter Török은 리플렉션은 더 이상 변경할 수 없는 API나 레거시 코드를 단위 테스트하는 특별한 경우에만 사용되는 마지막 방법이어야 한다고 합니다. 그렇지 않은 경우 당신의 코드가 테스트에 적합하지 않은 디자인으로 짜여졌다는 것이죠.
위와 같이 private한 멤버를 테스트에서 접근해야하는 경우 그건 클래스가 적합하지 않은 인터페이스를 가지고 있거나 너무 많은 것을 하려는 뜻입니다. 그러므로 인터페이스를 수정하거나, 몇몇 코드를 별도 클래스로 분리하거나, 문제가 되는 메소드 / 필드 접근자를 public으로 만들어야한다고 합니다.
게다가 리플렉션은 런타임 시 동작하기 때문에, 컴파일 시 감지되지 못한 예외가 등장할 수도 있습니다.
물론 JUnit과 같은 테스트 프레임워크 내에선 테스트를 위해 리플렉션을 많이 사용하기도 합니다. 하지만 이는 테스트 프레임워크 내에 캡슐화된 implementation이므로 우리의 테스팅 코드에 악영향을 주진 않습니다.
하지만 바로 아래의 답변에서 @Bozho가 지적한 것처럼 Mock test에서 의존성 주입을 위해 사용하는 건 괜찮아 보입니다. 애초에 Spring의 ReflectionTestUtils도 이 목적을 위해 만들어진 것이니까요. 저는 일단 리플렉션을 통해 문제를 해결했습니다.
마지막 방법은 final 키워드와 생성자 주입을 사용해서 멤버 필드에 값을 할당하는 것입니다.
@Value와 같은 프로퍼티 주입은 사실 Bean이 초기화된 후, 스프링에 의한 Reflection으로 런타임에 수행됩니다.
이러한 프로퍼티 주입을 Constructor Injection으로 변경하는 것입니다.
멤버 필드를 private final로 변경하고, 생성자의 파라미터 부분에 @Value 어노테이션을 붙여줍니다. 그리고 테스트 코드에서 이 생성자를 활용해서 원하는 인스턴스를 직접 초기화하면 됩니다. Lombok을 사용하면 더욱 편리하게 수정할 수 있습니다.
리플렉션을 사용하지 않고, 원래 코드를 해치지 않으면서 수정할 수 있는 좋은 방법입니다.
아래 Podo님의 포스트를 보시면 더욱 자세한 예시도 보실 수 있습니다.
추가학습
목적에 따라 테스트 전략을 어떻게 가져가면 좋을지 설명한 글입니다.
테스트의 관심사를 적절히 나눴다고 생각하여 저도 테스트의 대부분을 이 글을 따라 진행하고 있습니다.
Mockito Test Framework를 자세히 설명한 글입니다.
처음 Mock Test를 진행할 때 개념을 잡고 따라하기 좋습니다. 저도 헷갈릴 때 들어가보곤 합니다.
'문제해결' 카테고리의 다른 글
Nginx 413 Request Entity Too Large (0) | 2021.05.09 |
---|---|
Testcontainers - Localstack 설정 중 생겼던 궁금증 (0) | 2021.04.25 |
댓글