처음으로 블로그에 책에 대한 정리한 내용을 남긴다.
내용은 책을 통해 알게된 부분, 앞으로 내 서비스에 적용할 수 있는 내용에 대해 서술해보겠다.

1. 테스트는 왜 필요한가?
테스팅을 돌릴 환경에 대한 비용, 테스트를 작성할 시간에 대한 비용 < 테스트가 주는 가치

보통 테스트의 중요성에선 이런 그래프가 우선되어 나온다.
초반 개발의 병목이 되는 테스트가 후반에는 든든한 아군이 되어 많은 문제들을 사전에 예방해준다는 뜻.
결론적으로, 테스트 코드의 품질은 제품의 품질과 동치한다.
(하지만 쓰고 더 개발하지 않을 제품, 단순한 제품에는 굳이 테스트에 자원을 투자할 필요가 없음)
2. 단위테스트의 목표
소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것
단위테스트는 중요한 비지니스 로직에 대한 테스트 위주
그리고 단위테스트는 E2E, 통합 테스트에 비해 상대적으로 저렴하다. (시간이든, 리소스든)
그럼 모든 영역을 커버리지 해야하는가?
아니다. 비지니스 로직과 동떨어진 부분은 굳이 유지하지 않는 것이 오히려 낫다. 커지는 코드는 자산이 아니라 부채다.
3. 어떻게 단위테스트를 짜는 것이 가장 좋은가?

책의 핵심 중 하나라고 생각하는 표이다.
책에서 주장하는 좋은 단위 테스트의 4가지 요소
1. 회귀 방지(버그 방지)
2. 리팩토링 내성
3. 빠른 피드백
4. 유지 보수성
회귀 방지 -> 오류를 테스트가 발견하지 못하는 경우
'회귀'란 소프트웨어 버그다.
회귀 방지를 위해선 테스트 중 실행되는 코드가 많고, 특히 비지니스에 중요한 로직은 꼭 체크되는 것이 중요하다.
(비지니스적인 코드에서 발생하는 에러가 훨씬 큰 금액적 손실을 입힌다.)
결국 많은 시간을 투자해, 비지니스적으로 중요한 코드는 꼭 테스트해야한다.
리팩토링 내성 -> 허위 경보.
허위 경보의 가장 큰 문제 : 타당한 이유없이 실패하는 만큼, 개발자가 테스트에 쓰는 신경이 줄어든다.
리팩토링이란 식별할 수 있는 동작에 영향을 주지 않으면서 구현을 변경하는 것이다.
즉 기존 코드의 수정에도, 테스트가 함께 바뀌어야 하는 테스트 제작은 피해야 한다는 의미.
테스트는 기본적으로 구현 사항과 밀접해서는 안된다.
단위 테스트의 경우 최대한 블랙박스 테스트에 가깝게 개발한다.
(화이트박스 테스트는 차라리 코드커버리지 같은 정적 분석 도구를 사용할 것)
4가지는 다 가질 수 없다.
그럼 우선
2. 리팩토링 내성
4. 유지 보수성
두 가지는 반드시 챙긴다. 특히 리팩토링 내성의 경우, 아예 포기하거나 아예 챙기거나 둘 중 하나이므로 내 경우는 2번을 챙기는 것이 맞다.
그럼 이제 1,3 간의 밸런스를 잡을 차례

