본문 바로가기

Project/Trouble Shooting

[Independe] 댓글 Repository 삭제 테스트 오류 해결

프로젝트를 Junit5를 이용해 댓글 삭제 테스트 코드를 작성하던 중  Postman으로 테스트 할 땐 정상적으로 동작하던 코드가 테스트 코드를 이용하면 원하는 결과값이 도출되지 않았다.

@Test
void deleteCommentsByPostIdTest() {
    // given
    Member member = Member.builder()
            .username("id")
            .password("1234")
            .nickname("nick")
            .role("ROLE_USER")
            .build();
    memberRepository.save(member);

    Post post = Post.builder()
            .title("title")
            .content("content")
            .member(member)
            .independentPostType(IndependentPostType.COOK)
            .build();
    postRepository.save(post);

    Comment parentComment = Comment.builder()
            .content("parent")
            .member(member)
            .post(post)
            .build();
    Comment savedParent = commentRepository.save(parentComment);

    // when
    commentRepository.deleteCommentsByPostId(savedParent.getPost().getId());

    // then
    assertThatThrownBy(() -> commentRepository.findById(savedParent.getId()).get())
            .isInstanceOf(NoSuchElementException.class);
}

 

@Override
public int deleteCommentsByPostId(Long postId) {
    return em.createQuery("delete from Comment c where c.post.id = :postId")
            .setParameter("postId", postId)
            .executeUpdate();
}

문제의 코드는 다음과 같았다. 댓글 작성을 위해 Member Post 객체를 먼저 저장한 뒤 Comment 객체를 생성해 저장하고 게시글 PK를 이용해 댓글을 삭제하는 테스트를 원했다. 코드 흐름 상 전혀 문제가 없을 것 같아 당연히 통과할 수 있을줄 알았다. 그러나..

java.lang.AssertionError: 
Expecting code to raise a throwable.

at community.independe.repository.CommentRepositoryTest.deleteCommentsByPostIdTest(CommentRepositoryTest.java:233)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
	at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:727)
	at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
	at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:156)
	at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:147)
	at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:86)
	at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(InterceptingExecutableInvoker.java:103)
	at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.lambda$invoke$0(InterceptingExecutableInvoker.java:93)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)
	at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:92)
	at org.junit.jupiter.engine.execution.InterceptingExecutableInvoker.invoke(InterceptingExecutableInvoker.java:86)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$7(TestMethodTestDescriptor.java:217)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:213)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:138)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:68)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

테스트 수행 결과 예외가 발생되길 예상했지만 예외가 발생하지 않았다. 이유가 뭐지?

로그에 delete 쿼리가 나간 것 까지 보인다. 쿼리에 문제가 있나 싶어 정확한 파라미터 값을 보기위해 p6spy 라이브러리를 추가해보았다

// p6spy 추가
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'

코드에 System.out을 이용해 값을 추가해 비교해 보았다. (보통의 경우 System.out보단 Log를 사용하는 것이 더 좋다. 난 잠시 확인해 본 뒤 System.out을 제거할 계획이다.)

// == 생략 ==//
Comment parentComment = Comment.builder()
                .content("parent")
                .member(member)
                .post(post)
                .build();
Comment savedParent = commentRepository.save(parentComment);

System.out.println("post Id" + savedParent.getPost().getId());

// when
commentRepository.deleteCommentsByPostId(savedParent.getPost().getId());

// == 생략 ==//

postId 값이 일치한다. 그렇다면 쿼리상 postId가 3번인 댓글이 지워져야 한다. 하지만 댓글이 지워지지 않아 예외를 발생시키지 못 했다. 왜 이런 현상이 발생하는지 도저히 이해할 수 없었다. 다른 테스트들을 하나씩 돌려보며 다른점을 찾아봤다. 그러다 JPA에서 제공하는 deleteById를 이용해 제거해도 같은 현상이 발생하는지 궁금했다.

