Android 앱 테스트 자동화 완벽 가이드 — JUnit, MockK, Espresso, UI Automator와 GitHub Actions
Android 앱을 개발할 때 기능이 정상적으로 동작하는지 확인하는 가장 간단한 방법은 직접 실행해 보는 것입니다.
검색 버튼을 누르고, 상세 화면으로 이동하고, 좋아요를 등록한 뒤 앱을 다시 실행해 데이터가 유지되는지 확인합니다.
하지만 앱의 기능이 늘어나면 모든 동작을 수동으로 테스트하기 어려워집니다.
특히 다음과 같은 상황에서 문제가 발생하기 쉽습니다.
SQLite 데이터베이스 구조를 변경한 뒤 기존 검색 기능이 동작하지 않음
Retrofit API 응답 형식이 변경되어 데이터 파싱 오류가 발생함
ViewModel을 수정했더니 로딩 상태가 사라지지 않음
RecyclerView의 좋아요 버튼을 누르면 다른 아이템의 상태가 변경됨
Fragment를 이동한 뒤 이전 화면의 상태가 초기화됨
R8을 활성화한 릴리스 빌드에서만 오류가 발생함
Android 버전 업데이트 이후 특정 UI 동작이 실패함
이러한 문제를 줄이기 위해 필요한 것이 **자동화 테스트(Automated Testing)**입니다.
자동화 테스트는 개발자가 매번 앱을 직접 조작하지 않아도 코드와 화면의 동작을 반복적으로 검증할 수 있도록 도와줍니다.
이번 글에서는 Kotlin 기반 Android 앱에서 JUnit, MockK, Espresso, UI Automator를 이용해 테스트를 작성하고 GitHub Actions에서 자동으로 실행하는 방법을 정리하겠습니다.
작성 기준: 2026년 10월. Android Gradle Plugin, AndroidX Test 및 테스트 라이브러리 버전은 변경될 수 있으므로 실제 적용 시 공식 문서를 확인해야 합니다. 아래 코드는 설명용 예제이며 실제 프로젝트에서 실행 검증한 결과를 의미하지 않습니다.
1. Android 앱 테스트가 필요한 이유
앱을 수정할 때마다 기존 기능을 모두 확인하는 것은 시간이 많이 걸립니다.
예를 들어 조류 정보 앱에 다음과 같은 기능이 있다고 가정하겠습니다.
조류 이름 검색
목·과·속 필터
상세 정보 조회
좋아요 등록
메모 작성
이미지 슬라이드
SQLite 데이터베이스 업데이트
이 중 SQLite 검색 조건을 수정하면 검색 결과뿐 아니라 필터와 상세 화면에도 영향을 줄 수 있습니다.
자동화 테스트를 작성하면 변경 이후에도 중요한 기능이 정상적으로 동작하는지 확인할 수 있습니다.
테스트의 핵심 목적은 단순히 코드 실행 여부를 확인하는 것이 아닙니다.
기능이 의도한 결과를 반환하는지 검증하고, 기존에 정상 동작하던 기능이 수정 이후에도 유지되는지 확인하는 것입니다.
2. Android 테스트 종류 이해하기
Android 테스트는 크게 세 가지로 구분할 수 있습니다.
| 테스트 종류 | 주요 대상 | 실행 환경 |
|---|---|---|
| Local Unit Test | 순수 Kotlin 로직, ViewModel 일부 | JVM |
| Instrumented Test | SQLite, Android Framework, UI | Android 기기·에뮬레이터 |
| UI Test | 버튼 클릭, 화면 이동, 사용자 동작 | Android 기기·에뮬레이터 |
일반적으로 Local Unit Test가 빠르게 실행됩니다.
반면 Instrumented Test는 실제 Android 환경이 필요하므로 상대적으로 실행 시간이 길 수 있습니다.
따라서 모든 테스트를 UI 테스트로 작성하기보다 테스트 대상에 맞는 도구를 선택하는 것이 좋습니다.
3. Android 프로젝트 테스트 디렉터리
Android 프로젝트에는 일반적으로 다음 디렉터리가 있습니다.
app/
└── src/
├── main/
│ └── java/
│
├── test/
│ └── java/
│
└── androidTest/
└── java/각 디렉터리의 역할은 다음과 같습니다.
src/test는 JVM에서 실행하는 Local Unit Test를 작성하는 위치입니다.
src/androidTest는 Android 기기나 에뮬레이터에서 실행하는 테스트를 작성하는 위치입니다.
예를 들어 다음과 같이 구성할 수 있습니다.
src/test/java/
├── BirdSearchTest.kt
├── BirdRepositoryTest.kt
└── BirdViewModelTest.kt
src/androidTest/java/
├── BirdDatabaseTest.kt
├── BirdSearchUiTest.kt
└── BirdNavigationTest.kt4. 테스트 라이브러리 의존성 추가
app/build.gradle.kts에 테스트 라이브러리를 추가합니다.
dependencies {
testImplementation(
"junit:junit:4.13.2"
)
testImplementation(
"io.mockk:mockk:<version>"
)
testImplementation(
"org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>"
)
androidTestImplementation(
"androidx.test.ext:junit:<version>"
)
androidTestImplementation(
"androidx.test.espresso:espresso-core:<version>"
)
androidTestImplementation(
"androidx.test:runner:<version>"
)
androidTestImplementation(
"androidx.test.uiautomator:uiautomator:<version>"
)
}<version>에는 현재 프로젝트와 호환되는 버전을 지정합니다.
특히 kotlinx-coroutines-test는 프로젝트에서 사용하는 Kotlin Coroutine 라이브러리와 호환되는 버전을 선택해야 합니다.
Instrumented Test를 실행하려면 테스트 러너도 설정합니다.
android {
defaultConfig {
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}
}5. JUnit으로 가장 간단한 테스트 작성하기
먼저 순수 Kotlin 함수를 테스트해 보겠습니다.
예를 들어 조류 이름 검색에 사용할 문자열 정규화 함수가 있다고 가정하겠습니다.
fun normalizeKeyword(
keyword: String
): String {
return keyword
.trim()
.lowercase()
}이 함수는 앞뒤 공백을 제거하고 소문자로 변환합니다.
JUnit 테스트는 다음과 같습니다.
import org.junit.Assert.assertEquals
import org.junit.Test
class BirdSearchTest {
@Test
fun keyword_is_trimmed_and_lowercased() {
val result = normalizeKeyword(
" Sparrow "
)
assertEquals(
"sparrow",
result
)
}
}@Test가 붙은 함수는 테스트 실행 대상이 됩니다.
assertEquals()는 실제 결과가 예상 결과와 일치하는지 확인합니다.
두 값이 다르면 테스트가 실패합니다.
6. 여러 입력값 테스트하기
하나의 함수는 다양한 입력값을 처리해야 할 수 있습니다.
예를 들어 다음 상황을 테스트할 수 있습니다.
정상적인 문자열
앞뒤 공백이 있는 문자열
빈 문자열
한글 문자열
대소문자가 섞인 문자열
class BirdSearchTest {
@Test
fun empty_keyword_returns_empty_string() {
assertEquals(
"",
normalizeKeyword(" ")
)
}
@Test
fun korean_keyword_is_preserved() {
assertEquals(
"참새",
normalizeKeyword(" 참새 ")
)
}
}테스트 이름은 무엇을 검증하는지 이해할 수 있도록 작성하는 것이 좋습니다.
7. MockK란?
MockK는 Kotlin에서 Mock 객체를 만들기 위한 라이브러리입니다.
Mock 객체는 실제 객체 대신 테스트에서 원하는 동작을 제공하도록 구성한 객체입니다.
예를 들어 ViewModel을 테스트할 때 실제 서버에 연결할 필요는 없습니다.
Repository가 특정 데이터를 반환한다고 가정하고 ViewModel의 동작만 검증할 수 있습니다.
ViewModel Test
│
▼
Mock Repository
│
▼
미리 정의한 테스트 데이터이렇게 하면 네트워크 연결 상태와 서버 응답에 영향을 받지 않는 테스트를 작성할 수 있습니다.
8. Repository를 Mock으로 대체하기
먼저 Repository 인터페이스를 정의하겠습니다.
interface BirdRepository {
suspend fun getBirds(): List<Bird>
}데이터 클래스는 다음과 같습니다.
data class Bird(
val id: Long,
val name: String
)MockK 테스트를 작성합니다.
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class BirdRepositoryTest {
private val repository =
mockk<BirdRepository>()
@Test
fun repository_returns_bird_list() = runTest {
val expected = listOf(
Bird(1, "참새"),
Bird(2, "까치")
)
coEvery {
repository.getBirds()
} returns expected
val result = repository.getBirds()
assertEquals(
2,
result.size
)
assertEquals(
"참새",
result[0].name
)
}
}이 예제는 MockK의 기본 사용법을 설명합니다.
다만 Mock 객체에 설정한 반환값을 다시 확인하는 테스트만으로는 실제 Repository 구현의 정확성을 검증할 수 없습니다.
실제 프로젝트에서는 Mock Repository를 사용하는 ViewModel이나 UseCase의 동작을 검증하는 데 활용하는 것이 더 의미 있습니다.
9. Coroutine 테스트에 runTest 사용하기
Coroutine을 사용하는 함수는 일반적인 동기 함수와 실행 방식이 다릅니다.
runTest는 Coroutine 테스트를 위한 환경을 제공합니다.
@Test
fun coroutine_test_example() = runTest {
val result = repository.getBirds()
assertEquals(
2,
result.size
)
}runTest는 테스트 스케줄러를 이용해 Coroutine의 실행을 제어할 수 있습니다.
하지만 실제 네트워크 요청이나 모든 외부 비동기 작업이 자동으로 가상 시간에 따라 실행되는 것은 아닙니다.
테스트 대상이 사용하는 Dispatcher와 외부 의존성을 적절히 제어해야 합니다.
10. ViewModel을 테스트하기 좋은 구조
ViewModel이 Repository를 직접 생성하면 테스트하기 어렵습니다.
예를 들어 다음과 같은 구조입니다.
class BirdViewModel : ViewModel() {
private val repository =
RealBirdRepository()
}이 경우 테스트에서도 실제 Repository에 의존하게 됩니다.
대신 생성자를 통해 Repository를 전달하는 것이 좋습니다.
class BirdViewModel(
private val repository: BirdRepository
) : ViewModel() {
private val _birds =
MutableStateFlow<List<Bird>>(
emptyList()
)
val birds: StateFlow<List<Bird>> =
_birds.asStateFlow()
fun loadBirds() {
viewModelScope.launch {
_birds.value =
repository.getBirds()
}
}
}이렇게 하면 실제 앱에서는 Real Repository를 전달하고, 테스트에서는 Mock Repository를 전달할 수 있습니다.
11. Main Dispatcher 테스트 규칙 만들기
ViewModel의 viewModelScope는 Main Dispatcher를 사용합니다.
하지만 일반적인 JVM 테스트 환경에는 Android 메인 루퍼가 없습니다.
따라서 테스트에서 Main Dispatcher를 교체해야 합니다.
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher =
StandardTestDispatcher()
) : TestWatcher() {
override fun starting(
description: Description
) {
Dispatchers.setMain(dispatcher)
}
override fun finished(
description: Description
) {
Dispatchers.resetMain()
}
}이 규칙은 테스트 실행 전 Main Dispatcher를 교체하고 테스트가 끝나면 원래 상태로 되돌립니다.
12. ViewModel 테스트 작성하기
앞에서 만든 MainDispatcherRule을 사용해 ViewModel을 테스트하겠습니다.
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class BirdViewModelTest {
@get:Rule
val mainDispatcherRule =
MainDispatcherRule()
private val repository =
mockk<BirdRepository>()
@Test
fun loadBirds_updates_state() = runTest {
val expected = listOf(
Bird(1, "참새"),
Bird(2, "까치")
)
coEvery {
repository.getBirds()
} returns expected
val viewModel = BirdViewModel(
repository
)
viewModel.loadBirds()
advanceUntilIdle()
assertEquals(
expected,
viewModel.birds.value
)
}
}이 테스트에서는 Repository가 데이터를 반환했을 때 ViewModel의 상태가 변경되는지 확인합니다.
다만 실제 프로젝트에서는 테스트 스케줄러를 공유하도록 Dispatcher 규칙과 runTest를 구성해야 합니다.
위와 같이 Main Dispatcher를 먼저 설정하면 일반적인 Coroutine 테스트 환경에서 스케줄러 공유가 가능하지만, 테스트 코드의 Dispatcher 생성 시점에 따라 동작이 달라질 수 있습니다.
13. 로딩과 오류 상태도 테스트하기
성공 결과만 테스트하는 것은 충분하지 않습니다.
실제 앱에서는 다음 상태를 검증하는 것이 좋습니다.
로딩 시작
데이터 조회 성공
데이터가 없는 경우
네트워크 오류
데이터베이스 오류
작업 취소
예를 들어 UI 상태를 다음과 같이 정의할 수 있습니다.
sealed interface BirdUiState {
data object Loading : BirdUiState
data class Success(
val birds: List<Bird>
) : BirdUiState
data class Error(
val message: String
) : BirdUiState
}테스트에서는 Repository가 예외를 발생시키도록 설정할 수 있습니다.
coEvery {
repository.getBirds()
} throws IOException("Network error")이후 ViewModel이 해당 예외를 적절한 UI 오류 상태로 변환하는지 확인합니다.
단, ViewModel에서 예외 처리가 구현되어 있어야 하며, CancellationException은 일반적인 오류로 변환하지 않도록 주의해야 합니다.
14. SQLite 테스트는 왜 별도로 필요한가?
SQLite를 직접 사용하는 앱에서는 SQL 쿼리가 실제로 정상 동작하는지 확인해야 합니다.
예를 들어 다음과 같은 쿼리를 사용한다고 가정하겠습니다.
SELECT *
FROM korean_birds
WHERE SpeciesKor LIKE ?이 쿼리는 문자열 검색을 수행합니다.
하지만 실제 SQLite에서는 다음과 같은 문제가 발생할 수 있습니다.
테이블 이름 오류
컬럼 이름 오류
JOIN 조건 오류
LIKE 검색 결과 오류
NULL 처리 문제
데이터베이스 버전 차이
UPSERT 동작 오류
이러한 문제는 Mock 객체만으로 검증하기 어렵습니다.
따라서 실제 SQLite 데이터베이스를 사용하는 Instrumented Test가 유용합니다.
15. SQLiteOpenHelper 테스트 작성하기
예를 들어 다음과 같은 SQLiteOpenHelper가 있다고 가정하겠습니다.
class BirdDatabaseHelper(
context: Context,
name: String = "birds.db"
) : SQLiteOpenHelper(
context,
name,
null,
1
) {
override fun onCreate(
db: SQLiteDatabase
) {
db.execSQL(
"""
CREATE TABLE korean_birds (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)
""".trimIndent()
)
}
override fun onUpgrade(
db: SQLiteDatabase,
oldVersion: Int,
newVersion: Int
) {
// 실제 앱에서는 스키마 마이그레이션 구현
}
}Instrumented Test에서는 실제 Android Context를 사용할 수 있습니다.
@RunWith(AndroidJUnit4::class)
class BirdDatabaseTest {
@Test
fun insert_and_query_bird() {
val context =
ApplicationProvider
.getApplicationContext<Context>()
val helper = BirdDatabaseHelper(
context,
null
)
val db = helper.writableDatabase
try {
db.execSQL(
"""
INSERT INTO korean_birds
(id, name)
VALUES (1, '참새')
""".trimIndent()
)
db.rawQuery(
"SELECT name FROM korean_birds WHERE id = ?",
arrayOf("1")
).use { cursor ->
assertTrue(
cursor.moveToFirst()
)
assertEquals(
"참새",
cursor.getString(0)
)
}
} finally {
helper.close()
}
}
}이 예제에서는 null 데이터베이스 이름을 사용해 메모리 기반 데이터베이스를 생성합니다.
테스트가 서로 영향을 주지 않도록 데이터베이스를 독립적으로 구성하는 것이 좋습니다.
필요한 주요 의존성에는 AndroidX Test Core가 포함됩니다.
androidTestImplementation(
"androidx.test:core:<version>"
)16. SQLite 마이그레이션 테스트
SQLite 데이터베이스를 업데이트하는 앱에서는 기존 사용자 데이터가 유지되는지 확인해야 합니다.
예를 들어 다음과 같은 시나리오입니다.
기존 DB 버전 1
│
├── 사용자 메모 저장
└── 좋아요 등록
│
▼
DB 버전 2 업데이트
│
▼
기존 메모 유지 여부 확인
│
▼
좋아요 유지 여부 확인테스트에서는 다음 항목을 검증하는 것이 좋습니다.
기존 데이터베이스 생성
사용자 데이터 삽입
새 버전 마이그레이션 실행
기존 사용자 데이터 조회
새 컬럼과 인덱스 확인
검색 쿼리 정상 동작 확인
특히 앱에 포함된 assets 데이터베이스를 교체하는 구조라면 기본 데이터와 사용자 데이터를 분리하는 방식을 고려하는 것이 좋습니다.
17. Espresso란?
Espresso는 Android 앱 내부 UI를 테스트하기 위한 프레임워크입니다.
버튼 클릭, 텍스트 입력, 화면 표시 여부 등을 검증할 수 있습니다.
예를 들어 다음과 같은 화면이 있다고 가정하겠습니다.
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<EditText
android:id="@+id/editKeyword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="조류 이름 입력" />
<Button
android:id="@+id/buttonSearch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="검색" />
<TextView
android:id="@+id/textResult"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>Espresso를 사용하면 사용자가 직접 입력하고 버튼을 누르는 동작을 자동화할 수 있습니다.
18. Espresso로 검색 화면 테스트하기
다음은 Activity의 검색 UI를 테스트하는 예시입니다.
@RunWith(AndroidJUnit4::class)
class BirdSearchUiTest {
@get:Rule
val activityRule =
ActivityScenarioRule(
MainActivity::class.java
)
@Test
fun search_button_displays_result() {
onView(
withId(R.id.editKeyword)
).perform(
typeText("참새"),
closeSoftKeyboard()
)
onView(
withId(R.id.buttonSearch)
).perform(
click()
)
onView(
withId(R.id.textResult)
).check(
matches(
withText("참새")
)
)
}
}이 예제는 버튼을 누른 뒤 결과 TextView에 특정 문자열이 표시되는 화면을 전제로 합니다.
실제 앱에서는 검색 결과가 RecyclerView에 표시될 수 있으므로 해당 구조에 맞게 테스트를 작성해야 합니다.
또한 비동기 네트워크 요청이나 Coroutine 작업이 포함되어 있다면 Espresso가 해당 작업의 완료를 자동으로 인식하지 못할 수 있습니다.
이 경우 테스트용 데이터 소스나 IdlingResource 등 적절한 동기화 방법이 필요합니다.
19. RecyclerView 테스트하기
RecyclerView는 Android 앱에서 자주 사용하는 UI 컴포넌트입니다.
목록 화면에서는 다음과 같은 동작을 테스트할 수 있습니다.
특정 아이템 표시 여부
아이템 클릭
상세 화면 이동
좋아요 버튼 동작
목록 갱신
스크롤 이후 상태 유지
Espresso에서는 RecyclerViewActions를 사용할 수 있습니다.
추가 의존성은 다음과 같습니다.
androidTestImplementation(
"androidx.test.espresso:espresso-contrib:<version>"
)예를 들어 첫 번째 아이템을 클릭할 수 있습니다.
onView(
withId(R.id.recyclerView)
).perform(
RecyclerViewActions
.actionOnItemAtPosition<
RecyclerView.ViewHolder
>(
0,
click()
)
)다만 광고 아이템과 데이터 아이템이 섞인 RecyclerView에서는 단순한 위치 기반 테스트가 불안정할 수 있습니다.
예를 들어 광고가 삽입되면 기존 데이터 아이템의 위치가 변경될 수 있습니다.
따라서 실제 프로젝트에서는 아이템의 고유 ID나 표시 텍스트를 기준으로 테스트하는 방식을 고려하는 것이 좋습니다.
20. UI Automator란?
UI Automator는 Android 기기 수준에서 UI를 조작하고 검증할 수 있는 테스트 프레임워크입니다.
Espresso와 달리 앱 외부의 시스템 UI를 다루는 테스트에도 사용할 수 있습니다.
대표적으로 다음과 같은 상황에서 유용합니다.
시스템 권한 요청 화면
앱 간 화면 전환
알림 영역
시스템 설정 화면
외부 앱 실행과 복귀
다만 시스템 UI는 Android 버전과 제조사에 따라 다를 수 있습니다.
따라서 특정 버튼의 텍스트나 화면 위치에 지나치게 의존하는 테스트는 불안정할 수 있습니다.
21. UI Automator 기본 사용법
다음은 UI Automator의 기본적인 사용 예시입니다.
@RunWith(AndroidJUnit4::class)
class BirdUiAutomatorTest {
@Test
fun app_package_is_visible() {
val device = UiDevice.getInstance(
InstrumentationRegistry
.getInstrumentation()
)
device.pressHome()
val context =
ApplicationProvider
.getApplicationContext<Context>()
val packageName =
context.packageName
val intent =
context.packageManager
.getLaunchIntentForPackage(
packageName
)
assertNotNull(intent)
intent!!.addFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK
)
context.startActivity(intent)
val appObject =
device.wait(
Until.hasObject(
By.pkg(packageName)
.depth(0)
),
5_000
)
assertTrue(appObject)
}
}이 예제는 앱을 실행한 뒤 대상 패키지의 UI가 표시되는지 확인하는 기본 구조입니다.
실제 테스트에서는 앱 시작 화면의 특정 요소를 확인하는 것이 더 의미 있을 수 있습니다.
또한 앱 실행 방식과 패키지 구성은 테스트 환경에 따라 조정해야 합니다.
22. Espresso와 UI Automator 비교
| 항목 | Espresso | UI Automator |
|---|---|---|
| 앱 내부 UI 테스트 | 적합 | 가능 |
| RecyclerView 테스트 | 편리 | 상대적으로 직접 구현 필요 |
| 시스템 UI 조작 | 제한적 | 지원 |
| 권한 팝업 테스트 | 제한적 | 적합 |
| 앱 외부 화면 | 일반적으로 대상 아님 | 가능 |
| 테스트 안정성 | 앱 내부 UI에 적합 | 시스템 환경 영향이 큼 |
일반적인 Android 앱에서는 Espresso를 기본 UI 테스트 도구로 사용하고, 시스템 UI가 필요한 경우 UI Automator를 함께 사용하는 방법을 고려할 수 있습니다.
23. UI 테스트가 실패하는 대표적인 이유
UI 테스트는 Unit Test보다 환경의 영향을 많이 받습니다.
대표적인 원인은 다음과 같습니다.
네트워크 응답 시간 차이
애니메이션 실행
광고 로딩
키보드 표시 상태
화면 크기 차이
Android 버전 차이
테스트 데이터 상태
권한 허용 여부
특히 AdMob 광고를 사용하는 앱에서는 실제 광고 로딩 여부에 따라 UI가 달라질 수 있습니다.
따라서 자동화 테스트에서는 테스트 광고 설정을 사용하거나 광고 영역을 테스트용 구현으로 대체하는 방식을 고려하는 것이 좋습니다.
실제 광고 클릭을 자동화하는 테스트는 광고 정책 위반 위험이 있으므로 피해야 합니다.
24. 테스트 데이터와 실제 데이터 분리하기
자동화 테스트에서는 실제 사용자 데이터나 운영 서버 데이터를 직접 수정하지 않는 것이 중요합니다.
예를 들어 테스트에서 좋아요를 등록하거나 메모를 삭제하는 경우 운영 데이터에 영향을 줄 수 있습니다.
따라서 다음과 같이 분리하는 것이 좋습니다.
Production
│
├── 실제 서버
└── 실제 SQLite 데이터
Testing
│
├── Mock API
├── 테스트 SQLite DB
└── 테스트 사용자 데이터Retrofit API 테스트에는 MockWebServer를 사용할 수 있습니다.
SQLite 테스트에는 별도의 테스트 데이터베이스를 사용할 수 있습니다.
이렇게 하면 테스트가 실제 서비스 데이터에 영향을 주지 않도록 구성할 수 있습니다.
25. 테스트 코드에서 Thread.sleep() 피하기
비동기 작업이 끝날 때까지 기다리기 위해 다음과 같은 코드를 사용하는 경우가 있습니다.
Thread.sleep(3000)하지만 이 방식은 테스트 실행 시간을 늘리고 환경에 따라 불안정해질 수 있습니다.
예를 들어 네트워크 응답이 3초보다 늦으면 테스트가 실패할 수 있습니다.
반대로 0.1초 만에 작업이 끝나도 3초를 기다려야 합니다.
Coroutine Unit Test에서는 runTest, advanceUntilIdle() 등을 사용할 수 있습니다.
Espresso UI 테스트에서는 IdlingResource나 테스트용 데이터 소스를 이용해 작업 완료를 확인하는 방법을 고려할 수 있습니다.
중요한 것은 임의의 시간을 기다리는 것이 아니라 작업이 실제로 완료되었는지 확인하는 것입니다.
26. Gradle 명령어로 테스트 실행하기
Android 테스트는 Gradle 명령어로 실행할 수 있습니다.
Local Unit Test:
./gradlew testDebugUnitTestInstrumented Test:
./gradlew connectedDebugAndroidTestProduct Flavor를 사용하는 경우에는 다음과 같이 실행할 수 있습니다.
./gradlew testBirdDebugUnitTest
./gradlew connectedBirdDebugAndroidTestconnectedAndroidTest 계열 작업은 연결된 Android 기기나 에뮬레이터가 필요합니다.
따라서 단순한 JVM 환경에서는 실행할 수 없습니다.
27. GitHub Actions에서 Unit Test 자동 실행
GitHub Actions에서는 코드를 Push하거나 Pull Request를 생성했을 때 테스트를 실행할 수 있습니다.
.github/workflows/android-test.yml
name: Android Unit Test
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: gradle
- name: Grant Gradle permission
run: chmod +x gradlew
- name: Run unit tests
run: ./gradlew testDebugUnitTest이렇게 하면 GitHub에서 코드 변경이 발생할 때마다 Unit Test를 실행할 수 있습니다.
JDK 버전은 사용하는 Android Gradle Plugin의 요구사항에 맞춰 변경해야 합니다.
28. GitHub Actions에서 에뮬레이터 테스트 실행
Instrumented Test는 Android 에뮬레이터가 필요합니다.
GitHub Actions에서는 에뮬레이터 실행을 지원하는 Action을 활용할 수 있습니다.
예를 들어 다음과 같은 구성이 가능합니다.
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
arch: x86_64
script: ./gradlew connectedDebugAndroidTest이 예제는 에뮬레이터를 실행한 뒤 Instrumented Test를 수행하는 기본 구조입니다.
실제 프로젝트에서는 에뮬레이터 이미지, 하드웨어 가속 지원, 테스트 실행 시간 등을 고려해야 합니다.
또한 서드파티 Action을 사용할 때는 유지보수 상태와 보안성을 확인하는 것이 좋습니다.
29. 테스트 실패 시 결과 확인하기
GitHub Actions에서 테스트가 실패하면 실행 로그를 확인할 수 있습니다.
Gradle은 일반적으로 테스트 결과를 다음 경로에 생성합니다.
app/build/test-results/
app/build/reports/tests/Instrumented Test의 결과 경로는 빌드 Variant와 Gradle 설정에 따라 달라질 수 있습니다.
테스트 결과를 Artifact로 업로드하면 실패 원인을 확인하기 편리합니다.
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-test-reports
path: |
app/build/reports/tests/
app/build/test-results/if: always()를 사용하면 이전 테스트 단계가 실패하더라도 결과 업로드를 시도할 수 있습니다.
다만 테스트 결과 디렉터리가 생성되지 않은 경우에는 업로드할 파일이 없을 수 있습니다.
30. 개인 개발자에게 추천하는 테스트 우선순위
개인 개발자는 테스트 작성에 사용할 수 있는 시간이 제한적일 수 있습니다.
따라서 모든 화면을 처음부터 자동화하기보다 오류 발생 가능성이 높은 기능부터 테스트하는 것이 좋습니다.
추천 순서는 다음과 같습니다.
| 우선순위 | 테스트 대상 | 추천 도구 |
|---|---|---|
| 1 | SQLite 검색 및 데이터 유지 | Instrumented Test |
| 2 | ViewModel 상태 관리 | JUnit + MockK |
| 3 | Retrofit 응답 처리 | JUnit + MockWebServer |
| 4 | 좋아요·메모 저장 | SQLite Test |
| 5 | 검색 화면 | Espresso |
| 6 | RecyclerView 상세 이동 | Espresso |
| 7 | 권한 요청 및 시스템 화면 | UI Automator |
| 8 | 전체 사용자 흐름 | UI Test |
특히 데이터베이스를 직접 관리하는 앱에서는 SQLite 관련 테스트의 우선순위를 높이는 것이 좋습니다.
앱 업데이트 이후 기존 사용자 메모나 좋아요 데이터가 사라지는 문제는 단순한 UI 오류보다 사용자에게 더 큰 영향을 줄 수 있기 때문입니다.
31. 테스트 자동화 체크리스트
JUnit 의존성 추가
MockK 의존성 추가
Coroutine 테스트 라이브러리 설정
ViewModel 의존성 주입 구조 적용
Main Dispatcher 테스트 규칙 작성
성공·실패·취소 상태 테스트
SQLite 쿼리 테스트
SQLite 마이그레이션 테스트
Retrofit MockWebServer 테스트
Espresso UI 테스트 작성
RecyclerView 아이템 클릭 테스트
UI Automator 시스템 UI 테스트
실제 광고 클릭 테스트 방지
운영 데이터와 테스트 데이터 분리
Thread.sleep 기반 대기 제거
GitHub Actions Unit Test 연동
Instrumented Test 실행 환경 구성
테스트 결과 Artifact 저장
릴리스 전 주요 테스트 통과 확인
32. 마무리
Android 앱 테스트 자동화는 대규모 개발팀에서만 필요한 기능이 아닙니다.
오히려 여러 앱을 혼자 개발하고 유지보수하는 개인 개발자에게 더욱 유용할 수 있습니다.
앱을 수정할 때마다 모든 기능을 직접 실행해 확인하는 것은 시간이 많이 걸리기 때문입니다.
실무에서는 다음과 같은 원칙을 추천합니다.
순수 Kotlin 로직은 JUnit으로 테스트합니다.
ViewModel은 MockK와 Coroutine Test를 활용합니다.
SQLite는 실제 Android 환경에서 테스트합니다.
화면 조작은 Espresso로 검증합니다.
시스템 UI가 필요한 경우 UI Automator를 사용합니다.
테스트 데이터와 운영 데이터를 분리합니다.
GitHub Actions에서 테스트를 자동 실행합니다.
처음부터 모든 기능을 테스트할 필요는 없습니다.
가장 중요한 검색, 데이터 저장, ViewModel 상태 관리부터 시작하고 새로운 기능을 추가할 때 테스트를 함께 작성하는 것이 좋습니다.
Android 테스트 자동화의 핵심은 테스트 코드의 양을 늘리는 것이 아니라, 앱의 중요한 기능이 수정 이후에도 정상적으로 유지된다는 신뢰를 확보하는 것입니다.
참고 자료
Android Developers — Android 앱 테스트
https://developer.android.com/training/testingAndroid Developers — Local Unit Test
https://developer.android.com/training/testing/local-testsAndroid Developers — Instrumented Test
https://developer.android.com/training/testing/instrumented-testsAndroid Developers — Espresso
https://developer.android.com/training/testing/espressoAndroid Developers — UI Automator
https://developer.android.com/training/testing/other-components/ui-automatorKotlin Coroutines Test
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/MockK 공식 저장소
https://github.com/mockk/mockkGitHub Actions 공식 문서
https://docs.github.com/en/actionsAndroid Emulator Runner
https://github.com/ReactiveCircus/android-emulator-runner
태그: Android, Kotlin, JUnit, MockK, Espresso, UI Automator, Coroutine Test, MVVM, SQLite, Retrofit, RecyclerView, GitHub Actions, Android 테스트 자동화, CI/CD