public class ArticleService {
private final ArticleRepository articleRepository;
private final UserRepository userRepository;
public Article writeArticle(ArticleWriteRequest request) {
// ...
}
}
Layered Architecture와 함께 Spring으로 서비스를 구현하다보면 필연적으로 Repository 의존성이 생기게 된다. 그리하면 당연히 서비스의 테스트를 짜기 위해서는 Repository의 의존성을 주입해주어야 한다.
class ArticleServiceTest {
@Test
void writeArticleTest() {
// given
ArticleRepository repository = mock(ArticleRepository::class);
when(repository.save(any())).thenReturn(
Article.builder()
.id(1)
.title("title")
.content("content")
.build()
);
ArticleService articleService = new ArticleService(
repository,
mock(UserRepository::class)
);
// when
articleService.writeArticle(
new ArticleWriteRequest("title", "content")
);
// then
assertThat(repository.findAll()).hasSize(1);
assertThat(repository.findById(1).getTitle()).isEqualTo("title");
}
}
하지만 서비스의 테스트를 하기 위해 매번 @SpringBootTest
를 할 수 없는 노릇이고, 매번 이렇게 모킹을 할 경우 매번 when
을 통해 Repository의 동작을 작성해주어야 하므로 실제로 Service가 제대로 동작하는지를 확인하기 조금 까다롭다.
public class ArticleMockRepository implements ArticleRepository {
private final Map<Long, Article> articleMap = new HashMap<>();
public Article save(Article a) {
articleMap.put(a);
return a;
}
public Optional<Article> findById(long id) {
return Optional.ofNullable(articleMap .get(id));
}
public List<Article> findAll() {
return List.copyOf(articleMap.values());
}
public void clear() {
map.clear();
}
}
그렇기 때문에 나는 Stub을 통해 진짜 객체를 넣어주는 것을 선호한다. 레포지토리는 HashMap을 통해 구현할 수 있으니!
하지만 Repository는 비즈니스 객체의 갯수만큼 존재할 것이고, 이걸 매번 다 짜려면 굉장히 귀찮은데.
그러니 제네릭을 활용하여 공통화를 해보자.
제네릭을 사용해 공통화하기
public class HashMapRepository<T> {
private final Map<Long, T> map = new HashMap<>();
protected void save(long id, T entity) {
map.put(id, entity);
}
public Optional<T> findById(long id) {
return Optional.ofNullable(map.get(id));
}
public List<T> findAll() {
return List.copyOf(map.values());
}
// 원하는 조회 조건 생성용
protected Optional<T> findBy(Predicate<T> predicate) {
return map.values().stream()
.filter(predicate)
.findFirst();
}
protected List<T> findAllBy(Predicate<T> predicate) {
return map.values().stream()
.filter(predicate)
.toList();
}
public void clear() {
map.clear();
}
}
다음과 같이 HashMapRepository
를 구현할 수 있다. 이렇게 구현한다면 이제 각 도메인 별로 레포지토리를 만들기는 간단하다.
class ArticleMockRepository extends HashMapRepository<Article> implements ArticleRepository {
@Override
public Article save(Article article) {
super.save(article.getId(), article);
return article;
}
@Override
public Optional<Article> findByAuthorId(long authorId) {
return findBy(it -> it.getAuthorId() == authorId);
}
}
다음과 같이 save
만 재정의해주면 된다.
만약 원하는 조회 조건이 필요하다면, findBy
, findAllBy
를 이용해서 원하는 조건을 Predicate
로 넣어주는 방식을 이용해서 추가해주면 된다.
적용과 함께 마무리
class ArticleServiceTest {
@Test
void writeArticleTest() {
// given
ArticleRepository repository = new ArticleMockRepository();
ArticleService articleService = new ArticleService(
repository,
new UserMockRepository()
);
// when
articleService.writeArticle(
new ArticleWriteRequest("title", "content")
);
// then
assertThat(repository.findAll()).hasSize(1);
assertThat(repository.findById(1).getTitle()).isEqualTo("title");
}
}
꽤나 깔끔한 모양새로 테스트를 작성할 수 있게 됐다.
구현해야 할 메소드가 너무 많아요
이런 식으로 Stub 객체를 만드려고 했더니 ArticleRepository
안의 인터페이스가 너무 많아서 모두 구현하기 힘들 수도 있다. ( JpaRepository
를 그대로 상속받아서 사용 중인 경우 특히 그렇다)
하지만 그렇다고 Stub을 포기하고 Mock을 시도하는 것은, 테스트 코드가 알려주는 코드 악취를 모킹이라는 강력한 도구로 감추고 숨는 것에 지나지 못한다.
그럴 때에는 설계를 조금 고쳐보는 것은 어떨까? 이전 블로그의 글을 링크 걸어본다.