본문으로 건너뛰기

🔄 TDD (Test-Driven Development)

📖 TDD란?

TDD는 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 작성하는 개발 방법론입니다. 버그를 줄이고 설계를 개선할 수 있습니다!

💡 TDD 사이클

Red-Green-Refactor

1. 🔴 Red: 실패하는 테스트 작성
2. 🟢 Green: 테스트를 통과하는 최소한의 코드 작성
3. 🔵 Refactor: 코드 개선

🎯 첫 TDD 예제

계산기 만들기

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

// 1️⃣ Red: 테스트 먼저 작성
class CalculatorTest : StringSpec({
"2와 3을 더하면 5가 된다" {
val calculator = Calculator()
calculator.add(2, 3) shouldBe 5
}
})

// 컴파일 에러! Calculator가 없음

// 2️⃣ Green: 최소한의 코드
class Calculator {
fun add(a: Int, b: Int): Int {
return a + b
}
}

// 3️⃣ Refactor: 개선할 부분 없음 (간단하므로)

문자열 처리

// 1️⃣ Red: 테스트 작성
class StringProcessorTest : StringSpec({
"빈 문자열은 빈 문자열을 반환한다" {
val processor = StringProcessor()
processor.reverse("") shouldBe ""
}

"hello를 뒤집으면 olleh가 된다" {
val processor = StringProcessor()
processor.reverse("hello") shouldBe "olleh"
}
})

// 2️⃣ Green: 구현
class StringProcessor {
fun reverse(text: String): String {
return text.reversed()
}
}

// 3️⃣ Refactor: 더 나은 방법이 있을까?
// 이미 간결함!

🎨 실전 TDD

쇼핑 카트 개발

Step 1: 빈 카트

// Red
class ShoppingCartTest : StringSpec({
"새 카트는 아이템이 0개다" {
val cart = ShoppingCart()
cart.getItemCount() shouldBe 0
}
})

// Green
class ShoppingCart {
private val items = mutableListOf<Item>()

fun getItemCount(): Int = items.size
}

data class Item(val name: String, val price: Double)

Step 2: 아이템 추가

// Red
"아이템을 추가하면 개수가 증가한다" {
val cart = ShoppingCart()
cart.addItem(Item("사과", 1000.0))

cart.getItemCount() shouldBe 1
}

// Green
class ShoppingCart {
private val items = mutableListOf<Item>()

fun addItem(item: Item) {
items.add(item)
}

fun getItemCount(): Int = items.size
}

Step 3: 총액 계산

// Red
"총액을 계산한다" {
val cart = ShoppingCart()
cart.addItem(Item("사과", 1000.0))
cart.addItem(Item("바나나", 1500.0))

cart.getTotal() shouldBe 2500.0
}

// Green
class ShoppingCart {
private val items = mutableListOf<Item>()

fun addItem(item: Item) {
items.add(item)
}

fun getItemCount(): Int = items.size

fun getTotal(): Double {
return items.sumOf { it.price }
}
}

// Refactor: 깔끔함!

🔥 비즈니스 로직 TDD

할인 계산기

// Red: 테스트 먼저
class DiscountCalculatorTest : StringSpec({
"10% 할인" {
val calculator = DiscountCalculator()
calculator.calculate(10000.0, 0.1) shouldBe 9000.0
}

"할인율이 0이면 원가" {
val calculator = DiscountCalculator()
calculator.calculate(10000.0, 0.0) shouldBe 10000.0
}

"할인율이 1이면 0원" {
val calculator = DiscountCalculator()
calculator.calculate(10000.0, 1.0) shouldBe 0.0
}
})

// Green: 구현
class DiscountCalculator {
fun calculate(price: Double, rate: Double): Double {
require(rate in 0.0..1.0) { "할인율은 0~1 사이여야 합니다" }
return price * (1 - rate)
}
}

// Refactor: 검증 로직 추가했으므로 테스트 추가
"잘못된 할인율은 예외 발생" {
val calculator = DiscountCalculator()

shouldThrow<IllegalArgumentException> {
calculator.calculate(10000.0, 1.5)
}
}

사용자 등록

// Red
class UserRegistrationTest : StringSpec({
"유효한 사용자 등록" {
val service = UserRegistration()
val user = service.register("hong@example.com", "Pass123!")

user.email shouldBe "hong@example.com"
}

"중복 이메일은 실패" {
val service = UserRegistration()
service.register("hong@example.com", "Pass123!")

shouldThrow<DuplicateEmailException> {
service.register("hong@example.com", "Pass456!")
}
}

"약한 비밀번호는 실패" {
val service = UserRegistration()

shouldThrow<WeakPasswordException> {
service.register("hong@example.com", "123")
}
}
})

