Java 개발자를 위한 Kotlin (4/4)

이전 편: [Kotlin] 3. Spring Boot에서의 Kotlin (코프링)

이 문서가 시리즈의 마지막 편입니다.

Java 개발자를 위한 Kotlin 시리즈

이전 문서: Spring Boot에서의 Kotlin

이 문서에서는 Kotlin + Spring Boot 프로젝트에서의 테스트 작성법을 다룬다.

테스트 전략

개인 블로그 프로젝트에서 효율적인 테스트 전략이다.

레이어테스트 방식우선순위
Controller@WebMvcTest + MockMvc높음
Service단위 테스트 (Mockito)높음
Repository@DataJpaTest보통
통합 테스트@SpringBootTest필요할 때만

모든 걸 테스트하려고 하지 말고, Controller + Service 위주로 작성하면 충분하다. 개인 프로젝트니까.

기본 설정

테스트 의존성

// build.gradle.kts
dependencies {
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.security:spring-security-test")
    testImplementation("io.mockk:mockk:1.13.16")                   // Kotlin 친화 Mock
    testImplementation("com.ninja-squad:springmockk:4.0.2")         // Spring + MockK 통합
}

Mockito vs MockK

Java에서는 Mockito를 쓰지만, Kotlin에서는 MockK가 더 자연스럽다.

MockitoMockK
when(...).thenReturn(...)every { ... } returns ...
verify(mock).method()verify { mock.method() }
@Mock@MockkBean (springmockk)
final 클래스 mock 불가final 클래스 mock 가능

Kotlin 클래스는 기본이 final이다. Mockito는 final 클래스를 mock하려면 별도 설정이 필요하지만, MockK는 기본으로 된다.

Mockito를 써도 상관없다

kotlin-spring 플러그인이 Spring 관련 클래스를 open으로 만들어주니까, Service 테스트에서 Mockito를 써도 대부분 동작한다. 팀 프로젝트에서 Java 개발자와 협업한다면 Mockito가 나을 수도 있다. 개인 프로젝트니까 MockK로 가자.

Service 단위 테스트

class PostServiceTest {

    private val postRepository = mockk<PostRepository>()
    private val categoryRepository = mockk<CategoryRepository>()
    private val tagRepository = mockk<TagRepository>()

    private val postService = PostService(
        postRepository = postRepository,
        categoryRepository = categoryRepository,
        tagRepository = tagRepository
    )

    @Test
    fun `글 작성 시 ID를 반환한다`() {
        // given
        val request = PostCreateRequest(
            title = "테스트 글",
            content = "<p>내용</p>",
            isPublished = true
        )

        every { postRepository.save(any()) } answers {
            firstArg<Post>().apply {
                // id를 리플렉션으로 설정 (테스트용)
            }
        }

        // when
        val result = postService.createPost(request)

        // then
        verify { postRepository.save(any()) }
    }

    @Test
    fun `미발행 글은 목록에 나오지 않는다`() {
        // given
        val pageable = PageRequest.of(0, 10)
        every {
            postRepository.findByIsPublishedTrue(pageable)
        } returns PageImpl(emptyList())

        // when
        val result = postService.getPosts(0, 10, null)

        // then
        assert(result.content.isEmpty())
        verify { postRepository.findByIsPublishedTrue(pageable) }
    }

    @Test
    fun `존재하지 않는 카테고리로 글 작성 시 예외 발생`() {
        // given
        val request = PostCreateRequest(
            title = "테스트",
            content = "내용",
            categoryId = 999L
        )
        every { categoryRepository.findByIdOrNull(999L) } returns null

        // when & then
        assertThrows<BusinessException> {
            postService.createPost(request)
        }
    }
}

Kotlin 테스트의 특징을 짚으면 이렇다.

  • 메서드 이름을 백틱으로 감싸면 한글을 포함한 자유로운 이름을 쓸 수 있다. fun \글 작성 시 ID를 반환한다\() 같이.
  • MockK의 every { } returns 문법이 Mockito의 when().thenReturn()보다 읽기 좋다.
  • verify { }로 호출 검증도 간결하다.

