Backend

๐ŸŽฏ Kotest BDD vs TDD ์™„์ „ ์ •๋ณต: ์ฝ”ํ‹€๋ฆฐ ์Šคํ”„๋ง๋ถ€ํŠธ ํ…Œ์ŠคํŒ… ๋งˆ์Šคํ„ฐ ๊ฐ€์ด๋“œ

๊ด€๋ฆฌ์ž

1์ผ ์ „

36000
#Kotlin#SpringBoot#Kotest#MockK#BDD#TDD#BehaviorSpec#FunSpec#ํ…Œ์ŠคํŒ…#JUnit๋Œ€์ฒด

๐ŸŽฏ Kotest BDD vs TDD ์™„์ „ ์ •๋ณต: ์ฝ”ํ‹€๋ฆฐ ์Šคํ”„๋ง๋ถ€ํŠธ ํ…Œ์ŠคํŒ… ๋งˆ์Šคํ„ฐ ๊ฐ€์ด๋“œ

๐ŸŽฏ ํ•œ ์ค„ ์š”์•ฝ

BehaviorSpec vs FunSpec vs JUnit? ์ฝ”ํ‹€๋ฆฐ ์Šคํ”„๋ง๋ถ€ํŠธ์—์„œ BDD์™€ TDD ์Šคํƒ€์ผ ํ…Œ์ŠคํŒ…์„ ์™„๋ฒฝํ•˜๊ฒŒ ๋น„๊ต๋ถ„์„ํ•˜๊ณ  ์‹ค์ „ ์ ์šฉ๋ฒ•๊นŒ์ง€ ํ•œ ๋ฒˆ์— ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค!

Kotest ๋ฉ”์ธ ์ด๋ฏธ์ง€

๐Ÿค” ํ…Œ์ŠคํŒ… ์Šคํƒ€์ผ ์„ ํƒ์˜ ๊ณ ๋ฏผ

์ฝ”ํ‹€๋ฆฐ ์Šคํ”„๋ง๋ถ€ํŠธ ํ”„๋กœ์ ํŠธ๋ฅผ ์‹œ์ž‘ํ•  ๋•Œ ์ด๋Ÿฐ ๊ณ ๋ฏผ ํ•ด๋ณด์…จ๋‚˜์š”?

  • "Kotest BehaviorSpec์ด BDD๋ผ๋Š”๋ฐ, TDD๋Š” ๋ญ˜๋กœ ํ•ด์•ผ ํ•˜์ง€?"
  • "JUnit 5 vs Kotest, ์–ด๋–ค ๊ฑธ ์„ ํƒํ•ด์•ผ ํ• ๊นŒ?"
  • "Given-When-Then vs Test ํ•จ์ˆ˜, ์–ธ์ œ ๋ญ˜ ์จ์•ผ ํ• ๊นŒ?"

์˜ค๋Š˜ ์ด ๋ชจ๋“  ๊ณ ๋ฏผ์„ ์™„์ „ํžˆ ํ•ด๊ฒฐํ•ด๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค! ๐Ÿ”ฅ

BDD vs TDD ์ฐจ์ด์ 

๐Ÿ“š BDD vs TDD: ๊ฐœ๋…๋ถ€ํ„ฐ ์ •๋ฆฌํ•˜์ž

๐ŸŽฏ TDD (Test-Driven Development)

"์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ๋ฅผ ๋จผ์ € ์ž‘์„ฑํ•˜๊ณ , ์ตœ์†Œํ•œ์˜ ์ฝ”๋“œ๋กœ ํ†ต๊ณผ์‹œํ‚จ ํ›„ ๋ฆฌํŒฉํ† ๋ง"

TDD 3๋‹จ๊ณ„ ์‚ฌ์ดํด:

  1. Red: ์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ž‘์„ฑ
  2. Green: ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผํ•˜๋Š” ์ตœ์†Œ ์ฝ”๋“œ ์ž‘์„ฑ
  3. Refactor: ์ฝ”๋“œ ๊ฐœ์„ 

๐ŸŽฏ BDD (Behavior-Driven Development)

"๋น„์ฆˆ๋‹ˆ์Šค ์š”๊ตฌ์‚ฌํ•ญ์„ ์ž์—ฐ์–ด์— ๊ฐ€๊นŒ์šด ํ˜•ํƒœ๋กœ ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค ์ž‘์„ฑ"

BDD Given-When-Then ํŒจํ„ด:

  • Given: ์ฃผ์–ด์ง„ ์ƒํ™ฉ (์ „์ œ์กฐ๊ฑด)
  • When: ํŠน์ • ๋™์ž‘์„ ์‹คํ–‰ํ–ˆ์„ ๋•Œ
  • Then: ์˜ˆ์ƒ๋˜๋Š” ๊ฒฐ๊ณผ

