[안드로이드] 테스트 코드에 대하여
안녕하세요. 오늘은 테스트 코드에 대한 이야기를 해보려 합니다.
사실 저 역시도 아직까지 테스트 코드를 한번도 경험해본 적이 없습니다.
그냥 “현업에서는 테스트 코드를 꼭 작성한다더라”라는 소문만 들었을 뿐,
지금껏 학생의 신분으로 참여할 수 있었던 제 모든 프로젝트에서는 “잘 돌아가니까 됐지 뭐”하고 넘어갔어요.
그러나 이제는 더 이상 물러날 곳이 없어졌습니다.

이번에 참여하게 된 프로젝트에서 테스트 코드를 적용하기로 결정이 되었거든요. 그래서 본격적으로 공부하고 정리해보려 합니다.
이번 글은 테스트 코드를 작성하기에 앞서 기본 개념을 이해하기 위한 글입니다.
그럼 시작해볼까요?
우선, 테스트 코드란 무엇인지부터 알아볼게요.
테스트 코드란 무엇인가?
한 마디로
내가 작성한 코드가 원하는 대로 동작하는지 검증하는 코드입니다.
조금 더 쉽게 말하면,
내가 짠 코드가 언제 실행해도 같은 결과를 내는지, 규칙을 잘 지키는지 자동으로 확인해주는 안전망 역할인 겁니다.
왜 테스트 코드를 써야 할까?
여기서 자연스럽게 이런 의문이 듭니다.
안전망 역할 하나만을 위해 테스트 코드를 작성해야 할까? 귀찮음이 더 클 거 같은데 ..
그래서 테스트 코드의 장점을 더 알아봤습니다.
1. 안정성 확보
코드는 항상 올바르게 동작하지 않을 수 있어요.
테스트 코드는 그 작은 버그들도 조기에 잡아내는 버그 탐지기 역할을 합니다.
→ “컴파일은 통과했는데, 런타임에서만 터짐” 같은 상황을 잡아줘요.
2. 리팩토링의 안정망
기존 코드를 수정해야 할 때 번뜩 “기존 기능까지 동작 안하면 어떡하지?”하는 두려움, 한번씩 다들 겪으셨을 거에요.
하지만 테스트 코드가 있으면 기능 유지 여부를 자동으로 확인할 수 있어서 마음 놓고 리팩토링할 수 있습니다.
3. 실행 가능한 문서
테스트 코드는 가장 확실한 사용 예시입니다.
“이 클래스는 어떻게 써야 하는지”를 설명해주는 문서 대신이 되기도 하는거죠. 협업할 때 너무 좋겠죠?
4. 코드의 신뢰와 유지보수성 향상
테스트를 짜려고 클래스를 분리하다 보면 더 모듈화되고 테스트하기 쉬운 구조로 자연스럽게 변경되는 경우가 많아요.
결국 코드가 깨끗해지고 유지보수가 쉬워지는 효과가 발생하게 되죠.
5. 빠른 피드백 루프
유닛 테스트는 단위별로 매번 실행되고, 실패 시 바로 알려줍니다.
따라서 버그가 어디서 났는지 빠르게 확인하고, 수정할 수 있어요.
이렇게 테스트 코드란 무엇인지, 장점은 뭐가 있는지 정리해보니
테스트 코드가 단순히 안전망만이 아니라 개발 전체를 건강하게 만들어주는 장치라는 걸 알 수 있죠.
그럼 또 이런 궁금증이 자연스럽게 생깁니다.
그럼 테스트 코드에는 어떤게 있지?
테스트 코드에도 여러 레벨이 있어요.
그중 가장 기본이 되는 것이 바로 유닛 테스트와 통합 테스트입니다.
유닛테스트와 통합테스트
테스트라는게 무조건 크고 복잡할 필요는 없다고 합니다.
어떤 건 아주 작은 단위(유닛)만 빠르게 검증하면 되고,
또 어떤 건 여러 컴포넌트가 협력해서 잘 동작하는지를 확인해야 합니다.
그래서 크게 두 가지로 나눠서 이야기를 해요.