Controller 슬라이스 테스트

@WebMvcTest(PostController::class)
class PostControllerTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockkBean
    private lateinit var postService: PostService

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @Test
    fun `글 목록 조회 성공`() {
        // given
        val posts = PageImpl(
            listOf(
                PostListResponse(
                    id = 1,
                    title = "테스트 글",
                    thumbnail = null,
                    category = null,
                    tags = listOf("Spring"),
                    viewCount = 0,
                    createdAt = LocalDateTime.now()
                )
            )
        )
        every { postService.getPosts(0, 10, null) } returns posts

        // when & then
        mockMvc.get("/api/posts") {
            param("page", "0")
            param("size", "10")
        }.andExpect {
            status { isOk() }
            jsonPath("$.content[0].title") { value("테스트 글") }
            jsonPath("$.content[0].tags[0]") { value("Spring") }
        }
    }

    @Test
    fun `글 작성 - 인증 없으면 401`() {
        mockMvc.post("/api/posts") {
            contentType = MediaType.APPLICATION_JSON
            content = objectMapper.writeValueAsString(
                PostCreateRequest(title = "제목", content = "내용")
            )
        }.andExpect {
            status { isUnauthorized() }
        }
    }

    @Test
    @WithMockUser    // 인증된 사용자로 테스트
    fun `글 작성 - 제목 없으면 400`() {
        mockMvc.post("/api/posts") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"title": "", "content": "내용"}"""
        }.andExpect {
            status { isBadRequest() }
        }
    }
}

Spring의 MockMvc Kotlin DSL 덕분에 .andExpect { } 안에서 깔끔하게 검증할 수 있다.

@WebMvcTest는 Security 포함

@WebMvcTest는 Spring Security 설정도 포함한다. 인증이 필요한 API를 테스트할 때 @WithMockUser를 붙이거나, Security 설정을 커스텀해야 한다.

Repository 테스트

@DataJpaTest
class PostRepositoryTest {

    @Autowired
    private lateinit var postRepository: PostRepository

    @Autowired
    private lateinit var categoryRepository: CategoryRepository

    @Test
    fun `발행된 글만 조회된다`() {
        // given
        postRepository.save(Post(title = "발행됨", content = "내용", isPublished = true))
        postRepository.save(Post(title = "임시저장", content = "내용", isPublished = false))

        // when
        val pageable = PageRequest.of(0, 10)
        val result = postRepository.findByIsPublishedTrue(pageable)

        // then
        assert(result.content.size == 1)
        assert(result.content[0].title == "발행됨")
    }

    @Test
    fun `카테고리별 글 조회`() {
        // given
        val category = categoryRepository.save(Category(name = "개발"))
        postRepository.save(
            Post(title = "개발 글", content = "내용", category = category, isPublished = true)
        )
        postRepository.save(
            Post(title = "다른 글", content = "내용", isPublished = true)
        )

        // when
        val pageable = PageRequest.of(0, 10)
        val result = postRepository.findByCategory_IdAndIsPublishedTrue(category.id, pageable)

        // then
        assert(result.content.size == 1)
        assert(result.content[0].title == "개발 글")
    }
}

@DataJpaTest는 내장 H2를 사용한다. PostgreSQL 전용 쿼리를 테스트하려면 Testcontainers가 필요하지만, 블로그 수준에서는 H2면 충분하다.

통합 테스트

전체 흐름을 검증할 때 쓴다. 자주 쓸 필요는 없고, 핵심 시나리오만.

@SpringBootTest
@AutoConfigureMockMvc
class PostIntegrationTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var postRepository: PostRepository

    @Test
    @WithMockUser(username = "admin")
    fun `글 작성부터 조회까지 전체 흐름`() {
        // 글 작성
        val createResult = mockMvc.post("/api/posts") {
            contentType = MediaType.APPLICATION_JSON
            content = """
                {
                    "title": "통합 테스트 글",
                    "content": "<p>내용</p>",
                    "isPublished": true
                }
            """.trimIndent()
        }.andExpect {
            status { isCreated() }
        }.andReturn()

        // 글 목록에서 확인
        mockMvc.get("/api/posts").andExpect {
            status { isOk() }
            jsonPath("$.content[0].title") { value("통합 테스트 글") }
        }
    }
}

테스트 팁

given-when-then 주석

@Test
fun `댓글 작성 시 대댓글 깊이 제한`() {
    // given — 준비
    val comment = createComment(parentId = null)

    // when — 실행
    val result = commentService.create(
        CommentCreateRequest(parentId = comment.id, ...)
    )

    // then — 검증
    assertNotNull(result)
}

모든 테스트를 이 패턴으로 쓰면 읽기 좋다.

테스트 픽스처

테스트마다 객체를 반복 생성하면 귀찮다. 헬퍼 함수를 만들어두면 편하다.

// 테스트 유틸
fun createPost(
    title: String = "테스트 글",
    content: String = "<p>내용</p>",
    isPublished: Boolean = true,
    category: Category? = null
) = Post(
    title = title,
    content = content,
    isPublished = isPublished,
    category = category
)

fun createComment(
    nickname: String = "테스터",
    password: String = "1234",
    content: String = "댓글 내용",
    parentId: Long? = null
) = CommentCreateRequest(
    nickname = nickname,
    password = password,
    content = content,
    parentId = parentId
)

Kotlin의 기본값 파라미터 덕분에 Java의 Builder 패턴 없이도 유연하게 객체를 만들 수 있다.

@Transactional 테스트

@SpringBootTest
@Transactional    // 각 테스트 후 롤백
class PostServiceIntegrationTest {
    // DB 변경이 테스트 간 영향을 주지 않는다
}
@Transactional 테스트의 함정

테스트에서 @Transactional을 걸면 lazy loading이 항상 동작해서, 실제 서비스에서 LazyInitializationException이 나는 걸 못 잡을 수 있다. 중요한 시나리오는 @Transactional 없이 테스트하는 것도 고려하자.

테스트 파일 구조

src/test/kotlin/com/blog/
├── domain/
│   ├── post/
│   │   ├── PostControllerTest.kt
│   │   ├── PostServiceTest.kt
│   │   └── PostRepositoryTest.kt
│   ├── comment/
│   │   ├── CommentControllerTest.kt
│   │   └── CommentServiceTest.kt
│   └── category/
│       └── CategoryServiceTest.kt
├── auth/
│   └── AuthControllerTest.kt
├── support/
│   └── TestFixtures.kt          // 테스트 헬퍼
└── integration/
    └── PostIntegrationTest.kt   // 통합 테스트

프로덕션 코드 구조를 미러링하면 찾기 쉽다.


면접 Q&A

Q. 왜 MockK를 Mockito 대신 사용하나요?

Kotlin 클래스는 기본이 final이라 Mockito로는 mock이 안 됩니다. kotlin-spring 플러그인이 Spring 클래스를 open으로 만들어주지만, 일반 클래스는 여전히 final입니다. MockK는 final 클래스도 mock할 수 있고, 문법도 Kotlin에 자연스럽습니다.

Q. @DataJpaTest와 @SpringBootTest의 차이는?

@DataJpaTest는 JPA 관련 빈만 로드해서 가볍고 빠릅니다. Repository 테스트에 적합합니다. @SpringBootTest는 전체 컨텍스트를 올려서 무겁지만 실제 환경과 동일한 조건에서 테스트할 수 있습니다.

Q. 테스트에서 @Transactional을 쓰면 안 되는 경우는?

테스트의 @Transactional이 영속성 컨텍스트를 열어두기 때문에, 실제 서비스에서는 트랜잭션이 끝나 LazyInitializationException이 발생하는 케이스를 놓칠 수 있습니다. 중요한 통합 테스트에서는 @Transactional 없이 검증하는 것이 좋습니다.