๐Ÿ” ์ฝ”ํ‹€๋ฆฐ์—์„œ์˜ BDD vs TDD ๊ตฌํ˜„ ๋ฐฉ์‹

1๏ธโƒฃ BDD ์Šคํƒ€์ผ: Kotest BehaviorSpec

BDD๋Š” ๋น„์ฆˆ๋‹ˆ์Šค ์š”๊ตฌ์‚ฌํ•ญ์„ ์ค‘์‹ฌ์œผ๋กœ ํ•œ ํ–‰๋™ ๊ฒ€์ฆ์ž…๋‹ˆ๋‹ค:

// BDD ์Šคํƒ€์ผ: BehaviorSpec (์ž์—ฐ์–ด์— ๊ฐ€๊นŒ์šด ํ˜•ํƒœ)
class UserServiceBehaviorTest : BehaviorSpec({
    
    Given("์‹ ๊ทœ ์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ์ฃผ์–ด์กŒ์„ ๋•Œ") {
        val userService = UserService()
        val validUserRequest = CreateUserRequest(
            username = "kimkotlin",
            email = "kim@kotlin.com",
            password = "password123"
        )
        
        When("์‚ฌ์šฉ์ž ์ƒ์„ฑ์„ ์š”์ฒญํ•˜๋ฉด") {
            val createdUser = userService.createUser(validUserRequest)
            
            Then("์‚ฌ์šฉ์ž๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค") {
                createdUser.id shouldBeGreaterThan 0
                createdUser.username shouldBe "kimkotlin"
                createdUser.email shouldBe "kim@kotlin.com"
            }
            
            Then("ํŒจ์Šค์›Œ๋“œ๋Š” ์•”ํ˜ธํ™”๋˜์–ด ์ €์žฅ๋œ๋‹ค") {
                createdUser.password shouldNotBe "password123"
                createdUser.password shouldStartWith "$2a$"
            }
        }
        
        When("์ค‘๋ณต๋œ ์ด๋ฉ”์ผ๋กœ ์‚ฌ์šฉ์ž ์ƒ์„ฑ์„ ์‹œ๋„ํ•˜๋ฉด") {
            userService.createUser(validUserRequest) // ์ฒซ ๋ฒˆ์งธ ์ƒ์„ฑ
            
            Then("DuplicateEmailException์ด ๋ฐœ์ƒํ•œ๋‹ค") {
                shouldThrow<DuplicateEmailException> {
                    userService.createUser(validUserRequest) // ์ค‘๋ณต ์‹œ๋„
                }
            }
        }
    }
    
    Given("๊ธฐ์กด ์‚ฌ์šฉ์ž๊ฐ€ ์กด์žฌํ•  ๋•Œ") {
        val userService = UserService()
        val existingUser = userService.createUser(
            CreateUserRequest("existing", "existing@test.com", "pass")
        )
        
        When("์œ ํšจํ•œ ID๋กœ ์‚ฌ์šฉ์ž๋ฅผ ์กฐํšŒํ•˜๋ฉด") {
            val foundUser = userService.findById(existingUser.id)
            
            Then("ํ•ด๋‹น ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") {
                foundUser shouldNotBe null
                foundUser!!.username shouldBe "existing"
            }
        }
        
        When("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•˜๋ฉด") {
            Then("UserNotFoundException์ด ๋ฐœ์ƒํ•œ๋‹ค") {
                shouldThrow<UserNotFoundException> {
                    userService.findById(999L)
                }
            }
        }
    }
})

2๏ธโƒฃ TDD ์Šคํƒ€์ผ: Kotest FunSpec / JUnit

TDD๋Š” ๊ธฐ๋Šฅ ๋‹จ์œ„์˜ ๋น ๋ฅธ ๊ฒ€์ฆ์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค:

A. Kotest FunSpec (์ฝ”ํ‹€๋ฆฐ ๋„ค์ดํ‹ฐ๋ธŒ)

