Test-Driven Development(TDD)은 코드 구현 전에 테스트를 작성하는 개발 방법론입니다. 개발자는 먼저 예상되는 동작을 실패하는 테스트 형태로 공식화한 다음, 이를 통과하기 위한 최소한의 코드를 작성하고 그 후에 결과를 리팩토링합니다. Martin Fowler(2023)에 따르면, TDD는 테스트 기법이 아니라 아키텍처를 훈련시키고 코드 작성 단계에서 결함 수를 줄이는 설계 기법입니다.
핵심 사항
Test-Driven Development는 자동화된 테스트가 프로덕션 코드 작성을 결정하는 소프트웨어 개발 방식입니다. 코드를 작성한 후 테스트하는 전통적인 접근 방식과 달리, TDD는 순서를 반대로 합니다: 먼저 테스트를 작성하고, 그 테스트를 통과하는 코드를 작성합니다.
TDD의 창시자는 Kent Beck으로, 1990년대 후반 Extreme Programming(XP) 방법론의 일부로 이 방식을 공식화했습니다. “Test-Driven Development: By Example”(2002) 책에서 Beck은 TDD의 다섯 가지 규칙을 설명했으며, 이는 표준이 되었습니다: 프로덕션 코드 전에 테스트를 작성하고, 테스트를 통과하는 데 충분한 코드만 작성하며, 각 주기 후에 리팩토링하십시오.
첫 번째 원칙 — 테스트가 인터페이스를 정의한다. 개발자는 컴포넌트가 어떻게 구현될지 생각하기 전에 어떻게 사용될지 생각해야 합니다. 이는 처음부터 깔끔한 API를 형성합니다.
두 번째 원칙 — 최소한의 구현. 테스트가 작성되면 개발자는 이를 통과하는 데 필요한 만큼만 프로덕션 코드를 작성합니다 — 한 줄도 더 작성하지 않습니다. 이는 Martin Fowler가 Speculative Generality라고 부르는 시기상조의 추상화와 과도한 복잡성을 방지합니다.
TDD와 사후 테스트의 주요 차이점은 순서의 훈련입니다. TDD에서 테스트는 코드를 검증할 뿐만 아니라 그 구조를 안내합니다. Microsoft Research 연구(Nagappan et al., 2008)에 따르면, TDD를 적용하는 팀은 전통적인 접근 방식을 사용하는 팀에 비해 결함 밀도가 40~90% 감소합니다.
Red-Green-Refactor 주기는 각 새 테스트마다 반복되는 3단계 시퀀스입니다. Red: 통과하지 않는 테스트를 작성합니다. Green: 테스트를 통과시키는 최소 코드를 작성합니다. Refactor: 동작을 변경하지 않고 코드를 개선합니다.
개발자는 아직 구현되지 않은 기능을 확인하는 테스트를 작성합니다. 이 단계에서 테스트는 실패해야 합니다 — 이는 테스트가 실제로 무언가를 검증하고 있음을 확인합니다. Android 개발 환경에서 JUnit 5 프레임워크는 실패한 테스트에 빨간색 표시기를 보여주며, 이 단계의 이름이 여기서 유래했습니다.
class CalculatorTest {
fun testAddition() {
val result = Calculator().add(2, 3)
Assertions.assertEquals(5, result)
}
}
이 단계에서는 테스트를 통과하기에 충분한 최소한의 프로덕션 코드를 작성합니다. 중복 없음 — 초록색 표시기에 필요한 것만 작성합니다. 구현이 상수로 가능하다면 상수로 둡니다. 리팩토링은 새 테스트가 나타날 때 다음 단계에서 이루어집니다.
class Calculator {
fun add(a: Int, b: Int): Int {
return a + b
}
}
초록색 테스트는 리팩토링을 위한 보험입니다. 개발자는 테스트가 예상 동작에서의 이탈을 즉시 감지할 것이라는 확신을 가지고 구현을 다시 작성하거나, 성능을 최적화하거나, 가독성을 개선할 수 있습니다. Android 모바일 개발에서 이 단계는 공통 인터페이스를 추출하고 코드 중복을 줄이는 데 특히 중요합니다.
모바일 프로젝트에 TDD를 적용하면 학술 연구와 선도적인 개발 스튜디오의 실무 모두에서 확인된 측정 가능한 이점을 제공합니다.
4개 산업 프로젝트에 대한 IBM 연구(Bhat & Nagappan, 2006)는 TDD를 사용하는 팀이 전통적 방식으로 작업하는 유사한 팀에 비해 40% 적은 결함을 생성한다는 것을 보여주었습니다. Google Play 출시 후 버그 수정 비용이 코드 작성 단계보다 훨씬 높은 모바일 개발에서 이 지표는 매우 중요합니다.
TDD로 작성된 테스트는 API의 살아있는 문서 역할을 합니다. 프로젝트에 합류하는 개발자는 테스트를 읽고 각 컴포넌트가 어떻게 사용되어야 하는지 이해할 수 있습니다. 이는 팀 이직률이 높은 상황 — 모바일 스튜디오의 일반적인 문제 — 에서 특히 가치가 있습니다.
90%를 초과하는 코드 커버리지를 통해 개발자는 무언가를 깨뜨릴 걱정 없이 리팩토링할 수 있습니다. Google은 “Software Engineering at Google”(2020) 책에서 테스트 커버리지를 수백만 줄의 코드를 가진 프로젝트에서 코드베이스를 깨끗하게 유지하는 핵심 요소라고 말합니다.
모바일 개발의 TDD 생태계에는 Android와 iOS 모두를 위한 단위 테스트, 모킹 및 UI 컴포넌트 검증 도구가 포함됩니다.
| 도구 | 플랫폼 | 목적 |
|---|---|---|
| JUnit 5 | Android(Kotlin/Java) | 단위 테스트를 위한 기본 프레임워크 |
| Mockito | Android | 목 객체 생성 및 호출 검증 |
| MockK | Android(Kotlin) | Kotlin-first 구문 및 코루틴 지원 모킹 |
| Turbine | Android | Kotlin Flow 및 반응형 스트림 테스트 |
| XCTest | iOS(Swift) | 표준 테스트 프레임워크 |
Kotlin 기반 Android 프로젝트의 표준 스택에는 JUnit 5 + MockK가 포함됩니다. MockK는 추가 설정 없이 Kotlin의 일급 기능(sealed class, 코루틴, suspend 함수)을 지원하므로 Mockito보다 선호됩니다.
iOS 개발에서 TDD는 XCTest를 통해 구현됩니다 — 어설션, 테스트 클래스 및 Xcode Server나 GitHub Actions를 통한 CI/CD 통합을 제공하는 Apple의 내장 프레임워크입니다. iOS에서 모킹을 위해 Cuckoo 및 OHHTTPStubs 라이브러리가 사용됩니다.
Android용 Kotlin의 실제 TDD 시나리오를 살펴보겠습니다 — 사용자 리포지토리 테스트입니다. 먼저 테스트를 작성하고, 그 테스트를 통과하는 구현을 작성합니다.
class UserRepositoryTest {
private val api = mockk<UserApi>()
private val dao = mockk<UserDao>()
private val repo = UserRepository(api, dao)
fun `when api returns user then cache and emit`() = runTest {
val user = User(1, "Alice")
coEvery { api.getUser(1) } returns user
every { dao.insert(user) } returns Unit
val result = repo.getUser(1)
assertEquals(user, result)
verify { dao.insert(user) }
}
}
class UserRepository(
private val api: UserApi,
private val dao: UserDao
) {
suspend fun getUser(id: Int): User {
val user = api.getUser(id)
dao.insert(user)
return user
}
}
첫 번째 테스트를 통과한 후 두 번째 테스트를 추가합니다 — 네트워크 오류 시 동작을 확인합니다. 이제 테스트는 API가 실패할 때 리포지토리가 캐시에서 데이터를 반환해야 한다고 결정합니다.
fun `when api fails then return cached user`() = runTest {
val cached = User(1, "Cached Alice")
coEvery { api.getUser(1) } throws IOException()
every { dao.getById(1) } returns cached
val result = repo.getUser(1)
assertEquals(cached, result)
}
TDD로의 전환은 방법론의 모든 이점을 무효화할 수 있는 전형적인 실수를 수반합니다. 이러한 함정을 이해하면 팀이 더 효과적으로 방식을 도입할 수 있습니다.
첫 번째이자 가장 흔한 안티패턴은 하나의 테스트에서 너무 큰 기능 범위를 테스트하는 것입니다. 테스트는 정확히 하나의 어설션을 검증해야 합니다. 테스트가 실패하면 개발자는 추가 디버깅 없이 무엇이 고장 났는지 정확히 알아야 합니다.
두 번째 실수는 처음부터 통과하는 테스트를 작성하는 것입니다. 테스트가 한 번이라도 빨간색이 아니었다면 실제로 무언가를 검증하고 있다는 확신이 없습니다. 규칙: 실패하는 것을 본 적이 없는 테스트는 절대 신뢰하지 마십시오.
세 번째 흔한 실수는 초록색 단계에서 멈추는 것입니다. 리팩토링은 선택 사항이 아니라 주기의 필수 단계입니다. 리팩토링 없이 코드베이스는 저하되고, 테스트는 깨지기 쉬우며, TDD의 이점이 사라집니다.
자주 묻는 질문
TDD는 무엇보다도 설계 기법이지 테스트 기법이 아닙니다. TDD에서 테스트는 명세의 역할을 합니다: 구현 전에 컴포넌트 API를 정의합니다. Kent Beck 자신은 TDD를 “테스트의 훈련이 아닌 설계의 훈련”이라고 부릅니다.
Microsoft Research 연구에 따르면, TDD가 습관이 되려면 팀에 3~6개월의 지속적인 연습이 필요합니다. 처음 2~3주는 생산성이 15~30% 떨어지지만, 적응 후에는 디버깅 시간 감소 덕분에 원래 수준으로 돌아오거나 그 이상이 됩니다.
그렇지만 제한 사항이 있습니다. UI 로직(ViewModel, State)에는 TDD가 직접 적용 가능합니다. 시각적 컴포넌트(Compose UI, SwiftUI Views)의 경우 스냅샷 테스트가 TDD를 보완하지만 대체하지는 않습니다. 비즈니스 로직과 표현을 분리하는 것이 좋습니다.
레거시 코드에는 “특성화 테스트” 전략이 권장됩니다 — 기존 동작에 대해 테스트를 작성한 다음 코드를 리팩토링합니다. 이 접근 방식은 Michael Feathers의 저서 “Working Effectively with Legacy Code”(2004)에 설명되어 있으며 TDD를 단계적으로 도입할 수 있습니다.
TDD와 Clean Architecture는 서로를 강화합니다. 클린 아키텍처는 계층 간 명확한 경계를 요구하며, TDD는 개발자가 테스트를 통해 이러한 경계를 설계하도록 강제합니다. 도메인 계층은 모크 의존성과 함께 격리되어 테스트되고, 데이터 계층은 통합 테스트를 통해 테스트됩니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.