diff --git a/build.gradle.kts b/build.gradle.kts index cc48c209..fc41345c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ group = "gomushin" version = "0.0.1-SNAPSHOT" val mockkVersion = "1.13.10" +val kotestVersion = "5.5.4" java { toolchain { @@ -90,6 +91,8 @@ dependencies { testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("io.mockk:mockk:${mockkVersion}") + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/main/kotlin/gomushin/backend/core/common/support/SpringContextHolder.kt b/src/main/kotlin/gomushin/backend/core/common/support/SpringContextHolder.kt index 5ac49f39..d63d9f63 100644 --- a/src/main/kotlin/gomushin/backend/core/common/support/SpringContextHolder.kt +++ b/src/main/kotlin/gomushin/backend/core/common/support/SpringContextHolder.kt @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component @Component object SpringContextHolder : ApplicationContextAware { - private lateinit var context: ApplicationContext + lateinit var context: ApplicationContext override fun setApplicationContext(applicationContext: ApplicationContext) { context = applicationContext diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/service/CommentService.kt b/src/main/kotlin/gomushin/backend/schedule/domain/service/CommentService.kt index 5465ec5e..dacb6bc4 100644 --- a/src/main/kotlin/gomushin/backend/schedule/domain/service/CommentService.kt +++ b/src/main/kotlin/gomushin/backend/schedule/domain/service/CommentService.kt @@ -39,8 +39,8 @@ class CommentService( } @Transactional(readOnly = true) - fun findAllByLetter(letter: Letter): List { - return commentRepository.findAllByLetterId(letter.id) + fun findAllByLetterId(id: Long): List { + return commentRepository.findAllByLetterId(id) } @Transactional(readOnly = true) diff --git a/src/main/kotlin/gomushin/backend/schedule/facade/ReadLetterFacade.kt b/src/main/kotlin/gomushin/backend/schedule/facade/ReadLetterFacade.kt index 8188ebcc..8af03208 100644 --- a/src/main/kotlin/gomushin/backend/schedule/facade/ReadLetterFacade.kt +++ b/src/main/kotlin/gomushin/backend/schedule/facade/ReadLetterFacade.kt @@ -60,7 +60,7 @@ class ReadLetterFacade( PictureResponse.of(picture) } - val comments = commentService.findAllByLetter(letter) + val comments = commentService.findAllByLetterId(letter.id) val commentResponses = comments.map { comment -> CommentResponse.of(comment) } diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/facade/UpsertAndDeleteCommentFacadeTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/facade/UpsertAndDeleteCommentFacadeTest.kt index 14334823..f4b5bb96 100644 --- a/src/test/kotlin/gomushin/backend/schedule/domain/facade/UpsertAndDeleteCommentFacadeTest.kt +++ b/src/test/kotlin/gomushin/backend/schedule/domain/facade/UpsertAndDeleteCommentFacadeTest.kt @@ -1,6 +1,9 @@ package gomushin.backend.schedule.domain.facade import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.support.SpringContextHolder +import gomushin.backend.core.configuration.env.AppEnv +import gomushin.backend.core.infrastructure.exception.BadRequestException import gomushin.backend.member.domain.entity.Member import gomushin.backend.member.domain.service.MemberService import gomushin.backend.schedule.domain.entity.Comment @@ -9,90 +12,165 @@ import gomushin.backend.schedule.domain.service.CommentService import gomushin.backend.schedule.domain.service.LetterService import gomushin.backend.schedule.dto.request.UpsertCommentRequest import gomushin.backend.schedule.facade.UpsertAndDeleteCommentFacade +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.InjectMocks -import org.mockito.Mock -import org.mockito.Mockito.* -import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.context.ApplicationContext import kotlin.test.Test -@ExtendWith(MockitoExtension::class) +@ExtendWith(MockKExtension::class) class UpsertAndDeleteCommentFacadeTest { - @Mock + @MockK private lateinit var commentService: CommentService - @Mock + @MockK private lateinit var letterService: LetterService - @Mock + @MockK private lateinit var memberService: MemberService - @InjectMocks + @MockK(relaxed = true) + private lateinit var mockAppEnv: AppEnv + + @MockK + private lateinit var mockApplicationContext: ApplicationContext + + @InjectMockKs private lateinit var upsertAndDeleteCommentFacade: UpsertAndDeleteCommentFacade + @BeforeEach + fun setup() { + SpringContextHolder.context = mockApplicationContext + every { mockApplicationContext.getBean(AppEnv::class.java) } returns mockAppEnv + every { mockAppEnv.getId() } returns "test-env" + } + + @DisplayName("댓글 생성 또는 수정 성공") @Test fun upsert_success() { // given - val customUserDetails = mock(CustomUserDetails::class.java) + val customUserDetails = mockk() val letterId = 1L - val memberId = 1L - val upsertCommentRequest = UpsertCommentRequest( - commentId = null, - content = "댓글 내용" - ) - - val mockMember = mock(Member::class.java).apply { - `when`(id).thenReturn(memberId) - `when`(nickname).thenReturn("테스트유저") - } - val mockLetter = mock(Letter::class.java).apply { - `when`(id).thenReturn(letterId) - } - - `when`(customUserDetails.getId()).thenReturn(memberId) - `when`(memberService.getById(memberId)).thenReturn(mockMember) - `when`(letterService.getById(letterId)).thenReturn(mockLetter) + val upsertCommentRequest = mockk() + val member = mockk() + val letter = mockk() + every { customUserDetails.getId() } returns 1L + every { memberService.getById(any()) } returns member + every { letterService.getById(any()) } returns letter + every { upsertCommentRequest.commentId } returns 1L + every { letter.id } returns letterId + every { member.id } returns 1L + every { member.nickname } returns "닉네임" + every { commentService.upsert(any(), any(), any(), any(), any()) } returns Unit // when upsertAndDeleteCommentFacade.upsert(customUserDetails, letterId, upsertCommentRequest) // then - verify(commentService, times(1)).upsert( - id = upsertCommentRequest.commentId, - letterId = mockLetter.id, - authorId = mockMember.id, - nickname = "테스트유저", - upsertCommentRequest = upsertCommentRequest - ) + verify { memberService.getById(1L) } + verify { letterService.getById(1L) } + verify { commentService.upsert(1L, 1L, 1L, "닉네임", upsertCommentRequest) } } - @DisplayName("댓글 삭제 성공") - @Test - fun delete_success() { - // given - val customUserDetails = mock(CustomUserDetails::class.java) - val letterId = 1L - val commentId = 1L - val mockMember = mock(Member::class.java) - val mockComment = mock(Comment::class.java) - // when - `when`(customUserDetails.getId()).thenReturn(1L) - `when`(memberService.getById(customUserDetails.getId())).thenReturn(mockMember) - `when`(commentService.getById(commentId)).thenReturn(mockComment) - `when`(mockMember.id).thenReturn(1L) - `when`(mockComment.authorId).thenReturn(1L) - `when`(mockComment.letterId).thenReturn(letterId) - `when`(mockComment.letterId).thenReturn(letterId) + @Nested + inner class DeleteTest { + @DisplayName("댓글 삭제 성공") + @Test + fun delete_success() { + // given + val customUserDetails = mockk() + val letterId = 1L + val memberId = 1L + val commentId = 1L + val authorId = 1L + val member = mockk() + val comment = mockk() - upsertAndDeleteCommentFacade.delete(customUserDetails, letterId, commentId) + every { customUserDetails.getId() } returns 1L + every { memberService.getById(any()) } returns member + every { commentService.getById(commentId) } returns comment + every { comment.authorId } returns authorId + every { member.id } returns memberId + every { comment.letterId } returns letterId + every { commentService.delete(commentId) } returns Unit - // then - verify(memberService, times(1)).getById(customUserDetails.getId()) - verify(commentService, times(1)).getById(commentId) - verify(commentService, times(1)).delete(commentId) + // when + upsertAndDeleteCommentFacade.delete(customUserDetails, letterId, commentId) + + // then + verify { memberService.getById(1L) } + verify { commentService.getById(commentId) } + verify { commentService.delete(commentId) } + } + + @DisplayName("댓글 삭제 시 comment 작성자 ID와 Member ID가 다를 경우, 에러 발생") + @Test + fun delete_shouldThrowBadRequestException_When_Comment_AuthorId_is_Not_MemberId() { + // given + val customUserDetails = mockk() + val letterId = 1L + val memberId = 2L + val commentId = 1L + val authorId = 1L + val member = mockk() + val comment = mockk() + + every { customUserDetails.getId() } returns 1L + every { memberService.getById(any()) } returns member + every { commentService.getById(commentId) } returns comment + every { comment.authorId } returns authorId + every { member.id } returns memberId + every { comment.letterId } returns letterId + every { commentService.delete(commentId) } returns Unit + + // when + val exception = shouldThrow { + upsertAndDeleteCommentFacade.delete(customUserDetails, letterId, commentId) + } + + // then + exception.error.element.message.resolved shouldBe "댓글은 작성자만 삭제하거나 업데이트 할 수 있어요." + } + + @DisplayName("댓글의 letterId와 입력으로 받은 letterId가 다른 경우, 예외를 발생시킨다.") + @Test + fun delete_shouldThrowBadRequestException_when_commentLetterId_and_letterId_not_match() { + // given + val customUserDetails = mockk() + val letterId = 1L + val memberId = 1L + val commentId = 1L + val authorId = 1L + val member = mockk() + val comment = mockk() + + every { customUserDetails.getId() } returns 1L + every { memberService.getById(any()) } returns member + every { commentService.getById(commentId) } returns comment + every { comment.authorId } returns authorId + every { member.id } returns memberId + every { comment.letterId } returns 2L + every { commentService.delete(commentId) } returns Unit + + // when + val exception = shouldThrow { + upsertAndDeleteCommentFacade.delete(customUserDetails, letterId, commentId) + } + + // then + exception.error.element.message.resolved shouldBe "편지에 해당하는 댓글이 아니에요." + } } } diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/service/CommentServiceTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/service/CommentServiceTest.kt index b10d7b86..86fc2537 100644 --- a/src/test/kotlin/gomushin/backend/schedule/domain/service/CommentServiceTest.kt +++ b/src/test/kotlin/gomushin/backend/schedule/domain/service/CommentServiceTest.kt @@ -1,74 +1,217 @@ package gomushin.backend.schedule.domain.service +import gomushin.backend.core.common.support.SpringContextHolder +import gomushin.backend.core.configuration.env.AppEnv +import gomushin.backend.core.infrastructure.exception.BadRequestException import gomushin.backend.schedule.domain.entity.Comment import gomushin.backend.schedule.domain.repository.CommentRepository import gomushin.backend.schedule.dto.request.UpsertCommentRequest +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.InjectMocks -import org.mockito.Mock -import org.mockito.Mockito.* -import org.mockito.junit.jupiter.MockitoExtension -import java.util.* +import org.springframework.context.ApplicationContext +import org.springframework.data.repository.findByIdOrNull import kotlin.test.Test -@ExtendWith(MockitoExtension::class) +@ExtendWith(MockKExtension::class) class CommentServiceTest { - @Mock - lateinit var commentRepository: CommentRepository + @MockK + private lateinit var commentRepository: CommentRepository - @InjectMocks - lateinit var commentService: CommentService + @MockK(relaxed = true) + private lateinit var mockAppEnv: AppEnv + + @MockK + private lateinit var mockApplicationContext: ApplicationContext + + @InjectMockKs + private lateinit var commentService: CommentService + + @BeforeEach + fun setup() { + SpringContextHolder.context = mockApplicationContext + every { mockApplicationContext.getBean(AppEnv::class.java) } returns mockAppEnv + every { mockAppEnv.getId() } returns "test-env" + } @Nested - inner class Upsert { + inner class UpsertTest { + @DisplayName("id가 입력으로 들어오지 않는 경우, 댓글을 생성한다.") + @Test + fun upsert_shouldCreateComment_When_ParameterId_NotExists() { + // given + val letterId = 1L + val authorId = 1L + val nickname = "닉네임" + val upsertCommentRequest = mockk() + val comment = mockk() + every { upsertCommentRequest.content } returns "훈련 힘내" + every { upsertCommentRequest.commentId } returns 1L + every { commentRepository.save(any()) } returns comment - @DisplayName("업데이트 성공 - 댓글이 존재할 때") + // when + commentService.upsert(null, letterId, authorId, nickname, upsertCommentRequest) + + // then + verify { commentRepository.save(any()) } + } + + @DisplayName("id로 찾은 댓글의 작성자와 수정하려는 authorId가 다른 경우, 에러를 반환한다.") @Test - fun upload_success() { + fun upsert_shouldThrowException_When_CommentAuthorId_And_AuthorId_Not_Matched() { // given val id = 1L val letterId = 1L val authorId = 1L val nickname = "닉네임" - val upsertCommentRequest = UpsertCommentRequest( - commentId = 1L, - content = "내용" - ) - val mockComment = mock(Comment::class.java).apply { - `when`(this.authorId).thenReturn(authorId) + val upsertCommentRequest = mockk() + val comment = mockk() + every { commentRepository.findByIdOrNull(1L) } returns comment + every { upsertCommentRequest.content } returns "훈련 힘내" + every { upsertCommentRequest.commentId } returns 1L + every { comment.authorId } returns 2L + + // when + val exception = shouldThrow { + commentService.upsert(id, letterId, authorId, nickname, upsertCommentRequest) } + // then + exception.error.element.message.resolved shouldBe "댓글은 작성자만 삭제하거나 업데이트 할 수 있어요." + } + } + + @Nested + inner class ReadTest { + @DisplayName("존재하지 않는 댓글 ID로 조회 시 BadRequestException 발생") + @Test + fun getById_shouldThrowBadRequestException_When_NotExistId() { + every { commentRepository.findByIdOrNull(any()) } returns null + + val exception = shouldThrow { + commentService.getById(999L) + } + + exception.error.element.message.resolved shouldBe "댓글을 찾을 수 없어요." + } + + @DisplayName("존재하는 댓글 ID로 조회 시 댓글 객체 반환") + @Test + fun getById_success() { + // given + val id = 1L + val comment = mockk() + every { commentRepository.findByIdOrNull(id) } returns comment + // when + val result = commentService.getById(id) + + // then + result shouldBe comment + verify { commentRepository.findByIdOrNull(id) } + verify { commentService.findById(id) } + } + + @DisplayName("findById 호출 시 존재하지 않는 ID로 조회 시 null 반환") + @Test + fun findById_shouldReturnNull_When_NotExistId() { + // given + val id = 999L + every { commentRepository.findByIdOrNull(id) } returns null + // when - `when`(commentRepository.findById(id)).thenReturn(Optional.of(mockComment)) - commentService.upsert(id, letterId, authorId, nickname, upsertCommentRequest) + val result = commentService.findById(id) // then - verify(mockComment).content = upsertCommentRequest.content + result shouldBe null + verify { commentRepository.findByIdOrNull(id) } } - @DisplayName("댓글 생성 성공") + @DisplayName("findById 호출 시 존재하는 ID로 조회 시 댓글 객체 반환") @Test - fun insert_success() { + fun findById_shouldReturnComment_When_ExistId() { + // given + val id = 1L + val comment = mockk() + every { commentRepository.findByIdOrNull(id) } returns comment + + // when + val result = commentService.findById(id) + + // then + result shouldBe comment + verify { commentRepository.findByIdOrNull(id) } + } + + @DisplayName("findAllByLetterId 호출 시 댓글 리스트 반환") + @Test + fun findAllByLetterId_success() { // given val letterId = 1L - val authorId = 1L - val nickname = "닉네임" - val upsertCommentRequest = UpsertCommentRequest( - commentId = null, - content = "내용" - ) - val mockComment = mock(Comment::class.java) + val comments = listOf(mockk(), mockk()) + every { commentRepository.findAllByLetterId(letterId) } returns comments // when - `when`(commentRepository.save(any())).thenReturn(mockComment) - commentService.upsert(null, letterId, authorId, nickname, upsertCommentRequest) + val result = commentService.findAllByLetterId(letterId) // then - verify(commentRepository, times(1)).save(org.mockito.kotlin.any()) + result shouldBe comments + verify { commentRepository.findAllByLetterId(letterId) } } + } + @DisplayName("save 호출 시 댓글 저장 후 반환") + @Test + fun save_shouldReturnSavedComment() { + // given + val comment = mockk() + every { commentRepository.save(comment) } returns comment + // when + val result = commentService.save(comment) + + // then + result shouldBe comment + verify { commentRepository.save(comment) } } + + @Nested + inner class DeleteTest { + @DisplayName("delete 호출 시 댓글 삭제") + @Test + fun delete_shouldDeleteComment() { + // given + val id = 1L + every { commentRepository.deleteById(id) } returns Unit + + // when + commentService.delete(id) + + // then + verify { commentRepository.deleteById(id) } + } + + @DisplayName("deleteAllByLetterId 호출 시 commentRepository.deleteAllByLetterId 가 1회 호출된다.") + @Test + fun deleteAllByLetterId_shouldCallDeleteAllByLetterId() { + // given + val letterId = 1L + every { commentRepository.deleteAllByLetterId(any()) } returns Unit + + // when + commentService.deleteAllByLetterId(letterId) + + // then + verify { commentRepository.deleteAllByLetterId(letterId) } + } + } + } diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/service/ScheduleServiceTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/service/ScheduleServiceTest.kt index 3bb353bc..82c80c5f 100644 --- a/src/test/kotlin/gomushin/backend/schedule/domain/service/ScheduleServiceTest.kt +++ b/src/test/kotlin/gomushin/backend/schedule/domain/service/ScheduleServiceTest.kt @@ -6,14 +6,24 @@ import gomushin.backend.core.infrastructure.exception.BadRequestException import gomushin.backend.schedule.domain.entity.Schedule import gomushin.backend.schedule.domain.repository.ScheduleRepository import gomushin.backend.schedule.dto.request.UpsertScheduleRequest -import io.mockk.* +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension -import org.junit.jupiter.api.* +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.context.ApplicationContext import org.springframework.data.repository.findByIdOrNull import java.time.LocalDateTime -import kotlin.test.assertEquals + @ExtendWith(MockKExtension::class) class ScheduleServiceTest { @@ -21,22 +31,21 @@ class ScheduleServiceTest { @MockK lateinit var scheduleRepository: ScheduleRepository - private lateinit var scheduleService: ScheduleService + @MockK(relaxed = true) + private lateinit var mockAppEnv: AppEnv + + @MockK + private lateinit var mockApplicationContext: ApplicationContext + + + @InjectMockKs + lateinit var scheduleService: ScheduleService @BeforeEach - fun setUp() { - // 확장함수 모킹 - mockkStatic("org.springframework.data.repository.CrudRepositoryExtensionsKt") - - // 서비스 인스턴스 직접 생성 - // 확장함수를 사용하는 경우, @InjectMockks가 아닌 직접 생성해야 함 - scheduleService = ScheduleService(scheduleRepository) - - // SpringContextHolder 모킹 - mockkObject(SpringContextHolder) - val mockAppEnv = mockk() - every { SpringContextHolder.getBean(AppEnv::class.java) } returns mockAppEnv - every { mockAppEnv.getId() } returns "test" + fun setup() { + SpringContextHolder.context = mockApplicationContext + every { mockApplicationContext.getBean(AppEnv::class.java) } returns mockAppEnv + every { mockAppEnv.getId() } returns "test-env" } @Nested @@ -102,22 +111,17 @@ class ScheduleServiceTest { @Nested inner class ReadTest { - @DisplayName("getById() 경우, 존재하지 않는 id로 조회 시 BadRequestException 발생") + @DisplayName("존재하지 않는 ID로 검색 시 예외 발생") @Test fun getById_notExistId_throwBadRequestException() { // given - val id = 1L - every { - scheduleRepository.findByIdOrNull(id) - } returns null + every { scheduleRepository.findByIdOrNull(any()) } returns null // when & then - val exception = assertThrows { - scheduleService.getById(id) + val exception = shouldThrow { + scheduleService.getById(1L) } - val errorMessage = exception.error.element.message.resolved - - assertEquals("해당 일정이 존재하지 않아요.", errorMessage) + exception.error.element.message.resolved shouldBe "해당 일정이 존재하지 않아요." } } @@ -150,6 +154,5 @@ class ScheduleServiceTest { // then verify { scheduleRepository.deleteById(scheduleId) } } - } }