class UserServiceFunTest : FunSpec({
    
    // Red โ†’ Green โ†’ Refactor ์‚ฌ์ดํด์— ์ ํ•ฉ
    test("์‚ฌ์šฉ์ž ์ƒ์„ฑ ์„ฑ๊ณต ํ…Œ์ŠคํŠธ") {
        // Arrange
        val userService = UserService()
        val request = CreateUserRequest("test", "test@example.com", "password")
        
        // Act
        val result = userService.createUser(request)
        
        // Assert
        result.id shouldBeGreaterThan 0
        result.username shouldBe "test"
        result.email shouldBe "test@example.com"
    }
    
    test("์ค‘๋ณต ์ด๋ฉ”์ผ ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ") {
        val userService = UserService()
        val request = CreateUserRequest("test", "test@example.com", "password")
        
        // ์ฒซ ๋ฒˆ์งธ ์‚ฌ์šฉ์ž ์ƒ์„ฑ
        userService.createUser(request)
        
        // ์ค‘๋ณต ์‹œ๋„ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ ๊ฒ€์ฆ
        shouldThrow<DuplicateEmailException> {
            userService.createUser(request)
        }
    }
    
    test("์‚ฌ์šฉ์ž ์กฐํšŒ ํ…Œ์ŠคํŠธ") {
        val userService = UserService()
        val created = userService.createUser(
            CreateUserRequest("lookup", "lookup@test.com", "pass")
        )
        
        val found = userService.findById(created.id)
        
        found shouldNotBe null
        found!!.username shouldBe "lookup"
    }
    
    test("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž ์กฐํšŒ ์˜ˆ์™ธ ํ…Œ์ŠคํŠธ") {
        val userService = UserService()
        
        shouldThrow<UserNotFoundException> {
            userService.findById(999L)
        }
    }
})

B. JUnit 5 (์ „ํ†ต์  ๋ฐฉ์‹)

@ExtendWith(SpringExtension::class)
@SpringBootTest
class UserServiceJUnitTest {
    
    @Autowired
    private lateinit var userService: UserService
    
    @Test
    @DisplayName("์‚ฌ์šฉ์ž ์ƒ์„ฑ ์„ฑ๊ณต")
    fun `should create user successfully`() {
        // Arrange
        val request = CreateUserRequest("junit", "junit@test.com", "password")
        
        // Act
        val result = userService.createUser(request)
        
        // Assert
        assertThat(result.id).isGreaterThan(0)
        assertThat(result.username).isEqualTo("junit")
        assertThat(result.email).isEqualTo("junit@test.com")
    }
    
    @Test
    @DisplayName("์ค‘๋ณต ์ด๋ฉ”์ผ ์˜ˆ์™ธ")
    fun `should throw exception for duplicate email`() {
        val request = CreateUserRequest("duplicate", "dup@test.com", "pass")
        userService.createUser(request)
        
        assertThrows<DuplicateEmailException> {
            userService.createUser(request)
        }
    }
}

โš”๏ธ ์Šคํƒ€์ผ๋ณ„ ์‹ฌํ™” ๋น„๊ต ๋ถ„์„

๐Ÿ“Š ํŠน์ง• ๋น„๊ตํ‘œ

๊ตฌ๋ถ„ BDD (BehaviorSpec) TDD (FunSpec) TDD (JUnit 5)
๊ฐ€๋…์„ฑ โญโญโญโญโญ (์ž์—ฐ์–ด) โญโญโญโญ โญโญโญ
์ž‘์„ฑ ์†๋„ โญโญโญ โญโญโญโญโญ โญโญโญโญ
์œ ์ง€๋ณด์ˆ˜ โญโญโญโญ โญโญโญโญ โญโญโญ
ํ˜‘์—… ์นœํ™”์„ฑ โญโญโญโญโญ โญโญโญ โญโญ
๋Ÿฌ๋‹ ์ปค๋ธŒ โญโญโญ โญโญโญโญโญ โญโญโญโญ

๐ŸŽฏ ์–ธ์ œ ์–ด๋–ค ์Šคํƒ€์ผ์„ ์‚ฌ์šฉํ•ด์•ผ ํ• ๊นŒ?

โœ… BDD (BehaviorSpec) ์‚ฌ์šฉ ์‹œ๊ธฐ:

  • ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์ด ๋ณต์žกํ•œ ์„œ๋น„์Šค
  • PM, QA์™€ ํ˜‘์—…์ด ์ค‘์š”ํ•œ ํ”„๋กœ์ ํŠธ
  • ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŠธ
  • ์ธ์ˆ˜ ํ…Œ์ŠคํŠธ (Acceptance Test)
