kotest-behaviorspec

star 0

Kotest BehaviorSpec 기반 테스트 작성 가이드. Spring Boot 통합 테스트, E2E 테스트, Repository 테스트 패턴. 테스트 작성/수정 시 자동 활성화. Kotest, BehaviorSpec, 테스트, test 언급 시 트리거.

spandios By spandios schedule Updated 3/6/2026

name: kotest-behaviorspec description: Kotest BehaviorSpec 기반 테스트 작성 가이드. Spring Boot 통합 테스트, E2E 테스트, Repository 테스트 패턴. 테스트 작성/수정 시 자동 활성화. Kotest, BehaviorSpec, 테스트, test 언급 시 트리거. allowed-tools: Read, Write, Grep, Edit

Kotest BehaviorSpec Test Skill

Kotest BehaviorSpec을 사용한 Spring Boot 테스트 작성 가이드.

When I Activate

  • 테스트 코드 작성 요청 시
  • 기존 테스트 수정/추가 시
  • Kotest, BehaviorSpec 관련 질문
  • 통합 테스트, E2E 테스트 작성 시
  • Repository, Service 테스트 작성 시

Test File Structure

src/test/kotlin/com/ifamilysc/romandcx/
├── e2e/              # E2E 테스트 (MockMvc + Testcontainers)
├── service/          # Service 통합 테스트
├── repository/
│   └── internal/     # Repository 테스트
└── unit/             # 유닛 테스트

Base Classes

1. SpringBehaviorSpec (기본 Spring 테스트)

abstract class SpringBehaviorSpec(
    body: SpringBehaviorSpec.() -> Unit = {}
) : BehaviorSpec(), SpringListener {
    override fun listeners() = listOf(SpringExtension)
}

2. SpringIntegrationBehaviorSpec (통합 테스트)

@SpringBootTest
@Import(TestcontainersConfiguration::class)
@Transactional
abstract class SpringIntegrationBehaviorSpec(
    body: SpringIntegrationBehaviorSpec.() -> Unit = {}
) : BehaviorSpec(), SpringListener {
    override fun listeners() = listOf(SpringExtension)
}

3. SpringE2EBehaviorSpec (E2E 테스트)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestcontainersConfiguration::class)
@AutoConfigureMockMvc
abstract class SpringE2EBehaviorSpec(
    body: SpringE2EBehaviorSpec.() -> Unit = {}
) : BehaviorSpec(), SpringListener {
    override fun listeners() = listOf(SpringExtension)
}

Pattern 1: Repository Test

