이전 글에서 애플리케이션에서 Proto DataStore를 사용하는 방법을 소개했습니다. 그 글은 제가 직접 만든 앱에서 Proto DataStore를 활용하며 쌓은 경험을 바탕으로 작성한 것이었습니다.
이후 자연스럽게 궁금해진 것은, 그동안 배운 지식을 바탕으로 해당 애플리케이션의 Proto DataStore에 대한 테스트를 작성하면 어떨까 하는 점이었습니다.
온라인에서 관련 가이드를 찾아봤지만 크게 도움이 되는 자료를 찾기 어려웠고, 그래서 같은 고민을 하고 있는 분들에게 도움이 되도록 제가 얻은 지식을 공유하기로 했습니다. 최악의 경우라도 적어도 저의 기록으로 남을 것입니다.
검색 과정에서 한 편의 글을 찾긴 했지만, 그 글은 Preferences DataStore 테스트에 초점이 맞춰져 있었고 Proto DataStore는 아니었습니다. 다만 해당 글에는 이런 문구가 있었습니다.
"다만, 이 내용은 Proto DataStore 테스트 환경을 구성하는 데에도 활용할 수 있습니다. Preferences와 매우 유사하기 때문입니다."
하지만 실제로 따라 해 본 결과, 의존성 추가 부분 외에는 유사한 점이 많지 않았고, Proto DataStore를 테스트하려면 별도의 로직을 직접 구현해야 한다는 사실을 알게 되었습니다.
환경 설정하기
애플리케이션의 build.gradle 파일에 다음 의존성을 추가합니다:
dependencies {
///.....
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
}
$compose_version은 프로젝트 수준 build.gradle 파일에서 정의한 변수입니다.
그다음 androidTest 디렉터리로 이동해 새 파일을 생성합니다. 일반적으로 Proto DataStore와 상호작용하는 리포지토리 클래스가 있으므로, 파일 이름은 '리포지토리클래스명Test' 형태로 지정하면 됩니다. 여기서는 MyRepositoryTest라는 이름을 사용하겠습니다.
Proto DataStore 자체를 테스트하기 전에 먼저 인스턴스를 생성해야 합니다. 온라인에서 관련 문서를 찾아보면 상당히 빈약한 편입니다.
일반적인(테스트가 아닌) 상황에서는 전역 Context를 사용해 다음과 같이 Proto DataStore 인스턴스를 생성합니다:
private val Context.myDataStore: DataStore<MyItem> by dataStore(
fileName = DATA_STORE_FILE_NAME,
serializer = MyItemSerializer
)
그런데 이 코드는 테스트 클래스 안에서는 동작하지 않습니다. 위 코드를 복사해서 붙여넣을 수는 있어도 DataStore 객체에 접근할 수 없습니다. 다음과 같이 애플리케이션 컨텍스트를 가져올 수는 있습니다:
ApplicationProvider.getApplicationContext()
하지만 myDataStore 객체는 이 컨텍스트를 통해서는 사용할 수 없습니다.
그렇다면 어떻게 해야 할까요?
앞서 언급한 글에는 PreferenceDataStoreFactory.create 메서드를 사용해 Preference DataStore를 생성하는 예제가 나옵니다.
fun create(
corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
migrations: List<DataMigration<Preferences>> = listOf(),
scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile: () -> File): DataStore<Preferences>
하지만 우리는 Preference DataStore가 아니므로 이 방법은 쓸 수 없습니다. 대신 DataStoreFactory.create 메서드를 다음과 같이 사용하면 됩니다:
fun <T : Any?> create(
serializer: Serializer<T>,
corruptionHandler: ReplaceFileCorruptionHandler<T>? = null,
migrations: List<DataMigration<T>> = listOf(),
scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), produceFile: () -> File): DataStore<T>
이 메서드는 여러 인자를 받지만(일부는 기본값이 있음), 모두 전달할 필요는 없습니다. 우리가 전달할 것은 다음 두 가지입니다:
- 직렬화(serializer) 클래스
- Proto DataStore용 파일을 생성하는 람다 함수
dataStore = DataStoreFactory.create(
produceFile = {
testContext.dataStoreFile(TEST_DATA_STORE_FILE_NAME) },
serializer = MyItemSerializer
)
testContext는 다음과 같이 얻습니다:
private val testContext: Context = ApplicationProvider.getApplicationContext()
Proto DataStore 생성에 성공했다면 이제 테스트 코드를 작성할 차례입니다. 리포지토리 클래스가 Proto DataStore 인스턴스를 의존성으로 주입받는 구조라면, DataStore 생성 후 리포지토리 인스턴스도 함께 만들어야 합니다.
private val repository = MyRepository(datastore)
먼저 Proto DataStore의 초기 상태를 검증하는 테스트를 작성해 보겠습니다. Proto DataStore는 flow를 노출하므로 이를 활용할 수 있습니다.
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun repository_testFetchInitialState() {
runTest {
testScope.launch {
val dataStoreObject = repository.myFlow.first()
// 여기에 Proto DataStore에서 검증하고 싶은 값을
// 넣으세요. 예: 초기값이 false인 플래그
assert(dataStoreObject.myFlag == false)
}
}
}
☝️ 눈치채셨겠지만, 여기서 OptIn 어노테이션을 사용하고 있습니다. 현재 사용 중인 API들이 실험적(experimental) 상태이기 때문에, 사용 시 반드시 이를 명시해야 합니다.
DataStore의 flow에 접근하려면 testScope로 감싸야 합니다. TestScope는 다음과 같이 생성합니다:
@OptIn(ExperimentalCoroutinesApi::class)
private val dispatcher = TestCoroutineDispatcher()
@OptIn(ExperimentalCoroutinesApi::class)
private val testScope = TestCoroutineScope(dispatcher)
실행해 보고 첫 번째 Proto DataStore 테스트의 성공을 확인하세요.
물론 그 기쁨은 잠시뿐입니다.
이제 좀 더 의미 있는 테스트를 해보겠습니다.
Proto DataStore 안에 객체 리스트가 있고, 아이템을 추가했을 때 상태 변화를 테스트하고 싶다고 가정해 봅시다.
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun repository_testAdditionOfItem() {
runTest {
testScope.launch {
//1
val item: MyItem = MyItem.newBuilder().setItemId(UUID.randomUUID().toString())
.setItemDescription(TEST_ITEM_DESCRIPTION).build()
//2
repository.updateItem(item)
//3
val items = repository.myFlow.first().itemsList
assert(items.size == 1)
//4
assert(items[0].itemDescription.equals(TEST_ITEM_DESCRIPTION))
}
}
}
- protobuf가 노출하는 API를 사용해 테스트용 아이템을 생성합니다
- MyRepository 클래스에 정의한 메서드를 통해 이 아이템을 Proto DataStore에 추가합니다
- Proto DataStore가 노출하는 flow에서 아이템 리스트를 가져옵니다
- DataStore에 저장된 아이템이 앞서 생성한 아이템과 일치하는지 검증합니다
DataStore에 누수(leak)가 있다면
위 테스트들을 한꺼번에 실행하면 곧 런타임 에러를 만나게 됩니다:
"There are multiple DataStores active for the same file: /data/user/0/com.example.app/files/datastore/dataStore_filename.pb. You should either maintain your DataStore as a singleton or confirm that there is no two DataStore's active on the same file (by confirming that the scope is cancelled)."
곤란한 상황입니다. 테스트 클래스에서는 DataStore 인스턴스를 하나만 생성했는데 말이죠.
대체 무슨 일이 일어난 걸까요?
프로퍼티 델리게이트(Context.datastore) 방식으로 DataStore를 생성하지 않으면, 접근할 때마다 DataStore 객체가 싱글톤으로 유지된다는 보장이 없기 때문입니다.
이 문제를 우회하는 방법 중 하나는 각 테스트 케이스마다 DataStore를 삭제하고 다시 생성하는 것입니다. 삭제는 다음과 같이 할 수 있습니다:
@After
fun cleanup() {
File(testContext.filesDir, "datastore").deleteRecursively()
}
그리고 매 테스트 전에 다시 생성합니다:
@Before
fun setup() {
dataStore = DataStoreFactory.create(
produceFile = {
testContext.dataStoreFile(TEST_DATA_STORE_FILE_NAME)
},
serializer = MyItemSerializer
)
}
전체 예제는 원문의 링크에서 확인할 수 있습니다.
이 글에서는 Proto DataStore를 테스트하는 방법의 개요를 소개했습니다.
두 가지 테스트 케이스를 살펴봤지만, 여러분의 DataStore와 거기에 설정한 타입에 따라 더 많은 테스트 케이스와 시나리오가 필요할 수 있습니다. 기본 빌딩 블록은 이미 준비되어 있으니, 필요에 맞게 응용하기만 하면 됩니다.
마무리
freeCodeCamp의 오픈소스 커리큘럼은 4만 명 이상이 개발자로 취업하는 데 도움을 준 무료 학습 자료입니다. 코딩 학습을 시작해 보세요.