1. 개요
의존성 역전 원칙(Dependency Inversion Principle)은 객체 지향 설계 원칙 중 하나이다. SOLID 중 D가 바로 의존석 역전 원칙이다. 시스템의 고수준 모듈(비즈니스 로직 등의 세부 구현)이 저수준 모듈(데이터 엑세스 등)에 직접적으로 의존하는 것을 피하고, 대신 둘 모두가 추상화에 의존하도록 설계하는 원칙이다.
의존성 역전 원칙이 구현되어 있지 않은 프로젝트의 경우 유닛 테스트가 쉽지 않다. 일반적인 레이어드 아키텍처의 스프링 프로젝트들은Controller - Service - Repository 순으로 모두 직접 의존하게 되어 있는 경우가 많다. 이런 프로젝트들은 단위 테스트 작성이 매우 까다롭다. 데이터베이스 연결이 필요한 환경에서 테스트를 실행하거나, 테스트 데이터를 준비하고 테스트 후 원래 상태로 롤백해야한다. 또한, 데이터베이스 커넥션이 직접적으로 이뤄지기에 테스트 실행 속도가 느려진다. 이런 상황을 타파하기 위해 Mockito 프레임워크를 통해 Mocking 하여 강제적으로 메소드의 동작을 지정한다.
@Test
void sut_some_teset() {
// given
SomeEntity someEntity = new SomeEntity();
someEntiy.setSome("...");
SomeDomain someDomain = new SomeDomaion();
someDomain.setSome("...");
Mockito.when(someRepository.findById()).thenReturn(someEntity);
Mockito.when(someApiClient.post()).thenReturn(someDomain);
Mockito.when(someRepository.findById()).thenReturn(someEntity);
// when
...
// then
...
}
그렇게 복잡하지 않은 모듈의 경우엔 틀린 방법은 아니나 복잡하게 여러 모듈을 의존하는 모듈의 경우엔 내부 구현도 알고 있어야 mock을 작성할 수 있고, 테스트 코드의 가독성 자체도 매우 떨어지게 된다. 또한, Mock을 작성하면서 실제 테스트 대상에 집중하는 것을 방해할 수 있다. 내부 로직의 변화로 호출하는 메소드가 바뀌거나, Mock 객체에 넘기는 인자가 바뀌는 경우엔 테스트는 깨지는 테스트가 된다. 설계를 테스트하는 것이 아닌 내부 구현에 대한 검증까지 진행하는 것이다. 또한, Mockito 프레임워크를 쓸 수 없는 상황(ex. 언어가 변경 or 라이브러리를 붙일 수 없음)에서 테스트를 진행하기가 어려울 것이다.
레이어드 아키텍처 자체의 문제는 아니다. 프로젝트가 소규모라면 의존성 역전 원칙을 적용 하는건 오버 엔지니어링이 될 수 있다. 단, 시스템이 커지고 복잡해질 때 DIP를 적용하는 것이 유리할 것이다. DIP를 통해 각 모듈들은 서로 추상화된 인터페이스에만 의존하게 되므로, 가짜 객체를 제공하기 훨씬 쉬워져 테스트 코드의 가독성이 좋아지고 테스트 대상에 집중하게 되는 효과를 누릴 수 있다.
2. 예제
우선 Service, Repository 의 Interface를 생성해준다.
public interface UserRepository {
Optional<User> findById(long id);
Optional<User> findByEmail(String email);
}
public interface UserService {
User update(long id, UserUpdate userUpdate);
User getByEmail(String email);
}
각 인터페이스들의 구현체를 생성한다. 이때 유의할 점으로 각 모듈은 실체 구현체에 의존하는 것이 아닌 추상화에 의존해야한다.
import interface.UserRepository;
import java.util.Optional;
import org.springframework.stereotype.Repository
@Repository
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepository {
private final UserJpaRepository userJpaRepository;
public Optional<User> findById(long id){
return userJpaRepository.findById(id);
}
public Optional<User> findByEmail(String email) {
return userJpaRepository.findByEmail(email);
}
}
import interface.UserService;
import interface.UserRepository;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
public User update(long id, UserUpdate userUpdate) {
... logic
}
public User getByEmail(String email) {
... logic
User user = userRepository.findByEmail(email).orElseThrow(() -> new ResourceNotFoundException());
... logic
return user;
}
}
각 인터페이스의 구현체들은 빈에 하나씩만 등록하여 DI될 때 알아서 구현체들이 주입되므로 실제 코드가 컴파일되고 실행될 땐 알아서 각 구현체들이 선택된다. 테스트 코드에선 각 인터페이스들을 구현하는 Fake 객체를 만들어 테스트에 활용한다.
public class FakeUserRepository implements UserRepository {
private final long autoGeneratedId = 1L;
private final List<User> data = new ArrayList<>();
public Optional<User> findById(long id) {
return data.stream().filter(item -> item.getId().equals(id)).findAny();
}
public Optional<User> findByEmail(String email) {
return data.stream().filter(item -> item.getEmail().equals(email)).findAny();
}
public void save(User user) {
if (user.getId() == null || user.getId() == 0) {
User newUser = User.builder()
.id(autoGeneratedId++)
.email(user.getEmail())
.build();
data.add(user);
} else {
data.removeIf(item -> Objects.equals(item.getid(), user.getId()));
data.add(user);
}
}
}
public class UserServiceTest {
private UserServiceImpl sut;
@BeforeEach
void setup() {
// UserRepository 인터페이스만을 의존하고 있기에 Fake 객체를 손쉽게 사용할 수 있다.
FakeUserRepository fakeUserRepository = new FakeUserRepository();
this.sut = new UserService(fakeUserRepository);
fakeUserRepository.save(User.bulder()
.id(1L)
.email("test@test.com")
.build());
}
@Test
@DisplayName("이메일로 User 정보를 받아올 수 있다.")
void sut_user_can_find_by_email() {
// given
String email = "test@test.com";
// when
User actual = sut.getByEmail(email);
// then
assertThat(actual.getEmail()).isEqualTo(email);
}
@Test
@DisplayName("이메일로 User 정보를 찾을 수 없는 경우 ResourceNotFoundException이 발생한다.")
void sut_error_occur_when_cant_find_user() {
// given
String email = "test2@nothing.com";
// when
// then
assertThatThrownBy(() -> sut.getByEmail(email)).isInstanceOf(ResourceNotFoundException.class);
}
}
만약 의존성 역전이 안된 상태로 Mockito를 활용해 테스트를 진행했다면 아래와 같을 것이다.
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
UserRepository userRepository;
@Test
@DisplayName("이메일로 User 정보를 받아올 수 있다.")
void sut_user_can_find_by_email() {
// given
User user = User.builder()
.id(1L)
.email("test@test.com")
.build();
Mockito.when(userRepository.findByEmail(any(String.class)))
.thenReturn(user);
// when
User actual = userService.getByEmail(email);
// then
assertThat(actual.getEmail()).isEqualTo(email);
}
}
동일한 방식으로 Controller 의 로직을 테스트 할 때도 FakeUserService와 FakeUserRepository를 생성해 테스트할 수 있다. mockMvc등을 활용해서 통합 테스트를 진행하지 않아도 Controller로부터 발생되는 흐름도를 테스트할 수 있게 되는 것이다. 통합 테스트의 최대 약점인 실행 시간에서 벗어나 단위 테스트를 작성할 수 있게 되는 것이다.
3. 결론
의존성 역전이 반드시 정답인 것은 아니다. 의존성 역전을 적용하게 되면 관리 포인트가 늘고 복잡해지며 초기 투자가 많이 필요하다. 단, 테스트 코드를 작성함에 있어 Mockito에 너무 의존적인 상태라면 DIP가 해답이 될 수 있다. Mockito를 활용해 테스트 코드를 작성 중이라면 어느순간 내가 테스트코드를 작성하는 것인지 객체를 세팅하고 각 메소드들의 동작을 일일히 지정해주는 자체가 테스트인지 헷갈려질 때가 많다. 이럴 때 의존성 역전을 적용해 좀 더 가독성 있고, 테스트 코드에 더 집중할 수 있는 환경을 만드는 것이 바람직할 것이다.
'로직과의 사투 > Test' 카테고리의 다른 글
Mockito를 이용한 단위테스트 겉핥기 (0) | 2023.02.21 |
---|