스프링 부트로 블로그 서비스 개발하기

포스트 CRUD 기능 - 테스트 코드

exena 2025. 12. 6. 07:34

테스트 코드 작성이 클린코드에서 중요하다고 말씀하셨으니 만들어야지.

도메인 영역

일단 게시글 엔티티의 도메인 규칙 테스트를 작성해 보자.

class PostTest {
    Member member;
    Post post;

    @BeforeEach
    void setUp() {
        // given
        PublishBlogpostFormRequest request = getPublishBlogpostFormRequest();

        // when
        member = Mockito.mock(Member.class);
        post = Post.of(request, member);
    }

    @Test
    @DisplayName("Post.of(): 요청값과 Member로 올바른 Post가 생성된다")
    void createPostFromFactoryMethod() {
        // then
        assertThat(post.getId()).isNull();
        assertThat(post.getTitle()).isEqualTo("Hello World");
        assertThat(post.getContent()).isEqualTo("This is content");
        assertThat(post.getMember()).isEqualTo(member);
    }

    @Test
    @DisplayName("title이 null이면 예외를 반환한다")
    void constructorNullCheck() {
        //when
        var publishBlogpostFormRequest = new PublishBlogpostFormRequest();

        //then
        assertThatThrownBy(()->Post.of(publishBlogpostFormRequest,member))
                .isInstanceOf(NullPointerException.class);
    }

    @Test
    @DisplayName("title과 content는 수정 가능한 필드이다")
    void changePostFields() {
        // when
        post.changeTitle("new title");
        post.changeContent("new content");

        // then
        assertThat(post.getTitle()).isEqualTo("new title");
        assertThat(post.getContent()).isEqualTo("new content");
    }

    @Test
    @DisplayName("title 수정시에도 Null이 들어가선 안된다.")
    void changePostFieldsNullCheck() {
        // then
        assertThatThrownBy(()->post.changeTitle(null))
                .isInstanceOf(NullPointerException.class);
    }

    @Test
    @DisplayName("Post 생성 시 Member가 정상적으로 매핑되는지 테스트")
    void memberMappingTest() {
        // then
        assertThat(post.getMember()).isNotNull();
        assertThat(post.getMember()).isEqualTo(member);
    }

}

이런 간단한 테스트는 AI로 생성하면 테스트 코드 작성에 큰 시간이 들지 않는 반면 유지보수성은 높아진다.

다만 셋업함수같은 부분은 직접 나눠주었다.

public class PostFixture {

    public static PublishBlogpostFormRequest getPublishBlogpostFormRequest() {
        return new PublishBlogpostFormRequest(null, "Hello World", "This is content");
    }
}

이후 서비스단 테스트에서도 재사용될 만한 부분은 Fixture로 만들어둔다.

 

어플리케이션 영역

리포지토리

@DataJpaTest
class PostRepositoryTest {

    @Autowired
    private PostRepository postRepository;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    EntityManager entityManager;

    @Test
    @DisplayName("findByMember()가 해당 member의 게시글 목록을 페이징하여 반환한다")
    void findByMemberTest() {

        // given
        Member member = Member.register(createRegisterRequest(), createPasswordEncoder());
        memberRepository.save(member);

        Post p1 = Post.of(getPublishBlogpostFormRequest(), member);
        Post p2 = Post.of(getPublishBlogpostFormRequest(), member);
        Post p3 = Post.of(getPublishBlogpostFormRequest(), member);
        postRepository.save(p1);
        postRepository.save(p2);
        postRepository.save(p3);

        entityManager.flush();
        entityManager.clear();

        // when
        Page<Post> page = postRepository.findByMember(member, PageRequest.of(0, 2));

        // then
        assertThat(page.getTotalElements()).isEqualTo(3);
        assertThat(page.getContent().size()).isEqualTo(2);
//        assertThat(page.getContent().getFirst().getMember()).isEqualTo(member); 프록시 객체 equals 정의하기
    }
}

어차피 JPA가 구현해주는 부분이니까 엄밀한 테스트가 필요하진 않을 것 같다.

  • PageRequest는 Pageable의 구현체이다.

포트

@SpringBootTest
@Transactional
@Import(AnthiveTestConfiguration.class)
class PostModifyTest {
    @Autowired MemberRegister memberRegister;
    @Autowired PostModify postModify;
    @Autowired PostFinder postFinder;
    @Autowired EntityManager entityManager;
    Member member;
    Post post;

    @BeforeEach
    void setUp() {
        member = memberRegister.register(MemberFixture.createRegisterRequest());
        post = postModify.publishPost(member.getEmail().address(), getPublishBlogpostFormRequest());
        entityManager.flush();
        entityManager.clear();
    }

    @Test
    void publishPost() {
        assertThat(post.getId()).isNotNull();
    }

    @Test
    void publishPostFail() {
        PublishBlogpostFormRequest request = new PublishBlogpostFormRequest(null, null, "This is content");

        assertThatThrownBy(()->postModify.publishPost(member.getEmail().address(), request)).
                isInstanceOf(ConstraintViolationException.class);
    }

    @Test
    void editPost() {
        postModify.editPost(member.getEmail().address(),  new PublishBlogpostFormRequest(post.getId(), "new title", "new content"));
        entityManager.flush();
        entityManager.clear();

        assertThat(postFinder.getPost(post.getId()).getContent()).isEqualTo("new content");
    }

    @Test
    void editPostFail() {
        Post post = postModify.publishPost(member.getEmail().address(), getPublishBlogpostFormRequest());

        assertThatThrownBy(()->postModify.editPost(member.getEmail().address(),  new PublishBlogpostFormRequest(post.getId(), null, "new content")))
                .isInstanceOf(ConstraintViolationException.class);
    }