@Test
void deleteByIdTest() {
    // given
    Member member = Member.builder()
            .username("id")
            .password("1234")
            .nickname("nick")
            .role("ROLE_USER")
            .build();
    memberRepository.save(member);

    Post post = Post.builder()
            .title("title")
            .content("content")
            .member(member)
            .independentPostType(IndependentPostType.COOK)
            .build();
    postRepository.save(post);

    Comment parentComment = Comment.builder()
            .content("parent")
            .member(member)
            .post(post)
            .build();
    Comment savedComment = commentRepository.save(parentComment);

    // when
    commentRepository.deleteById(savedComment.getId());

    // then
    assertThatThrownBy(() -> commentRepository.findById(savedComment.getId()).get())
            .isInstanceOf(NoSuchElementException.class);
}

해당 코드는 정상적으로 동작했다. 그런데 뭔가 이상한 점을 발견했다 로그에 delete 쿼리가 없었다. 로그를 다 뒤져봐도 delete 쿼리는 보이지 않았다 심지어 이제 다시보니 insert 쿼리도 없었다. Member, Post Comment를 저장하려면 insert 쿼리가 있었어야 하는데 이제보니 없다. 해당 이유를 구글링을 통해 찾아보았다.

@DataJpaTest
public class CommentRepositoryTest {

    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private PostRepository postRepository;
    @Autowired
    private CommentRepository commentRepository;
    
    //== 생략 ==//

원인은 테스트 클래스에 붙은 @DataJpaTest 때문이었다. @DataJpaTest 내부에는 @Transactional이 붙어있는데 @Test는 테스트 이후 데이터를 롤백시키기 때문에 데이터를 저장한다 해서 DB에 반영하는 것이 아닌 영속성 컨텍스트에서만 관리되는 것이었다. 그렇다면 여기서 또 다른 고민이 생긴다. '왜 내 처음 코드는 delete 쿼리가 로그에 적힌거지?' 다시한번 내 코드를 살펴보았다.

public class CommentRepositoryCustomImpl implements CommentRepositoryCustom{

    private final JPAQueryFactory queryFactory;
    private final EntityManager em;

    public CommentRepositoryCustomImpl(EntityManager em) {
        this.em = em;
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public int deleteCommentsByPostId(Long postId) {
        return em.createQuery("delete from Comment c where c.post.id = :postId")
                .setParameter("postId", postId)
                .executeUpdate();
    }

    @Override
    public int deleteCommentByParentId(Long commentId) {
        return em.createQuery("delete from Comment c where c.parent.id = :parentId")
                .setParameter("parentId", commentId)
                .executeUpdate();
    }
}
public interface CommentRepositoryCustom {

    int deleteCommentsByPostId(Long postId);

    int deleteCommentByParentId(Long commentId);
}

 내 쿼리는 쿼리 한번에 여러 테이블의 로우를 변경하는 벌크 연산이다. 이제야 예전 JPA를 공부했던 기억이 떠올랐다. 벌크연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 보낸다. 즉, @Test에 붙은 @Transactional로 인해 영속성 컨텍스트에만 관리되던 댓글을 데이터베이스에 직접 delete 쿼리를 날려 삭제하려 했기 때문에 테스트가 실패한 것이었다. 그럼 어떻게 해결하지? EntityManager가 있구나!

@Test
void deleteCommentsByPostIdTest() {
    // given
    Member member = Member.builder()
            .username("id")
            .password("1234")
            .nickname("nick")
            .role("ROLE_USER")
            .build();
    memberRepository.save(member);

    Post post = Post.builder()
            .title("title")
            .content("content")
            .member(member)
            .independentPostType(IndependentPostType.COOK)
            .build();
    postRepository.save(post);

    Comment parentComment = Comment.builder()
            .content("parent")
            .member(member)
            .post(post)
            .build();
    Comment savedParent = commentRepository.save(parentComment);

    em.flush();
    em.clear();

    // when
    commentRepository.deleteCommentsByPostId(savedParent.getPost().getId());

    // then
    assertThatThrownBy(() -> commentRepository.findById(savedParent.getId()).get())
            .isInstanceOf(NoSuchElementException.class);
}

EntityManager를 이용해 영속성 컨텍스트를 강제로 DB에 flush한 뒤 영속성컨텍스트를 삭제했다.

그 결과 정상적으로 테스트가 작동했다. 내 머리를 많이 아프게 했지만 JPA에 대해 좀 더 자세히 공부하고 알게된 순간이었다.