// ์˜ˆ์‹œ: ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ์‹œ์Šคํ…œ
Given("์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ƒํ’ˆ์ด ๋‹ด๊ฒจ์žˆ๊ณ  ๊ฒฐ์ œ์ˆ˜๋‹จ์ด ๋“ฑ๋ก๋œ ์ƒํƒœ์—์„œ") {
    When("์ฃผ๋ฌธ์„ ์š”์ฒญํ•˜๋ฉด") {
        Then("์ฃผ๋ฌธ์ด ์ƒ์„ฑ๋˜๊ณ ") {
        And("์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ๋˜๊ณ ") {
        And("๊ฒฐ์ œ๊ฐ€ ์ง„ํ–‰๋˜์–ด์•ผ ํ•œ๋‹ค") {
    }
}

โœ… TDD (FunSpec/JUnit) ์‚ฌ์šฉ ์‹œ๊ธฐ:

  • ๋‹จ์œ„ ํ…Œ์ŠคํŠธ (Unit Test)
  • ๋น ๋ฅธ ํ”ผ๋“œ๋ฐฑ์ด ํ•„์š”ํ•œ ๊ฐœ๋ฐœ
  • ์•Œ๊ณ ๋ฆฌ์ฆ˜, ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ
  • Red-Green-Refactor ์‚ฌ์ดํด
test("์•”ํ˜ธํ™” ํ•จ์ˆ˜ ๊ฒ€์ฆ") {
    val plainText = "password123"
    val encrypted = encrypt(plainText)
    
    encrypted shouldNotBe plainText
    decrypt(encrypted) shouldBe plainText
}

๐Ÿš€ Spring Boot ํ†ตํ•ฉ ์‹ค์ „ ๊ฐ€์ด๋“œ

1๏ธโƒฃ ์˜์กด์„ฑ ์„ค์ •

dependencies {
    // Kotest
    testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
    testImplementation("io.kotest:kotest-assertions-core:5.8.0")
    testImplementation("io.kotest:kotest-extensions-spring:1.1.3")
    
    // Spring Boot Test
    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude(group = "org.mockito", module = "mockito-core")
    }
    
    // MockK (Mockito ๋Œ€์‹ )
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("com.ninja-squad:springmockk:4.0.2")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

2๏ธโƒฃ ์‹ค์ œ Spring Boot ์ปจํŠธ๋กค๋Ÿฌ ํ…Œ์ŠคํŠธ

BDD ์Šคํƒ€์ผ: API ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserControllerBehaviorTest : BehaviorSpec() {
    
    @Autowired
    private lateinit var testRestTemplate: TestRestTemplate
    
    init {
        Given("์‚ฌ์šฉ์ž API ์—”๋“œํฌ์ธํŠธ๊ฐ€ ์ค€๋น„๋œ ์ƒํƒœ์—์„œ") {
            
            When("์œ ํšจํ•œ ์‚ฌ์šฉ์ž ์ƒ์„ฑ ์š”์ฒญ์„ ๋ณด๋‚ด๋ฉด") {
                val request = CreateUserRequest(
                    username = "apitester",
                    email = "api@test.com",
                    password = "secure123"
                )
                
                val response = testRestTemplate.postForEntity(
                    "/api/users", 
                    request, 
                    UserResponse::class.java
                )
                
                Then("201 Created ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") {
                    response.statusCode shouldBe HttpStatus.CREATED
                }
                
                Then("์ƒ์„ฑ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") {
                    val userResponse = response.body!!
                    userResponse.username shouldBe "apitester"
                    userResponse.email shouldBe "api@test.com"
                    userResponse.id shouldBeGreaterThan 0
                }
            }
            
            When("์ž˜๋ชป๋œ ์ด๋ฉ”์ผ ํ˜•์‹์œผ๋กœ ์š”์ฒญํ•˜๋ฉด") {
                val invalidRequest = CreateUserRequest(
                    username = "invalid",
                    email = "not-an-email",
                    password = "pass"
                )
                
                val response = testRestTemplate.postForEntity(
                    "/api/users", 
                    invalidRequest, 
                    ErrorResponse::class.java
                )
                
                Then("400 Bad Request ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค") {
                    response.statusCode shouldBe HttpStatus.BAD_REQUEST
                }
                
                Then("๊ฒ€์ฆ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ํฌํ•จํ•œ๋‹ค") {
                    response.body!!.message should include("์ด๋ฉ”์ผ ํ˜•์‹")
                }
            }
        }
    }
}

TDD ์Šคํƒ€์ผ: ์„œ๋น„์Šค ๋ ˆ์ด์–ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ

@ExtendWith(SpringExtension::class)
class UserServiceUnitTest : FunSpec() {
    
    private val userRepository = mockk<UserRepository>()
    private val passwordEncoder = mockk<PasswordEncoder>()
    private val userService = UserService(userRepository, passwordEncoder)
    
    init {
        test("์‚ฌ์šฉ์ž ์ƒ์„ฑ ์‹œ ํŒจ์Šค์›Œ๋“œ ์•”ํ˜ธํ™” ํ™•์ธ") {
            // Given
            val plainPassword = "password123"
            val encodedPassword = "$2a$10$encoded"
            val request = CreateUserRequest("user", "user@test.com", plainPassword)
            
            every { userRepository.existsByEmail(any()) } returns false
            every { passwordEncoder.encode(plainPassword) } returns encodedPassword
            every { userRepository.save(any()) } returns User(
                id = 1L,
                username = "user",
                email = "user@test.com",
                password = encodedPassword
            )
            
            // When
            val result = userService.createUser(request)
            
            // Then
            result.password shouldBe encodedPassword
            verify { passwordEncoder.encode(plainPassword) }
            verify { userRepository.save(any()) }
        }
        
        test("์ด๋ฉ”์ผ ์ค‘๋ณต ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ") {
            // Given
            every { userRepository.existsByEmail("dup@test.com") } returns true
            
            // When & Then
            shouldThrow<DuplicateEmailException> {
                userService.createUser(
                    CreateUserRequest("dup", "dup@test.com", "pass")
                )
            }
            
            verify(exactly = 0) { userRepository.save(any()) }
        }
    }
}

3๏ธโƒฃ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ

BDD ์Šคํƒ€์ผ: Repository ํ–‰๋™ ๊ฒ€์ฆ

@DataJpaTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserRepositoryBehaviorTest : BehaviorSpec() {
    
    @Autowired
    private lateinit var userRepository: UserRepository
    
    @Autowired
    private lateinit var testEntityManager: TestEntityManager
    
    init {
        Given("์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋˜์–ด ์žˆ์„ ๋•Œ") {
            val user1 = User(username = "active1", email = "active1@test.com", status = "ACTIVE")
            val user2 = User(username = "inactive1", email = "inactive1@test.com", status = "INACTIVE")
            val user3 = User(username = "active2", email = "active2@test.com", status = "ACTIVE")
            
            testEntityManager.persistAndFlush(user1)
            testEntityManager.persistAndFlush(user2)
            testEntityManager.persistAndFlush(user3)
            
            When("ํ™œ์„ฑ ์‚ฌ์šฉ์ž๋งŒ ์กฐํšŒํ•˜๋ฉด") {
                val activeUsers = userRepository.findByStatus("ACTIVE")
                
                Then("ํ™œ์„ฑ ์ƒํƒœ ์‚ฌ์šฉ์ž 2๋ช…์ด ์กฐํšŒ๋œ๋‹ค") {
                    activeUsers.size shouldBe 2
                    activeUsers.all { it.status == "ACTIVE" } shouldBe true
                }
            }
            
            When("์ด๋ฉ”์ผ๋กœ ์‚ฌ์šฉ์ž๋ฅผ ๊ฒ€์ƒ‰ํ•˜๋ฉด") {
                val foundUser = userRepository.findByEmail("active1@test.com")
                
                Then("ํ•ด๋‹น ์‚ฌ์šฉ์ž๊ฐ€ ์ •ํ™•ํžˆ ์กฐํšŒ๋œ๋‹ค") {
                    foundUser shouldNotBe null
                    foundUser!!.username shouldBe "active1"
                }
            }
        }
    }
}

๐ŸŽช ๊ณ ๊ธ‰ ํ…Œ์ŠคํŒ… ํŒจํ„ด

1๏ธโƒฃ ์ค‘์ฒฉ ์ปจํ…์ŠคํŠธ๋กœ ์‹œ๋‚˜๋ฆฌ์˜ค ๊ตฌ์„ฑ

class OrderServiceAdvancedTest : BehaviorSpec({
    
    Given("์ฃผ๋ฌธ ์‹œ์Šคํ…œ์ด ์ค€๋น„๋˜์–ด ์žˆ๊ณ ") {
        val orderService = OrderService()
        
        context("์ถฉ๋ถ„ํ•œ ์žฌ๊ณ ๊ฐ€ ์žˆ๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด") {
            val productId = 1L
            val availableStock = 100
            
            When("10๊ฐœ ์ฃผ๋ฌธ์„ ์š”์ฒญํ•˜๋ฉด") {
                val order = orderService.createOrder(productId, quantity = 10)
                
                Then("์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค") {
                    order.status shouldBe OrderStatus.CONFIRMED
                    order.quantity shouldBe 10
                }
                
                Then("์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ๋œ๋‹ค") {
                    val remainingStock = stockService.getStock(productId)
                    remainingStock shouldBe (availableStock - 10)
                }
            }
            
            When("์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์„ ์ฃผ๋ฌธํ•˜๋ฉด") {
                Then("์žฌ๊ณ  ๋ถ€์กฑ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") {
                    shouldThrow<InsufficientStockException> {
                        orderService.createOrder(productId, quantity = 200)
                    }
                }
            }
        }
        
        context("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•œ ์ƒํ’ˆ์— ๋Œ€ํ•ด") {
            val lowStockProductId = 2L
            
            When("์ฃผ๋ฌธ์„ ์‹œ๋„ํ•˜๋ฉด") {
                Then("์ฆ‰์‹œ ์žฌ๊ณ  ๋ถ€์กฑ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") {
                    shouldThrow<InsufficientStockException> {
                        orderService.createOrder(lowStockProductId, quantity = 1)
                    }
                }
            }
        }
    }
})

2๏ธโƒฃ ํ”„๋กœํผํ‹ฐ ๊ธฐ๋ฐ˜ ํ…Œ์ŠคํŒ… ํ™œ์šฉ

class ValidationTest : FunSpec({
    
    test("์ด๋ฉ”์ผ ๊ฒ€์ฆ ๋กœ์ง์˜ ๊ฒฌ๊ณ ์„ฑ ํ…Œ์ŠคํŠธ") {
        checkAll<String> { randomString ->
            val isValid = EmailValidator.isValid(randomString)
            
            if (isValid) {
                // ์œ ํšจํ•œ ์ด๋ฉ”์ผ์ด๋ผ๋ฉด @ ๊ธฐํ˜ธ๊ฐ€ ํฌํ•จ๋˜์–ด์•ผ ํ•จ
                randomString should contain("@")
                randomString shouldNot startWith("@")
                randomString shouldNot endWith("@")
            }
        }
    }
    
    test("ํŒจ์Šค์›Œ๋“œ ์•”ํ˜ธํ™” ์ผ๊ด€์„ฑ ํ…Œ์ŠคํŠธ") {
        checkAll(Gen.string().filter { it.isNotBlank() }) { password ->
            val encrypted1 = passwordEncoder.encode(password)
            val encrypted2 = passwordEncoder.encode(password)
            
            // ๋™์ผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ผ๋„ ๋งค๋ฒˆ ๋‹ค๋ฅธ ํ•ด์‹œ๊ฐ’ ์ƒ์„ฑ
            encrypted1 shouldNotBe encrypted2
            
            // ํ•˜์ง€๋งŒ ๊ฒ€์ฆ์€ ๋‘˜ ๋‹ค ์„ฑ๊ณต
            passwordEncoder.matches(password, encrypted1) shouldBe true
            passwordEncoder.matches(password, encrypted2) shouldBe true
        }
    }
})

โšก ์‹ค์ „ ํŒ๊ณผ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค

๐ŸŽฏ ํ…Œ์ŠคํŠธ ์กฐ์งํ™” ์ „๋žต

1. ํŒจํ‚ค์ง€ ๊ตฌ์กฐ

src/test/kotlin/
โ”œโ”€โ”€ behavior/           # BDD ํ…Œ์ŠคํŠธ
โ”‚   โ”œโ”€โ”€ user/
โ”‚   โ”œโ”€โ”€ order/
โ”‚   โ””โ”€โ”€ payment/
โ”œโ”€โ”€ unit/              # TDD ๋‹จ์œ„ ํ…Œ์ŠคํŠธ
โ”‚   โ”œโ”€โ”€ service/
โ”‚   โ”œโ”€โ”€ util/
โ”‚   โ””โ”€โ”€ validator/
โ””โ”€โ”€ integration/       # ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ
    โ”œโ”€โ”€ api/
    โ”œโ”€โ”€ repository/
    โ””โ”€โ”€ external/

2. ๋„ค์ด๋ฐ ์ปจ๋ฒค์…˜

// BDD: ์ž์—ฐ์–ด์— ๊ฐ€๊นŒ์šด ์„ค๋ช…
Given("์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ๋˜์–ด ์žˆ๊ณ  ๊ถŒํ•œ์ด ์žˆ์„ ๋•Œ")
When("๊ฒŒ์‹œ๊ธ€ ์‚ญ์ œ๋ฅผ ์š”์ฒญํ•˜๋ฉด")
Then("๊ฒŒ์‹œ๊ธ€์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‚ญ์ œ๋œ๋‹ค")

// TDD: ๊ธฐ๋Šฅ ์ค‘์‹ฌ์˜ ๊ฐ„๊ฒฐํ•œ ์„ค๋ช…
test("์œ ํšจํ•œ ํ† ํฐ์œผ๋กœ ์ธ์ฆ ์„ฑ๊ณต")
test("๋งŒ๋ฃŒ๋œ ํ† ํฐ์œผ๋กœ ์ธ์ฆ ์‹คํŒจ")
test("์ž˜๋ชป๋œ ํ˜•์‹ ํ† ํฐ ๊ฒ€์ฆ ์˜ˆ์™ธ")

๐Ÿ”ฅ ์„ฑ๋Šฅ ์ตœ์ ํ™” ํŒ

1. ํ…Œ์ŠคํŠธ ๋ณ‘๋ ฌ ์‹คํ–‰

// build.gradle.kts
tasks.test {
    systemProperty("kotest.framework.parallelism", "4")
    maxParallelForks = 4
}

2. ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ์ตœ์ ํ™”

// ํ…Œ์ŠคํŠธ ์ „์šฉ ํ”„๋กœํ•„ ์„ค์ •
@ActiveProfiles("test")
@TestPropertySource(
    properties = [
        "spring.jpa.hibernate.ddl-auto=create-drop",
        "spring.datasource.url=jdbc:h2:mem:testdb"
    ]
)

๐Ÿข ์‹ค์ œ ๋„์ž… ์‚ฌ๋ก€์™€ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜

๐Ÿ“Š ๊ธฐ์—… ์‚ฌ๋ก€ ๋ถ„์„

ํšŒ์‚ฌ ์ด์ „ ์Šคํƒ ํ˜„์žฌ ์Šคํƒ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ด์œ 
์นด์นด์˜ค๋ฑ…ํฌ JUnit + Mockito Kotest + MockK ์ฝ”ํ‹€๋ฆฐ ์นœํ™”์„ฑ, ๊ฐ€๋…์„ฑ ํ–ฅ์ƒ
ํ† ์Šค JUnit ํ˜ผํ•ฉ BDD/TDD ๋ถ„๋ฆฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๊ฒ€์ฆ ๊ฐ•ํ™”
๋ฐฐ๋‹ฌ์˜๋ฏผ์กฑ JUnit ์ค‘์‹ฌ Kotest ์ ์ง„ ๋„์ž… ๊ฐœ๋ฐœ ์ƒ์‚ฐ์„ฑ ํ–ฅ์ƒ

๐Ÿ› ๏ธ ๋‹จ๊ณ„๋ณ„ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฐ€์ด๋“œ

Phase 1: ์‹ ๊ทœ ์ฝ”๋“œ๋ถ€ํ„ฐ ์‹œ์ž‘

// ๊ธฐ์กด JUnit ํ…Œ์ŠคํŠธ๋Š” ์œ ์ง€ํ•˜๋ฉด์„œ
@Test
fun existingJUnitTest() {
    // ๊ธฐ์กด ํ…Œ์ŠคํŠธ ๊ทธ๋Œ€๋กœ ์œ ์ง€
}

// ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ์€ Kotest๋กœ
class NewFeatureTest : FunSpec({
    test("์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ") {
        // Kotest ์Šคํƒ€์ผ๋กœ ์ž‘์„ฑ
    }
})

Phase 2: ์ค‘์š” ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ BDD๋กœ

class PaymentProcessBehaviorTest : BehaviorSpec({
    Given("๊ฒฐ์ œ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์˜ ์ฃผ๋ฌธ์ด ์žˆ์„ ๋•Œ") {
        When("์‹ ์šฉ์นด๋“œ๋กœ ๊ฒฐ์ œ๋ฅผ ์‹œ๋„ํ•˜๋ฉด") {
            Then("๊ฒฐ์ œ๊ฐ€ ์„ฑ๊ณตํ•˜๊ณ  ์ฃผ๋ฌธ ์ƒํƒœ๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค") {
                // ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ BDD๋กœ ๋ช…ํ™•ํ•˜๊ฒŒ ๊ฒ€์ฆ
            }
        }
    }
})

Phase 3: ์œ ํ‹ธ๋ฆฌํ‹ฐ์™€ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ TDD๋กœ

class DateUtilTest : FunSpec({
    test("๋‚ ์งœ ํฌ๋งคํŒ… ์ •ํ™•์„ฑ ๊ฒ€์ฆ") {
        // ๋น ๋ฅธ ํ”ผ๋“œ๋ฐฑ์ด ํ•„์š”ํ•œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜ ํ…Œ์ŠคํŠธ
    }
})

๐Ÿ“ˆ ํ…Œ์ŠคํŠธ ํ’ˆ์งˆ ์ธก์ •๊ณผ ๊ฐœ์„ 

๐ŸŽฏ ์ปค๋ฒ„๋ฆฌ์ง€ vs ํ’ˆ์งˆ

// โŒ ๋‚˜์œ ์˜ˆ: ์ปค๋ฒ„๋ฆฌ์ง€๋งŒ ๋†’์€ ํ…Œ์ŠคํŠธ
test("๋ชจ๋“  getter ํ˜ธ์ถœ") {
    val user = User("test", "test@test.com")
    user.username // ๋‹จ์ˆœ ํ˜ธ์ถœ
    user.email    // ์˜๋ฏธ ์—†๋Š” ํ…Œ์ŠคํŠธ
}

// โœ… ์ข‹์€ ์˜ˆ: ์˜๋ฏธ ์žˆ๋Š” ํ–‰๋™ ๊ฒ€์ฆ
Given("์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ์ฃผ์–ด์กŒ์„ ๋•Œ") {
    When("ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ๋ฅผ ์š”์ฒญํ•˜๋ฉด") {
        Then("๋ณ€๊ฒฝ๋œ ์ •๋ณด๊ฐ€ ์ •ํ™•ํžˆ ๋ฐ˜์˜๋œ๋‹ค") {
            // ์‹ค์ œ ๋น„์ฆˆ๋‹ˆ์Šค ๊ฐ€์น˜๊ฐ€ ์žˆ๋Š” ๊ฒ€์ฆ
        }
    }
}

๐Ÿ“Š ํ…Œ์ŠคํŠธ ๋ฉ”ํŠธ๋ฆญ ์ถ”์ 

// ํ…Œ์ŠคํŠธ ์‹คํ–‰ ์‹œ๊ฐ„ ๋ชจ๋‹ˆํ„ฐ๋ง
class PerformanceTest : FunSpec({
    test("API ์‘๋‹ต์‹œ๊ฐ„ ๊ฒ€์ฆ").config(timeout = 3.seconds) {
        val startTime = System.currentTimeMillis()
        
        val response = apiClient.getUserData()
        
        val endTime = System.currentTimeMillis()
        (endTime - startTime) shouldBeLessThan 1000
    }
})

๐Ÿ’ญ ๋งˆ๋ฌด๋ฆฌ: ์ตœ์ ์˜ ํ…Œ์ŠคํŒ… ์ „๋žต ์„ ํƒ

BDD์™€ TDD๋Š” ๊ฒฝ์Ÿ ๊ด€๊ณ„๊ฐ€ ์•„๋‹Œ ์ƒํ˜ธ ๋ณด์™„ ๊ด€๊ณ„์ž…๋‹ˆ๋‹ค.

๐ŸŽฏ ๊ถŒ์žฅ ์กฐํ•ฉ ์ „๋žต:

๐Ÿ“‹ ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐ:

  • ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง: BDD (BehaviorSpec)
  • ์œ ํ‹ธ๋ฆฌํ‹ฐ/ํ—ฌํผ ํ•จ์ˆ˜: TDD (FunSpec)
  • ๋‹จ์œ„ ํ…Œ์ŠคํŠธ: TDD (FunSpec ๋˜๋Š” JUnit)

๐Ÿ—๏ธ ํ”„๋กœ์ ํŠธ ์„ฑ์žฅ๊ธฐ:

  • ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค: BDD๋กœ ์ „ํ™˜
  • ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ: TDD ์œ ์ง€
  • ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ: BDD/TDD ํ˜ผํ•ฉ

๐Ÿš€ ๋Œ€๊ทœ๋ชจ ์šด์˜:

  • ์ธ์ˆ˜ ํ…Œ์ŠคํŠธ: BDD ์ค‘์‹ฌ
  • ํšŒ๊ท€ ํ…Œ์ŠคํŠธ: TDD ์ค‘์‹ฌ
  • ๋ชจ๋‹ˆํ„ฐ๋ง ํ…Œ์ŠคํŠธ: TDD ์ค‘์‹ฌ

๊ฒฐ๊ตญ ํŒ€์˜ ์ƒํ™ฉ๊ณผ ํ”„๋กœ์ ํŠธ ํŠน์„ฑ์— ๋งž๊ฒŒ ์œ ์—ฐํ•˜๊ฒŒ ์กฐํ•ฉํ•˜๋Š” ๊ฒƒ์ด ์ •๋‹ต์ž…๋‹ˆ๋‹ค!

์—ฌ๋Ÿฌ๋ถ„์˜ ํ”„๋กœ์ ํŠธ์—์„œ๋Š” ์–ด๋–ค ํ…Œ์ŠคํŒ… ์ „๋žต์„ ์„ ํƒํ•˜์‹ค ๊ฑด๊ฐ€์š”? ๋Œ“๊ธ€๋กœ ๊ฒฝํ—˜์„ ๊ณต์œ ํ•ด์ฃผ์„ธ์š”! ๐ŸŽฏ


์ด ๊ธ€์ด ๋„์›€์ด ๋˜์…จ๋‹ค๋ฉด ์ข‹์•„์š”์™€ ๊ณต์œ  ๋ถ€ํƒ๋“œ๋ฆฝ๋‹ˆ๋‹ค! โค๏ธ

๐Ÿ”— ์ฐธ๊ณ  ์ž๋ฃŒ

๋Œ“๊ธ€ 0๊ฐœ

์•„์ง ๋Œ“๊ธ€์ด ์—†์Šต๋‹ˆ๋‹ค

์ฒซ ๋ฒˆ์งธ ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•ด๋ณด์„ธ์š”!