이전 문서: 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가 더 자연스럽다.
| Mockito | MockK |
|---|---|
when(...).thenReturn(...) | every { ... } returns ... |
verify(mock).method() | verify { mock.method() } |
@Mock | @MockkBean (springmockk) |
| final 클래스 mock 불가 | final 클래스 mock 가능 |
Kotlin 클래스는 기본이 final이다. Mockito는 final 클래스를 mock하려면 별도 설정이 필요하지만, MockK는 기본으로 된다.
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. 왜 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 없이 검증하는 것이 좋습니다.