유닛 테스트(Unit Test)
함수, 클래스 같은 작은 단위를 독립적으로 검증합니다.
속도가 빠르고, 실패 원인을 추적하기 쉽습니다.
통합 테스트(Integration Test)
여러 컴포넌트가 함께 잘 동작하는지 확인합니다.
실제 DB, 네트워크 같은 것까지 묶어서 검증하기 때문에 신뢰성은 높지만, 속도가 느리고 관리하기가 어렵습니다.
보통 현업에서는 유닛 테스트를 주로 작성하고, 중요한 시나리오만 통합테스트로 다루는 경우가 많다고 해요.
여기까지 이해하고 나면 또다시 고민이 생깁니다.
그럼 내 코드에서 무엇부터 테스트해야 하지?
많이 막막하죠. 하지만 바로 이 부분을 정리해주는 개념이 있습니다.
SUT와 DOC
테스트를 작성할 때 가장 먼저 던져야 할 질문은 “내가 지금 테스트하려는 대상은 무엇인가?”입니다.
그 답을 내는 순간 자연스럽게 SUT와 DOC가 드러나요.
SUT(System Under Test)
테스트의 주인공입니다.
내가 직접 검증하려는 클래스나 함수가 여기에 해당해요.
DOC(Dependency of Component)
하지만 SUT는 보통 혼자 동작하지 않습니다.
SUT가 의존하는 다른 객체들이 있는데, 이것들을 DOC라고 불러요.
예시1: Repository
아직 이해가 잘 안가셨을 것 같아요.
예시를 통해 다시 한번 알아봅시다.
class Repository(
private val local: LocalDataSource,
private val remote: RemoteDataSource
) {
fun fetchData(): String {
val cached = local.getData()
return cached ?: remote.getData()
}
}
이 코드에서
Repository → SUT
LocalDataSource, RemoteDataSource → DOC 입니다.
Repository가 올바르게 동작하는지 확인하려면, DOC인 Local/Remote가 제대로 된 값을 반환해줘야 의미 있는 결과를 얻을 수 있는거죠.
예시2: Calculator
좀 더 단순하게 생각해볼게요.
class Calculator(
private val a: A,
private val b: B
) {
fun complexFunction(): Int {
return a.xx() + b.xx()
}
}
여기서는
Calculator → SUT
A,B → DOC
만약 A.xx()가 항상 양수라는 규칙을 가져야 하는데,
어떤 상황에서 음수를 반환한다면 Calculator의 결과도 무너지겠죠?
따라서 Calculator를 테스트하려면 먼저 A와 B가 올바르게 동작한다는 걸 보장해야만 의미있는 테스트가 됩니다.
체인구조
위의 예시를 통해 이해하셨다면,
SUT와 DOC의 관계가 체인처럼 이어진다는 점도 이해하실 겁니다.
DOC → SUT → 그 위의 상위 SUT …
아래 단계(DOC)가 무너지면, 위 단계(SUT) 테스트도 다 무너집니다.
그래서 항상 DOC를 먼저 검증하고, 그 위에 SUT를 테스트하는 순서를 지켜야 해요.
DOC가 너무 많을 땐?
실제 프로젝트에서는 SUT가 수많은 DOC에 의존하기도 한다고 합니다.
예: Repository 하나를 테스트 하려고 해도
Repository → Local, Remote, Cache, Logger, AuthManager …과 같이 의존성이 계속 늘어나는 경우
그런데 내가 검증하고 싶은 건 Repository의 한 함수라면,
모든 DOC를 일일이 구현하고 검증하는 건 비효율적이고 귀찮겠죠.
이럴 때 사용하는 기법이 바로 테스트 더블(Test Double)입니다.
테스트 더블(Test Double)
테스트 더블은 말 그대로 실제 객체를 대신해서 테스트에 참여하는 대역이라고 할 수 있어요.
DOC가 많을 때 전부 다 진짜 객체로 준비하기는 벅차니까,
이 역할 정도만 흉내 내줄 대체제를 세워서 테스트를 단순화하는 거죠.
테스트 더블에도 여러 가지 종류가 있는데, 그중 대표적인게 바로 Fake와 Mock입니다.
Fake
Fake는 말 그대로 가짜 구현체입니다.
하지만 완전히 없는 게 아니라, 실제와 비슷하게 흉내 내는 방식이에요.
예를 들어 LocalDataSource라는 인터페이스가 있다고 해봅시다.
interface LocalDataSource {
fun getData(): String?
}
원래라면 이걸 Room DB 같은 걸로 구현해야겠죠.
하지만 테스트에서는 DB까지 띄우고 싶지 않잖아요.
이럴 땐 그냥 간단히 메모리에 저장하는 FakeLocalDataSource를 만들 수 있어요.
class FakeLocalDataSource : LocalDataSource {
private var data: String? = null
override fun getData(): String? = data
fun saveData(value: String) { data = value }
}
이렇게 하면 DB는 전혀 쓰지 않으면서도,
테스트에서는 “데이터를 반환한다”라는 역할만 흉내 내는 거죠.
이러면 실제 동작과 비슷하게 흉내 내기 때문에 신뢰성이 높습니다.
하지만 필요한 함수들을 전부 구현해야 해서 귀찮죠.
Mock
Mock은 Fake와 달리 아예 구현체를 만들지 않고,
테스트 코드 안에서 즉석에서 동작을 정의하는 방식입니다.
예를 들어 Car라는 클래스가 있다고 할게요.
val mockCar = mock<Car>()
whenever(mockCar.drive(Direction.NORTH)).thenReturn("OK")
이렇게 하면 Car 클래스 내부에 실제 구현이 없어도,
테스트 실행 시점에 “drive(Direction.NORTH)”를 호출하면 무조건 OK를 반환한다”라는 동작을 부여할 수 있습니다.
이러면 코드 몇 줄로 끝나기 때문에 간편하고 빠릅니다.
하지만 실제 Car 클래스의 구현이 바뀌어도, 이 Mock은 여전히 OK만 반환하겠죠.
→ 실제 코드 변경 사항을 반영하지 못해서 신뢰성이 떨어질 수 있어요.
Fake vs Mock
그럼 이제 질문이 생깁니다.
Fake랑 Mock 중 뭘 써야하지?
정답은 둘 다 쓴다는 겁니다.
회사마다, 심지어 팀마다 선호도가 달라요.
fake와 mock 각각의 장점과 단점이 확실하기 때문에
보통은 이렇게 쓴다고 해요.
핵심 로직 → Fake(신뢰성 확보)
부수적인 의존성 → Mock(빠른 작성)
즉, 프로젝트 상황에 따라 적절히 섞어 쓰는거죠.
오늘은 테스트 코드를 작성하기 전에 꼭 알아야 할 개념들을 위주로 살펴봤습니다.
테스트 코드를 한번도 안 써본 입장에서 시작했지만,
이제는 적어도 왜 필요하고, 무엇부터 어떻게 접근해야 하는지 정도는 감이 잡히는 것 같아요.
나중에는 실제로 Kotlin + JUnit 환경에서 간단한 테스트 코드를 작성해보면서,
오늘 정리한 개념들이 코드 안에서 어떻게 쓰이는지 살펴볼게요.

감사합니다.