ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Android] WorkManager 을 테스트 해볼까요
    IT/android 2025. 12. 23. 03:12
    SMALL

     

    현재 작업중인 사이드프로젝트에서 WorkManger 을 이용하여 Periodic work 를 주기적으로 실행하는 로직이 있었습니다. 오전 8시 근방으로 한차례 Notification 을 띄워주는 기능을 하는 친구인데 실제로 이것이 올바르게 동작하는지 매뉴얼로 확인하기 번거로워서 테스트 할수있는 방법을 찾아 공유하려합니다.

     

    안드로이드 테스트는 항상 느끼지만 설정이 참 복잡합니다. 그럼에도 불구하고 설정만 잘 해놓는다면 Junit 을 활용해서 빠르게 테스트를 작성할 수 있습니다. + AI를 잘 활용합시다

     

    Hilt 통합테스트 설정

     

    테스트하려는 Worker 은 @HiltWorker 으로 의존성이 관리되고 있습니다. 따라서 WorkManager 테스트 전 Hilt 관련 설정을 먼저 진행해야 합니다.

    @HiltWorker
    class DailyNotificationWorker @AssistedInject constructor(
        @Assisted appContext: Context,
        @Assisted workerParams: WorkerParameters,
        private val petRepository: PetRepository,
        private val activityRecordRepository: ActivityRecordRepository,
        private val activityTypeRepository: com.sejun2.reproutine.domain.repository.ActivityTypeRepository
    ) : CoroutineWorker(appContext, workerParams) {
    
        override suspend fun doWork(): Result {
        ...
        }

     

    먼저 테스트 종속항목을 추가합니다

     // For instrumented tests.
        androidTestImplementation("com.google.dagger:hilt-android-testing:2.57.1")
        // ...with Kotlin.
        kaptAndroidTest("com.google.dagger:hilt-android-compiler:2.57.1")
        // ...with Java.
        androidTestAnnotationProcessor("com.google.dagger:hilt-android-compiler:2.57.1")

     

    만약 프로세서를 kapt 가 아닌 ksp 를 사용중이라면 kspAndroidTest 로 종속성을 주입받아주세요!

     

    올바르게 주입되었다면, 다음은 계측 테스트 설정입니다.

    @HiltAndroidTest와 함께 Hilt를 사용하는 계측 테스트에 주석을 지정해야 합니다. 이 주석은 각 테스트에 관한 Hilt 구성요소 생성을 담당합니다.

    또한 테스트 클래스에 HiltAndroidRule을 추가해야 합니다. HiltAndroidRule은 구성요소의 상태를 관리하고, 테스트에서 삽입을 실행하는 데 사용됩니다.

    @HiltAndroidTest
    class NotificationSchedulerTest {
    
        @get:Rule(order = 0)
        val hiltRule = HiltAndroidRule(this)

     

    다음으로는 테스트는 Hilt가 생성하는 Application 클래스에 대해 알아야합니다.

     

    Hilt 를 지원하는 Application 객체에서 Hilt 를 사용하는 계측테스트를 실행해야 합니다. 위 테스트 라이브러리는 테스트에 사용할 HiltTestApplication 을 기본적으로 제공하고 있습니다. 만약 테스트에 다른 기본 애플리케이션이 필요하다면 테스트용 맞춤 애플리케이션을 참고하세요.

     

    이제 TestRunner 을 테스트용 Application 을 사용하도록 설정해야합니다.

    계측테스트 에서 Hilt 테스트 애플리케이션을 사용하려면 새 테스트 실행기를 구성해야 합니다. 이렇게 하면 Hilt는 프로젝트의 모든 계측 테스트에서 작동합니다. 

    1. androidTest 폴더에서 AndroidJUnitRunner를 확장하는 맞춤 클래스를 생성합니다.
    2. newApplication 함수를 재정의하고 생성된 Hilt 테스트 애플리케이션의 이름을 전달합니다.
    // A custom runner to set up the instrumented application class for tests.
    class CustomTestRunner : AndroidJUnitRunner() {
    
        override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
            return super.newApplication(cl, HiltTestApplication::class.java.name, context)
        }
    }

     

    그다음 App 수준 Gradle 에서 이 CustomTestRunner 을 계측테스트할때 사용한다고 명시해야합니다.

    android {
        defaultConfig {
            // Replace com.example.android.dagger with your class path.
            testInstrumentationRunner = "com.example.android.dagger.CustomTestRunner"
        }
    }

     

    지금까지가 Hilt 를 지원하는 테스트 설정이었습니다. 참 길죠?

     

    WorkManager 통합테스트 설정

     

    WorkManager는 작업자 테스트에 도움이 되는 work-testing 아티팩트를 제공합니다.

    work-testing 아티팩트를 사용하려면 app 수준 build.gradle 에 종속항목을 추가해야합니다.

    dependencies {
        val work_version = "2.4.0"
    
        ...
    
        // optional - Test helpers
        androidTestImplementation("androidx.work:work-testing:$work_version")
    }

     

    work-testing은 테스트 모드를 위해 특별한 WorkManager 구현을 제공하며 WorkManagerTestInitHelper를 사용하여 초기화됩니다.

    또한 work-testing 아티팩트는 여러 스레드, 잠금 및 래치를 처리할 필요 없이 동기 방식으로 테스트를 더 쉽게 작성할 수 있게 해주는 SynchronousExecutor를 제공합니다.

    @RunWith(AndroidJUnit4::class)
    class BasicInstrumentationTest {
        @Before
        fun setup() {
            val context = InstrumentationRegistry.getTargetContext()
            val config = Configuration.Builder()
                .setMinimumLoggingLevel(Log.DEBUG)
                .setExecutor(SynchronousExecutor())
                .build()
    
            // Initialize WorkManager for instrumentation tests.
            WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
        }
    }

     

    이런식으로 말이죠.

     

    이제 테스트를 정말로 동작시키기 위한 마지막 설정단계를 들어가 봅시다.

     

    @HiltAndroidTest
    @RunWith(AndroidJUnit4::class)
    class NotificationSchedulerTest {
    
        @get:Rule(order = 0)
        val hiltRule = HiltAndroidRule(this)
    
        @Inject
        lateinit var workerFactory: HiltWorkerFactory
    
        private lateinit var context: Context
        private lateinit var workManager: WorkManager
        private lateinit var testDriver: TestDriver
        private lateinit var scheduler: NotificationScheduler
    
        @Before
        fun setup() {
            hiltRule.inject()
            context = ApplicationProvider.getApplicationContext()
    
            val config = Configuration.Builder()
                .setMinimumLoggingLevel(Log.DEBUG)
                .setExecutor(SynchronousExecutor())
                .setWorkerFactory(workerFactory)
                .build()
    
            WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
    
            workManager = WorkManager.getInstance(context)
            testDriver = WorkManagerTestInitHelper.getTestDriver(context)!!
            scheduler = NotificationScheduler(context)
        }

     

    @HiltAndroidTest 어노테이션 - 이 테스트에 Hilt 의존성 주입을 사용하겠다고 명시합니다

     

    @RunWith(AndroidJUnit4::class) - 계측테스트로 Junit4 를 사용하겠다고 명시합니다.

     

    hiltRule - Hilt 컴포넌트를 생성하고 관리합니다. 또한 @Inject 필드에 의존성 주입을 담당합니다.

     

    HiltWorkerFactory - HiltWorker 을 생성하는 팩토리 입니다. 

     

    TestDriver - 시간 시뮬레이션 도구 입니다.

     

     

    @Before 로 선언된 setup 함수에서는 각 테스트가 실행되기전 환경변수를 초기화하는 함수입니다.

    이곳에서 실제 hilt 의존성 주입 명령, context 받아오기, workManager 관련 초기화 등을 수행하게 됩니다.

     

    각 줄을 살펴보면

     

    hiltRule.inject() - hiltRule 을 이용하여 최종적으로 의존성 주입을 실행하게 됩니다. 이 시점에 workerFactory 같은 hilt 로 관리되는 객체들이 초기화 되게 됩니다.

     

    WorkManagerTestInitHelper.initializeTestWorkManager(context, config) - Test 할 WorkManager 을 초기화 시키게 됩니다.

    TestDriver 도 초기화 시켜주고요.

     

    자 이제 모든 환경설정이 끝났습니다!

     

    간단한 테스트를 하나 작성해보면서 조금더 알아보겠습니다.

     @Test
        fun 주기적인_작업은_주기_지연_시간이_지나면_다시_대기열에_추가되어야_한다() {
            // Given
            scheduler.scheduleDailyNotification()
            val workId = getWorkInfo().id
    
            // 첫 번째 실행
            testDriver.setInitialDelayMet(workId)
    
            // When - 다음 주기 도달 시뮬레이션
            testDriver.setPeriodDelayMet(workId)
            testDriver.setPeriodDelayMet(workId)
            testDriver.setPeriodDelayMet(workId)
            testDriver.setPeriodDelayMet(workId)
            testDriver.setPeriodDelayMet(workId)
    
            // Then - PeriodicWork는 다시 ENQUEUED 상태로 돌아감
            val workInfo = workManager.getWorkInfoById(workId).get()
            assertThat(workInfo?.state).isIn(
                listOf(
                    WorkInfo.State.SUCCEEDED,
                    WorkInfo.State.RUNNING,
                    WorkInfo.State.ENQUEUED
                )
            )
        }

     

    * scheduler.scheduleDailyNotification() 함수는 Worker 을 WorkManager 에  periodically enqueue 시키는 함수입니다.

     

    먼저 worker 을 enqueue 해줍니다. 해당 worker 은 매일 주기적으로 실행되게끔 처리됩니다 내부적으로.

    그러면 현재 enqueue 된 workinfo 를 찾아올수있는 간단한 헬퍼함수를 통해 workId 를 가져올 수 있습니다.

     

    이제 TestDriver 을 통해 enqueue 된 worker 가 doWork 를 할때를 시뮬레이션 할 수 있습니다.

    setInitialDelayMet 함수는 첫번째 doWork 실행타이밍을 시뮬레이션할 수 있고,

    setPerodicDelayMet 함수는 주기적으로 실행되는 work의 경우 계속해서 실행타이밍을 시뮬레이션 할 수 있습니다.

     

    테스트 할 조건은 `주기적인 작업은 주기 지연시간이 지나면 다시 대기열에 추가되어야 한다` 입니다.

    각 주기 도달을 여러번 반복한 뒤에도 Work 가 계속 등록되어있다면 주기적으로 동작한다고 검증이 가능할 것 같습니다.

     

    최종적으로 workInfo 의 상태가 ENQUEUED 상태가 되었는지를 검증함으로써 테스트를 마무리 할 수 있습니다.
    그럼 결과를 한번 볼까요 ?

     

     

    위 작성한 테스트가 통과하는것을 확인할 수 있습니다.

     

    한계점으로는 시간 자체를 시뮬레이션해서 내가 원하는시간에 doWork 가 동작하는지 확인하는것은 어려웠습니다. 사실 어떻게보면 WorkManager 자체가 설정한 시간을 완벽하게 보장하는것은 아니기때문에 당연한것 같기도 합니다.

     

    어찌되었건 WorkManager 에서 제공해주는 api 를 통해서 간단하게나마 테스트를 해볼수 있었던 즐거운 시간이었습니다.

     

     

    아래는 테스트코드 전체입니다.

    package com.sejun2.reproutine.data.local.dao.notification
    
    import android.content.Context
    import android.util.Log
    import androidx.hilt.work.HiltWorkerFactory
    import androidx.test.core.app.ApplicationProvider
    import androidx.test.ext.junit.runners.AndroidJUnit4
    import androidx.work.Configuration
    import androidx.work.WorkInfo
    import androidx.work.WorkManager
    import androidx.work.testing.SynchronousExecutor
    import androidx.work.testing.TestDriver
    import androidx.work.testing.WorkManagerTestInitHelper
    import com.google.common.truth.Truth.assertThat
    import com.sejun2.reproutine.notification.NotificationScheduler
    import com.sejun2.reproutine.worker.DailyNotificationWorker
    import dagger.hilt.android.testing.HiltAndroidRule
    import dagger.hilt.android.testing.HiltAndroidTest
    import org.junit.Before
    import org.junit.Rule
    import org.junit.Test
    import org.junit.runner.RunWith
    import javax.inject.Inject
    
    @HiltAndroidTest
    @RunWith(AndroidJUnit4::class)
    class NotificationSchedulerTest {
    
        @get:Rule(order = 0)
        val hiltRule = HiltAndroidRule(this)
    
        @Inject
        lateinit var workerFactory: HiltWorkerFactory
    
        private lateinit var context: Context
        private lateinit var workManager: WorkManager
        private lateinit var testDriver: TestDriver
        private lateinit var scheduler: NotificationScheduler
    
        @Before
        fun setup() {
            hiltRule.inject()
            context = ApplicationProvider.getApplicationContext()
    
            val config = Configuration.Builder()
                .setMinimumLoggingLevel(Log.DEBUG)
                .setExecutor(SynchronousExecutor())
                .setWorkerFactory(workerFactory)
                .build()
    
            WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
    
            workManager = WorkManager.getInstance(context)
            testDriver = WorkManagerTestInitHelper.getTestDriver(context)!!
            scheduler = NotificationScheduler(context)
        }
    
        @Test
        fun `알림_작업이_스케줄링되면_ENQUEUED_상태가_되어야_한다`() {
            // When
            scheduler.scheduleDailyNotification()
    
            // Then
            val workInfo = getWorkInfo()
            assertThat(workInfo.state).isEqualTo(WorkInfo.State.ENQUEUED)
        }
    
        @Test
        fun 초기_지연_시간이_충족되면_Worker가_실행되어야_한다() {
            // Given
            scheduler.scheduleDailyNotification()
            val workId = getWorkInfo().id
    
            // When - 시스템이 initial delay가 지났다고 시뮬레이션
            testDriver.setInitialDelayMet(workId)
    
            // Then
            val workInfo = workManager.getWorkInfoById(workId).get()
            assertThat(workInfo?.state).isIn(
                listOf(
                    WorkInfo.State.RUNNING,
                    WorkInfo.State.SUCCEEDED,
                    WorkInfo.State.ENQUEUED  // PeriodicWork는 성공 후 다시 ENQUEUED
                )
            )
        }
    
        @Test
        fun 주기적인_작업은_주기_지연_시간이_지나면_다시_대기열에_추가되어야_한다() {
            // Given
            scheduler.scheduleDailyNotification()
            val workId = getWorkInfo().id
    
            // 첫 번째 실행
            testDriver.setInitialDelayMet(workId)
    
            // When - 다음 주기 도달 시뮬레이션
            testDriver.setPeriodDelayMet(workId)
            testDriver.setPeriodDelayMet(workId)
            testDriver.setPeriodDelayMet(workId)
            testDriver.setPeriodDelayMet(workId)
            testDriver.setPeriodDelayMet(workId)
    
            // Then - PeriodicWork는 다시 ENQUEUED 상태로 돌아감
            val workInfo = workManager.getWorkInfoById(workId).get()
            assertThat(workInfo?.state).isIn(
                listOf(
                    WorkInfo.State.SUCCEEDED,
                    WorkInfo.State.RUNNING,
                    WorkInfo.State.ENQUEUED
                )
            )
        }
    
        @Test
        fun 여러_주기가_연속적으로_실행되어야_한다() {
            // Given
            scheduler.scheduleDailyNotification()
            val workId = getWorkInfo().id
    
            // When - 3일치 시뮬레이션
            repeat(3) { day ->
                if (day == 0) {
                    testDriver.setInitialDelayMet(workId)
                } else {
                    testDriver.setPeriodDelayMet(workId)
                }
    
                // Then - 매번 실행 후 다시 스케줄됨
                val workInfo = workManager.getWorkInfoById(workId).get()
                assertThat(workInfo?.state).isIn(
                    listOf(
                        WorkInfo.State.RUNNING,
                        WorkInfo.State.SUCCEEDED,
                        WorkInfo.State.ENQUEUED
                    )
                )
            }
    
            // 최종 상태는 ENQUEUED (다음 실행 대기)
            val finalWorkInfo = workManager.getWorkInfoById(workId).get()
            assertThat(finalWorkInfo?.state).isIn(
                listOf(
                    WorkInfo.State.RUNNING,
                    WorkInfo.State.SUCCEEDED,
                    WorkInfo.State.ENQUEUED
                )
            )
        }
    
        @Test
        fun 취소된_작업은_CANCELLED_상태를_가져야_한다() {
            // Given
            scheduler.scheduleDailyNotification()
    
            // When
            scheduler.cancelDailyNotification()
    
            // Then
            val workInfo = getWorkInfo()
            assertThat(workInfo.state).isEqualTo(WorkInfo.State.CANCELLED)
        }
    
        @Test
        fun 중복_스케줄링_시에도_단일_작업만_유지되어야_한다() {
            // Given
            scheduler.scheduleDailyNotification()
            val firstWorkId = getWorkInfo().id
    
            // When
            scheduler.scheduleDailyNotification()
    
            // Then - KEEP 정책이면 같은 ID, UPDATE면 새 ID
            val workInfos = workManager
                .getWorkInfosForUniqueWork(DailyNotificationWorker.WORK_NAME)
                .get()
    
            assertThat(workInfos).hasSize(1)  // 중복 없이 하나만 존재
        }
    
        private fun getWorkInfo(): WorkInfo {
            return workManager
                .getWorkInfosForUniqueWork(DailyNotificationWorker.WORK_NAME)
                .get()
                .first()
        }
    }

     

    https://developer.android.com/develop/background-work/background-tasks/testing/persistent/integration-testing?hl=ko

     

    WorkManager 통합 테스트  |  Background work  |  Android Developers

    이 페이지는 Cloud Translation API를 통해 번역되었습니다. WorkManager 통합 테스트 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. WorkManager는 작업자 테스트에 도

    developer.android.com

    https://developer.android.com/training/dependency-injection/hilt-testing?hl=ko#kotlin

     

    Hilt 테스트 가이드  |  App architecture  |  Android Developers

    이 페이지는 Cloud Translation API를 통해 번역되었습니다. Hilt 테스트 가이드 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Hilt와 같은 종속 항목 삽입 프레임

    developer.android.com

     

    LIST
Designed by Tistory.