이 책의 장점이라고 생각하는데, 결론이 명확하다.
결국 "단위" 테스트는 빠른 피드백에 더 집중해야한다.
리팩토링 과정에서 발생하는 오류를 빠르게 캐치하고 수정할 수 있는 도구로 사용해야 한다.
"단위 테스트에서 어느 정도의 커버리지를 가지는 게 좋은가"도 여기서 느낄 수 있는데, 여러 클래스를 함께 테스트하면 그만큼 회귀 방지는 좋아질 것이다.
하지만 피드백 속도가 느려지긴 할것이다.. (그래봐야 클래스 단위인데?)
또 다른 단점은 어떤 수정 부분이 문제의 원인인지 파악하기 어려워 진다는 것
하지만 이 단점 역시 빠른 피드백의 장점으로 자주 실행하면 보완할 수 있을 것이다.
결론적으로 단위테스트의 핵심은 빠르고, 자주 수행하는 것이다.
더해서 테스트 내에서 통제할 수 없는 부분이 포함되면 허위 경보가 자주 발생함. 이는 모킹, 스텁 객체로 대체
모킹 -> 테스트 대상이 의존 객체와 어떻게 상호작용했는지 검증하는 객체
스텁 -> 외부 의존성을 대체하는 입력 제공자
4. 실제 서비스 예시 코드를, 테스트 작성에 적용해보자 (코틀린 기준)
OrderService (내부 + 외부 종속성)
class OrderService(
private val orderPolicy: OrderPolicy,
private val inventory: Inventory,
private val paymentClient: PaymentClient, // 외부
private val mailService: MailService // 외부
) {
fun placeOrder(order: Order): Boolean {
orderPolicy.validate(order)
val finalAmount = orderPolicy.applyDiscount(order)
val paymentResult = paymentClient.pay(finalAmount)
if (!paymentResult.success) return false
inventory.reduce(order)
mailService.sendOrderCompletedMail(order)
return true
}
}
주문 정책 (내부 종속)
class OrderPolicy(
private val discountApi: DiscountApi,
private val memberRepository: MemberRepository,
private val minAmount: Int = 1000
) {
fun validate(order: Order) {
require(order.totalAmount() >= minAmount) {
"최소 주문 금액 ${minAmount}원을 만족해야 합니다."
}
}
fun applyDiscount(order: Order, memberId: Long): Int {
val base = order.totalAmount()
val apiDiscount = discountApi.getTodayDiscountRate()
return if (memberRepository.isVipMember(memberId)) {
(base * (1 - apiDiscount - 0.1)).toInt() // VIP 추가 10% 할인
} else {
(base * (1 - apiDiscount)).toInt()
}
}
}
외부 의존성
interface DiscountApi {
fun getTodayDiscountRate(): Double
}
interface MemberRepository {
fun isVipMember(memberId: Long): Boolean
}
재고 관리 (내부 종속)
class Inventory(private val stock: MutableMap<Long, Int>) {
fun reduce(order: Order) {
orderItems(order).forEach { (productId, qty) ->
val current = stock[productId] ?: 0
if (current < qty) throw IllegalStateException("재고 부족: $productId")
stock[productId] = current - qty
}
}
fun getStock(productId: Long): Int = stock[productId] ?: 0
private fun orderItems(order: Order): Map<Long, Int> =
order.items.groupBy { it.product.id }
.mapValues { (_, items) -> items.sumOf { it.quantity } }
}
내부 도메인 클래스
data class Product(val id: Long, val name: String, val price: Int)
data class OrderItem(val product: Product, val quantity: Int) {
fun totalPrice(): Int = product.price * quantity
}
class Order(private val items: List<OrderItem>) {
fun totalAmount(): Int = items.sumOf { it.totalPrice() }
fun itemCount(): Int = items.sumOf { it.quantity }
}
기능 단위 단위테스트 작성
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.mockito.kotlin.*
class OrderPolicyTest {
private val discountApi: DiscountApi = mock()
private val memberRepository: MemberRepository = mock()
private val policy = OrderPolicy(discountApi, memberRepository, minAmount = 1000)
@Test
fun `최소 금액 미만이면 예외 발생`() {
val order = Order(listOf(OrderItem(Product(1, "사탕", 500), 1)))
assertThrows(IllegalArgumentException::class.java) {
policy.validate(order)
}
}
@Test
fun `VIP 회원은 API 할인율에 추가로 10프로 더 할인된다`() {
// given
val order = Order(listOf(OrderItem(Product(1, "노트북", 1000), 6))) // 총액 6000
whenever(discountApi.getTodayDiscountRate()).thenReturn(0.1) // 오늘 할인율 10%
whenever(memberRepository.isVipMember(1L)).thenReturn(true)
// when
val discounted = policy.applyDiscount(order, memberId = 1L)
// then
// 6000 * (1 - 0.1 - 0.1) = 6000 * 0.8 = 4800
assertEquals(4800, discounted)
}
@Test
fun `일반 회원은 API 할인율만 적용된다`() {
// given
val order = Order(listOf(OrderItem(Product(1, "책", 2000), 3))) // 총액 6000
whenever(discountApi.getTodayDiscountRate()).thenReturn(0.1) // 오늘 할인율 10%
whenever(memberRepository.isVipMember(2L)).thenReturn(false)
// when
val discounted = policy.applyDiscount(order, memberId = 2L)
// then
// 6000 * (1 - 0.1) = 5400
assertEquals(5400, discounted)
}
}
통합테스트 (외부 모킹)
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.mockito.kotlin.*
class OrderServiceTest {
private val paymentClient: PaymentClient = mock()
private val mailService: MailService = mock()
private val inventory = Inventory(mutableMapOf(1L to 5))
private val orderPolicy = OrderPolicy()
private val orderService = OrderService(orderPolicy, inventory, paymentClient, mailService)
@Test
fun `결제 성공 시 주문 완료`() {
val order = Order(listOf(OrderItem(Product(1, "펜", 2000), 2)))
whenever(paymentClient.pay(any())).thenReturn(PaymentResult(true))
val result = orderService.placeOrder(order)
assertTrue(result)
assertEquals(1, inventory.getStock(1L)) // 재고 차감 확인
verify(mailService).sendOrderCompletedMail(order)
}
@Test
fun `결제 실패 시 주문 실패`() {
val order = Order(listOf(OrderItem(Product(1, "펜", 2000), 2)))
whenever(paymentClient.pay(any())).thenReturn(PaymentResult(false))
val result = orderService.placeOrder(order)
assertFalse(result)
assertEquals(5, inventory.getStock(1L)) // 재고 그대로
verify(mailService, never()).sendOrderCompletedMail(any())
}
}
- 단위 테스트:
- 기능을 “작게” 잘라서 검증 (메서드, 클래스, 도메인 규칙 단위)
- 내부 로직까지 깊게 파고듦 (예외, 경계값, 정책 적용 등)
- 예: 재고 차감 시 수량이 부족하면 예외 발생한다
- 통합 테스트:
- 기능을 “시스템 관점”에서 검증 (유즈케이스 단위)
- 여러 컴포넌트가 협력해서 그 기능이 end-to-end로 제대로 동작하는지 확인
- 예: 주문 생성 → 결제 성공 → 재고 차감 → 이메일 발송까지 완료된다
위 예시에 적용하면.
- 단위 테스트
- Order.totalAmount() → 금액 합계가 올바른가?
- OrderPolicy.applyDiscount() → 할인 정책이 올바른가?
- Inventory.reduce() → 재고 차감이 올바른가?
- PaymentClient.pay() → 유효하지 않은 카드일 때 실패하는가?
- 통합 테스트
- OrderService.placeOrder() → 전체 플로우가 문제없이 실행되는가?
- DB에 status=COMPLETED 저장되고, 메일 발송이 호출되었는가?
- 결제가 실패하면 status=FAILED로 남고, 재고/메일은 실행되지 않았는가?
"주문의 합을 구하는 행위"는 하나의 단위고, 주문 정책의 적용 여부 자체도 'OrderPolicy' 의 하나의 기능..
아직도 헷갈리지만
단위테스트 역시 여러 도메인이 복합적으로 연결돼있어, 하나의 클래스'만' 커버리지에 넣기는 어렵고 비효율적이다.
핵심은 검증 범위가 한 클래스(= OrderPolicy)로 한정된다 라는 것.
반면 통합테스트는 여러 컴포넌트가 실제 환경에서 협력해 하나의 기능을 수행하는지를 검증하는 것이다.
'CS' 카테고리의 다른 글
| RabbitMQ 개념 정리 (0) | 2025.10.14 |
|---|---|
| REAL MySQL 정리 (4) | 2025.08.27 |