Android 개발/Kotlin

KMP commonTest 한 번 짜서 안드·iOS 양쪽 검증한 구조 정리

stackD 2026. 8. 3. 18:00
반응형

 

얼마 전에 commonTest에 환율 계산 테스트를 딱 하나 짜놓고 Run 버튼을 눌렀더니, 안드로이드 스튜디오(Android Studio)가 '어느 타깃에서 돌릴래?' 하고 팝업을 띄우더라고요. 그때 살짝 멘붕이 왔습니다.

 

그동안 안드로이드 단일 모듈만 만지다 보니, 테스트는 그냥 JVM에서 도는 거라고 생각했거든요. 근데 코틀린 멀티플랫폼(Kotlin Multiplatform)에서는 그 전제가 통째로 깨집니다. 제가 헤맸던 부분이랑 결국 정리한 구조를 풀어볼게요.

 

KMP commonTest 한 번 짜면 타깃별로 N번 검증돼요

가장 먼저 머리에 박아야 할 게 이거였어요. commonTest에 짠 코드는 JVM 전용이 아닙니다. 각 타깃(JVM, iOS 등)별로 개별적으로 컴파일되고 따로 실행됩니다.

 

쉽게 설명하자면, 코드는 한 번 쓰지만 검증은 안드로이드용으로 한 번, iOS용으로 또 한 번, 이렇게 N번 돈다는 얘기입니다. 그래서 JVM에서 초록불이 떴다고 iOS도 통과한다는 보장이 없어요. 정수 오버플로 처리나 소수점 반올림 같은 데서 한쪽만 깨지는 일이 생길 수 있거든요.

 

여기서 제가 또 하나 착각했던 게 있어요. 공유 모듈 안의 androidTest 폴더를 보고 "아 이거 디바이스 띄워야 하는 계측 테스트구나" 했는데, 아니더군요. 앱 모듈과 달리 공유 모듈의 androidTest는 디바이스 없이 JVM의 JUnit 위에서 도는 유닛 테스트 용도입니다. 참고로 최신 코틀린 버전에서는 이 소스셋 이름이 androidUnitTest로 바뀌었으니, 뒤에서 말할 버전 이슈와 함께 확인해 두시는 게 좋아요.

 

그리고 commonTest에서는 kotlin.test 같은 멀티플랫폼 라이브러리만 써야 해요. 안드로이드 전용 API를 끌어다 쓰면 iOS 컴파일 단계에서 바로 막히거든요.

 

 

공통 테스트에 둘 코드 vs 네이티브로 뺄 코드

배치 기준만 잡으면 절반은 끝납니다. 제가 쓰는 기준은 딱 하나예요. 플랫폼 API와 무관한 순수 비즈니스 로직만 commonTest에 둔다는 겁니다.

 

  1. 환율 계산, 할인율, 입력 검증 같은 순수 도메인 로직 → commonTest. 양쪽 다 똑같이 돌아야 하는 코드라서요.
  2. expect/actual로 플랫폼별 구현이 갈리는 코드(파일 경로, 시간대 처리 등) → commonTest에서 빼기. 한쪽 타깃에서 엉뚱하게 실패할 수 있어요.
  3. UI나 네이티브 SDK에 의존하는 코드 → 아예 공통 테스트 대상이 아님.

 

IDE에서는 테스트 파일 우클릭하면 android, iosSimulatorArm64 같은 타깃을 골라서 개별 실행하고 디버깅할 수 있습니다. 함수 옆 실행 아이콘 눌러서 단일 테스트만 특정 타깃으로 돌리는 것도 돼요.

 

 

네이티브 테스트는 androidTest·iosTest로 분리

actual 구현이 플랫폼마다 다른 코드는 공통에서 검증하면 안 됩니다. 예를 들어 저장소 구현이 안드로이드는 SharedPreferences, iOS는 UserDefaults로 갈린다면, 각각 androidTestiosTest에서 따로 검증해야 해요.

 

SwiftUI나 안드로이드 View처럼 UI에 묶인 코드는 한 발 더 나갑니다. 이건 코틀린 테스트 영역이 아니라 Espresso, XCTest 같은 네이티브 프레임워크로 가야 하는 자리예요. 멀티플랫폼 테스트의 책임 경계가 여기서 갈린다고 보시면 되겠습니다.

 

코루틴·Flow 비동기 테스트도 결정론적으로 검증

코루틴이랑 Flow가 들어가면 또 까다로워지는데, 다행히 공통 영역에서 잡을 수 있어요.

 

kotlinx-coroutines-testrunTest를 쓰면 가상 시간으로 돌기 때문에, 실제로 1초 delay를 걸어도 테스트는 순식간에 끝납니다. Flow의 상태 방출 순서는 Turbine 라이브러리의 awaitItem()으로 하나씩 받아서 검증하면 깔끔하더라고요.

 

이걸 굳이 commonTest에 두는 이유가 있어요. 플랫폼마다 디스패처 동작이 미묘하게 다른데, 공통에서 양쪽 타깃으로 돌려보면 그 차이를 일찍 잡을 수 있습니다. 커버리지는 Kover 플러그인으로 확인하고 있어요.

 

KMP CI에서 allTests 돌릴 때 macOS 러너가 발목

마지막 관문이 CI였어요. ./gradlew allTests를 돌리면 모든 타깃 테스트를 실행하고 build/reports/tests/allTests/index.html에 통합 리포트가 떨어집니다. 여기까지는 좋은데, 함정은 iOS 쪽이에요.

 

iOS 타깃 테스트는 macOS 환경에서만 돌아갑니다. 리눅스 러너로는 안 되고 맥 러너를 붙여야 하는데, 이게 CI 비용을 확 올려요. 게다가 iOS 시뮬레이터는 컴파일하고 부팅하는 시간 때문에 느린 편입니다.

 

그래서 제가 잡은 전략은 이래요. 평소 빠른 피드백이 필요한 단계에서는 :공유모듈:testDebugUnitTest로 JVM 타깃만 돌립니다. iOS 시뮬레이터 테스트(iosSimulatorArm64Test)는 PR 머지 직전에만 맥 러너로 검증하는 식으로요. TDD 사이클을 JVM으로 빠르게 돌리고, iOS는 게이트키퍼로 두는 겁니다.

 

한 가지 주의하실 건, 이 Gradle 태스크명이나 소스셋 구성이 Kotlin·AGP 버전 따라 바뀔 수 있다는 점이에요. 버전 올릴 때 태스크명부터 확인하시는 게 안전합니다.

 


결국 핵심은 "어디서 도느냐"가 아니라 "어디에 무엇을 두느냐"였어요. 순수 로직은 공통, 플랫폼 의존은 네이티브, iOS는 맥 러너. 이 세 칸만 머릿속에 그려두면 그 팝업 앞에서 더 이상 당황할 일은 없으실 거라 봅니다.

 

이번 글에선 공유 모듈의 로직 테스트까지만 다뤘는데, expect/actual 구현을 양쪽에서 어떻게 모킹하고 검증하는지는 다음 편에서 더 깊이 보실 수 있어요.

 

 

반응형
LIST