// Green
class UserRegistration {
private val users = mutableMapOf<String, User>()

fun register(email: String, password: String): User {
if (users.containsKey(email)) {
throw DuplicateEmailException()
}

if (password.length < 8) {
throw WeakPasswordException()
}

val user = User(email, password)
users[email] = user
return user
}
}

data class User(val email: String, val password: String)

class DuplicateEmailException : Exception()
class WeakPasswordException : Exception()

// Refactor: 검증 로직 분리
class UserRegistration {
private val users = mutableMapOf<String, User>()
private val validator = UserValidator()

fun register(email: String, password: String): User {
validator.validateEmail(email, users.keys)
validator.validatePassword(password)

val user = User(email, password)
users[email] = user
return user
}
}

class UserValidator {
fun validateEmail(email: String, existingEmails: Set<String>) {
if (existingEmails.contains(email)) {
throw DuplicateEmailException()
}
}

fun validatePassword(password: String) {
if (password.length < 8) {
throw WeakPasswordException()
}
}
}

🎯 TDD 베스트 프랙티스

작은 단계로

// ❌ 한번에 너무 많이
class ComplexTest : StringSpec({
"사용자 등록, 로그인, 프로필 수정까지" {
// 너무 복잡!
}
})

// ✅ 하나씩
class SimpleTest : StringSpec({
"사용자 등록" {
// 하나만 테스트
}

"로그인" {
// 별도로 테스트
}

"프로필 수정" {
// 또 별도로
}
})

명확한 테스트 이름

class GoodNaming : StringSpec({
// ❌ 나쁜 이름
"test1" { }

// ✅ 좋은 이름
"빈 장바구니의 총액은 0원이다" { }
"할인율 10%를 적용하면 10% 할인된다" { }
"중복 이메일로 가입하면 예외가 발생한다" { }
})

Given-When-Then 패턴

class GWTTest : StringSpec({
"주문 생성 후 결제하면 상태가 PAID가 된다" {
// Given
val order = Order(items = listOf(Item("상품", 10000.0)))

// When
order.pay()

// Then
order.status shouldBe OrderStatus.PAID
}
})

enum class OrderStatus { PENDING, PAID, SHIPPED }

data class Order(
val items: List<Item>,
var status: OrderStatus = OrderStatus.PENDING
) {
fun pay() {
status = OrderStatus.PAID
}
}

🛡️ TDD의 장점

1. 버그 조기 발견

// 테스트를 먼저 작성하면 요구사항을 명확히 이해
"음수 금액은 허용하지 않는다" {
shouldThrow<IllegalArgumentException> {
Order(amount = -1000.0)
}
}

// 구현하면서 자연스럽게 검증 로직 추가
data class Order(val amount: Double) {
init {
require(amount >= 0) { "금액은 0 이상이어야 합니다" }
}
}

2. 리팩토링 안전망

// 테스트가 있으면 리팩토링 후에도 안심
class BeforeRefactor {
fun calculate(price: Double, quantity: Int): Double {
var total = price * quantity
if (quantity > 10) {
total = total * 0.9 // 10개 이상 10% 할인
}
return total
}
}

// 리팩토링
class AfterRefactor {
fun calculate(price: Double, quantity: Int): Double {
val subtotal = price * quantity
val discount = getDiscount(quantity)
return subtotal * (1 - discount)
}

private fun getDiscount(quantity: Int): Double {
return if (quantity > 10) 0.1 else 0.0
}
}

// 테스트가 통과하면 리팩토링 성공!

🤔 자주 묻는 질문

Q1. 항상 테스트를 먼저 써야 하나요?

A: 복잡한 로직이나 중요한 기능에 우선 적용하세요!

// ✅ TDD 필수
// - 비즈니스 로직
// - 금액 계산
// - 검증 로직

// ❌ TDD 선택적
// - 단순 getter/setter
// - UI 레이아웃
// - 설정 파일

Q2. 테스트를 몇 개나 작성해야 하나요?

A: 경계 값과 예외 상황을 포함하세요!

class ComprehensiveTest : StringSpec({
// 정상 케이스
"정상적인 입력" { }

// 경계값
"최소값" { }
"최대값" { }

// 예외
"null 입력" { }
"빈 문자열" { }
"음수" { }
})

Q3. 레거시 코드에도 TDD를 적용할 수 있나요?

A: 수정하는 부분부터 시작하세요!

// 1. 기존 기능에 테스트 추가
// 2. 테스트가 통과하는지 확인
// 3. 리팩토링
// 4. 새 기능은 TDD로

🎬 마치며

TDD로 견고한 코드를!

핵심 정리:
✅ Red-Green-Refactor 사이클
✅ 테스트를 먼저 작성
✅ 작은 단계로 진행
✅ 리팩토링의 안전망
✅ 설계 개선 효과

축하합니다! Testing 시리즈를 완료했습니다! 🎉

다음 단계: Ktor 소개에서 백엔드 개발을 시작해보세요!