    @Test
    void deletePost() {
        Authentication auth = mock(Authentication.class);
        given(auth.getName()).willReturn(member.getEmail().address());
        postModify.deletePost(post.getId(), auth);
        entityManager.flush();
        entityManager.clear();

        assertThatThrownBy(()->postFinder.getPost(post.getId()))
                .isInstanceOf(PostNotFoundException.class);
    }
}

 

deletePost를 테스트할 때 사용자의 인증이 필요한데 그냥 Mock으로 처리했다.

@SpringBootTest
@Transactional
@Import(AnthiveTestConfiguration.class)
class PostFinderTest {
    @Autowired MemberRegister memberRegister;
    @Autowired PostModify postModify;
    @Autowired PostFinder postFinder;
    @Autowired EntityManager entityManager;
    Member member;
    Post post;

    @BeforeEach
    void setUp() {
        member = memberRegister.register(MemberFixture.createRegisterRequest());
        post = postModify.publishPost(member.getEmail().address(), getPublishBlogpostFormRequest());

        entityManager.flush();
        entityManager.clear();
    }

    @Test
    void getUsersPosts_success() {
        // given
        Pageable pageable = PageRequest.of(0, 10);

        // when
        Page<Post> result = postFinder.getUsersPosts(member.getEmail().address(), pageable);

        // then
        assertThat(result.getTotalElements()).isEqualTo(1);
    }

    @Test
    void getUsersPosts_memberNotFound() {
        // given
        String username = "unknown@test.com";
        Pageable pageable = PageRequest.of(0, 10);

        // when - then
        assertThrows(UsernameNotFoundException.class,
                () -> postFinder.getUsersPosts(username, pageable));
    }

    @Test
    void getPost_success() {
        // when
        Post result = postFinder.getPost(post.getId());

        // then
        assertThat(result.getId()).isEqualTo(post.getId());
    }

    @Test
    void getPost_notFound() {
        assertThrows(PostNotFoundException.class,
                () -> postFinder.getPost(1L));
    }
}

 

첫번째 테스트에서 DB에 게시글이 저장되고 다시 삭제되지만 세번째 테스트에서 다시 게시글을 작성하면 id는 2부터 시작한다. 이건 DB를 다뤄봤다면 알것이다. 데이터를 삭제한다고 해서 해당 id부터 다시 채워지진 않는다는 거.

 

@SpringBootTest
class PostPermissionMockTest {

    @MockitoBean
    private MemberRepository memberRepository; // 실제 객체 사용 X

    @MockitoBean
    private PostRepository postRepository; // 실제 객체 사용 X

    @Autowired
    private PostPermission postPermission; // 구현체는 PostService

    @Test
    void author_can_delete_post() {
        // given
        Long postId = 1L;

        Member mockMember = Mockito.mock(Member.class);
        Mockito.when(mockMember.getEmail()).thenReturn(new Email("user@test.com"));

        Post mockPost = Mockito.mock(Post.class);
        Mockito.when(mockPost.getMember()).thenReturn(mockMember);

        // PostRepository 동작 모킹
        Mockito.when(postRepository.findById(postId))
                .thenReturn(Optional.of(mockPost));

        // 인증 객체 모킹
        Authentication auth = Mockito.mock(Authentication.class);
        Mockito.when(auth.getName()).thenReturn("user@test.com");

        // when & then — 예외 없어야 정상
        assertDoesNotThrow(() ->
                postPermission.checkAuthorPermission(postId, auth)
        );
    }

    @Test
    void non_author_cannot_delete_post() {
        // given
        Long postId = 1L;

        Member mockAuthor = Mockito.mock(Member.class);
        Mockito.when(mockAuthor.getEmail()).thenReturn(new Email("author@test.com"));

        Post mockPost = Mockito.mock(Post.class);
        Mockito.when(mockPost.getMember()).thenReturn(mockAuthor);

        // PostRepository 동작 모킹
        Mockito.when(postRepository.findById(postId))
                .thenReturn(Optional.of(mockPost));

        Authentication auth = Mockito.mock(Authentication.class);
        Mockito.when(auth.getName()).thenReturn("other@test.com");

        // when & then — SecurityException 발생해야 함
        assertThrows(SecurityException.class, () ->
                postPermission.checkAuthorPermission(postId, auth)
        );
    }
}

마지막으로 Permission을 검사하는 테스트는 실제로 DB까지 가지 않고 그냥 리파지토리의 함수를 모킹했다.

이전에 쓰던 @MockBean 이 @MockitoBean으로 변경되었다.

@ExtendWith(MockitoExtension.class)
class PostPermissionUnitTest {

    @InjectMocks
    @Spy
    private PostService postService;   // <— Spy로 만들어야 내부 메서드 mocking 가능

    @Test
    void mock_internal_getPost() {
        // given
        Post mockPost = Mockito.mock(Post.class);
        Member mockMember = Mockito.mock(Member.class);
        when(mockMember.getEmail()).thenReturn(new Email("author@test.com"));
        when(mockPost.getMember()).thenReturn(mockMember);

        // ★ 핵심: getPost 내부 호출을 stub
        doReturn(mockPost).when(postService).getPost(1L);

        Authentication auth = Mockito.mock(Authentication.class);
        when(auth.getName()).thenReturn("author@test.com");

        // when & then
        assertDoesNotThrow(() ->
                postService.checkAuthorPermission(1L, auth)
        );
    }
}

혹은 이런 식으로 내부 메서드를 모킹하는것도 가능하다. 다만 인터페이스가 아닌 구현체를 직접 테스트해서 별로 마음에 들지는 않는다.