@DataJpaTest
@Import(TestcontainersConfiguration::class, QueryDslConfig::class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class CustomerRepositoryTest(
    private val customerRepository: CustomerRepository,
) : SpringBehaviorSpec({

    beforeTest {
        customerRepository.deleteAll()
    }

    given("고객 데이터가 존재할 때") {
        beforeTest {
            customerRepository.saveAll(listOf(
                Customer(name = "홍길동", phone = "010-1234-5678"),
                Customer(name = "김철수", phone = "010-8765-4321"),
            ))
        }

        `when`("이름으로 검색하면") {
            then("해당 고객이 조회된다") {
                val command = PageCustomerCommand(
                    pageRequest = PageRequest(page = 1, size = 10, keyword = "홍길동"),
                )

                val result = customerRepository.findAllByPage(command)

                result.content.shouldHaveSize(1)
                result.content[0].name shouldBe "홍길동"
            }
        }

        `when`("전화번호로 검색하면") {
            then("해당 고객이 조회된다") {
                val result = customerRepository.findByPhone("010-1234-5678")

                result shouldNotBe null
                result!!.name shouldBe "홍길동"
            }
        }
    }

    given("고객이 없을 때") {
        then("빈 결과가 반환된다") {
            val command = PageCustomerCommand(
                pageRequest = PageRequest(page = 1, size = 10),
            )

            val result = customerRepository.findAllByPage(command)

            result.content.shouldBeEmpty()
        }
    }
})

Pattern 2: Service Integration Test

class ConsultationCategoryServiceCacheTest(
    private val consultationCategoryService: ConsultationCategoryService,
    private val consultationCategoryRepository: ConsultationCategoryRepository,
    private val cacheManager: CacheManager,
    private val redisTemplate: StringRedisTemplate,
    private val environment: Environment,
) : SpringIntegrationBehaviorSpec({

    val cacheName = "consultation-categories"
    val activeProfile = environment.activeProfiles.firstOrNull() ?: "default"
    val cacheKeyPrefix = "cx:$activeProfile:"

    beforeTest {
        cacheManager.getCache(cacheName)?.clear()
        consultationCategoryRepository.deleteAll()
    }

    given("getAllCategories() 호출") {
        `when`("처음 호출하면") {
            then("캐시에 저장된다") {
                // given
                consultationCategoryService.create(
                    CreateCategoryCommand(name = "테스트 카테고리", color = "#FF0000"),
                )

                // when
                val firstResult = consultationCategoryService.getAllCategories()

                // then
                val keys = redisTemplate.keys("$cacheKeyPrefix$cacheName*")
                keys shouldNotBe null
                keys!!.size shouldBe 1
                firstResult.shouldHaveSize(1)
                firstResult[0].name shouldBe "테스트 카테고리"
            }
        }
    }

    given("create 호출") {
        then("캐시가 클리어된다") {
            // given
            consultationCategoryService.create(
                CreateCategoryCommand(name = "카테고리1", color = "#FF0000"),
            )
            consultationCategoryService.getAllCategories()  // 캐시 생성

            // when
            consultationCategoryService.create(
                CreateCategoryCommand(name = "카테고리2", color = "#00FF00"),
            )

            // then
            val result = consultationCategoryService.getAllCategories()
            result.shouldHaveSize(2)
        }
    }
})

Pattern 3: E2E Test (MockMvc)

class AuthControllerE2ETest(
    private val mockMvc: MockMvc,
    private val objectMapper: ObjectMapper,
    private val adminUserRepository: AdminUserRepository,
    private val passwordEncoder: PasswordEncoder,
) : SpringE2EBehaviorSpec({

    beforeTest {
        adminUserRepository.deleteAll()
        adminUserRepository.save(
            AdminUser(
                username = "testuser",
                password = passwordEncoder.encode("password123"),
                name = "테스트 사용자",
                role = Role.ADMIN,
            )
        )
    }

    given("로그인 API") {
        `when`("올바른 자격 증명으로 요청하면") {
            then("200 OK와 사용자 정보를 반환한다") {
                val request = LoginRequest(
                    username = "testuser",
                    password = "password123",
                )

                mockMvc.post("/api/v1/auth/login") {
                    contentType = MediaType.APPLICATION_JSON
                    content = objectMapper.writeValueAsString(request)
                }.andExpect {
                    status { isOk() }
                    jsonPath("$.username") { value("testuser") }
                    jsonPath("$.name") { value("테스트 사용자") }
                }
            }
        }

        `when`("잘못된 비밀번호로 요청하면") {
            then("401 Unauthorized를 반환한다") {
                val request = LoginRequest(
                    username = "testuser",
                    password = "wrongpassword",
                )

                mockMvc.post("/api/v1/auth/login") {
                    contentType = MediaType.APPLICATION_JSON
                    content = objectMapper.writeValueAsString(request)
                }.andExpect {
                    status { isUnauthorized() }
                }
            }
        }
    }

    given("인증된 사용자") {
        `when`("me API를 호출하면") {
            then("현재 사용자 정보를 반환한다") {
                // 로그인
                val loginRequest = LoginRequest("testuser", "password123")
                val loginResult = mockMvc.post("/api/v1/auth/login") {
                    contentType = MediaType.APPLICATION_JSON
                    content = objectMapper.writeValueAsString(loginRequest)
                }.andReturn()

                val sessionCookie = loginResult.response.getCookie("SESSION")

                // me 호출
                mockMvc.get("/api/v1/auth/me") {
                    cookie(sessionCookie!!)
                }.andExpect {
                    status { isOk() }
                    jsonPath("$.username") { value("testuser") }
                }
            }
        }
    }
})

Pattern 4: Unit Test

@Execution(ExecutionMode.CONCURRENT)  // 병렬 실행 가능
class PhoneNumberFormatterTest : BehaviorSpec({

    given("전화번호 포맷터") {
        `when`("11자리 숫자가 주어지면") {
            then("하이픈이 포함된 형식으로 변환한다") {
                val result = "01012345678".toPhoneNumber()
                result shouldBe "010-1234-5678"
            }
        }

        `when`("이미 하이픈이 포함되어 있으면") {
            then("그대로 반환한다") {
                val result = "010-1234-5678".toPhoneNumber()
                result shouldBe "010-1234-5678"
            }
        }

        `when`("숫자가 아닌 문자가 포함되면") {
            then("숫자만 추출하여 변환한다") {
                val result = "010 1234 5678".toPhoneNumber()
                result shouldBe "010-1234-5678"
            }
        }
    }
})

Kotest Matchers Cheatsheet

import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldStartWith
import io.kotest.matchers.longs.shouldBeBetween
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.assertions.throwables.shouldThrow

// 기본 비교
result shouldBe "expected"
result shouldNotBe null

// 컬렉션
list.shouldHaveSize(3)
list.shouldBeEmpty()
list shouldContain "item"
list.shouldContainAll("a", "b", "c")

// 문자열
str shouldContain "substring"
str shouldStartWith "prefix"

// 숫자 범위
number.shouldBeBetween(1L, 100L)

// Null 체크
value.shouldBeNull()
value.shouldNotBeNull()

// 예외
shouldThrow<IllegalArgumentException> {
    doSomething()
}

BDD Style Guide

given("테스트 컨텍스트/시나리오 설명") {
    // 테스트 데이터 설정 (beforeTest)

    `when`("특정 조건/액션") {  // 선택사항
        then("예상 결과") {
            // given - 추가 설정
            // when - 실행
            // then - 검증
        }
    }

    then("직접 결과 검증") {  // when 없이도 사용 가능
        // 테스트 로직
    }
}

Test Isolation

E2E/통합 테스트 (순차 실행)

@SpringBootTest
@Transactional  // DB 자동 롤백
@Execution(ExecutionMode.SAME_THREAD)  // 순차 실행
class MyIntegrationTest : SpringIntegrationBehaviorSpec({

    beforeTest {
        // Redis 등 공유 자원 정리
        redisTemplate.delete(redisTemplate.keys("*") ?: emptySet())
    }
})

유닛 테스트 (병렬 실행)

@Execution(ExecutionMode.CONCURRENT)  // 병렬 실행
class MyUnitTest : BehaviorSpec({
    // 외부 자원 없이 순수 로직만 테스트
})

Common Imports

// Kotest
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.collections.shouldHaveSize

// Spring Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post

// JUnit (병렬 실행)
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode

References

Install via CLI
npx skills add https://github.com/spandios/vibe-utils --skill kotest-behaviorspec
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator