ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Codelabs Tutorial] Android 앱에서 Hilt 사용해보기 (3) with 한정자/UI테스트
    프로그래밍/Android 2022. 5. 16. 21:31

    https://zerodeg.tistory.com/61

     

    [Codelabs Tutorial] Android 앱에서 Hilt 사용해보기 (2)

    https://zerodeg.tistory.com/60 [Codelabs Tutorial] Android 앱에서 Hilt 사용해보기 (1) https://developer.android.com/codelabs/android-hilt#0 Android 앱에서 Hilt 사용  | Android 개발자  | Android De..

    zerodeg.tistory.com

    지난 시간에 이어 오늘은 구글의 코드랩을 통해 한정자와 UI테스트, EntryPoint Annotation에 대해 알아봅니다.

    한정자

    이 섹션에서 설명하는 내용은 액티비티 컨테이너로 범위를 지정하는 방법과 한정자의 정의, 한정자를 사용함으로써 해결할 수 있는 문제가 무엇인지, 한정자는 어떻게 사용하는지에 대해 배웁니다.

    시작하기에 앞서 data폴더에 LoggerDataSource라는 새 파일을 만들고 아래와 같이 입력합니다.

    interface LoggerDataSource {
        fun addLog(msg: String)
        fun getAllLogs(callback: (List<Log>) -> Unit)
        fun removeLogs()
    
        /*
        LoggerLocalDataSource는 ButtonsFragment와 LogsFragment, 두 프래그먼트에서 사용됩니다.
        LoggerDataSource 인스턴스를 사용하기 위해 두 프래그먼트를 사용하도록 리팩토링 해야합니다.
        * */
    }

    LogsFragment를 열고 LoggerDataSource 타입의 logger 변수를 만듭니다.

    @AndroidEntryPoint
    class LogsFragment : Fragment() {
    
        @Inject lateinit var logger: LoggerDataSource
    
    	...
        
    }

    ButtonsFragment에도 동일한 작업을 합니다.

    class ButtonsFragment : Fragment() {
    
        @Inject lateinit var logger: LoggerDataSource
        
        ...
    }

    다음은 LoggerLocalDataSource에서 이 인터페이스를 구현하도록 합니다. data/LoggerLocalDataSource 파일을 열고 LoggerDataSource 인터페이스를 구현합니다.

    @Singleton // 어플리케이션 컨테이너에서 항상 동일한 LoggerLocalDataSource 인스턴스를 제공하도록 함
    class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) : LoggerDataSource {
        // Inject로 주입받겠다는 Annotation을 달았기 때문에
    
        private val executorService: ExecutorService = Executors.newFixedThreadPool(4)
        private val mainThreadHandler by lazy {
            Handler(Looper.getMainLooper())
        }
    
        override
        fun addLog(msg: String) {
            executorService.execute {
                logDao.insertAll(
                    Log(
                        msg,
                        System.currentTimeMillis()
                    )
                )
            }
        }
    
        override
        fun getAllLogs(callback: (List<Log>) -> Unit) {
            executorService.execute {
                val logs = logDao.getAll()
                mainThreadHandler.post { callback(logs) }
            }
        }
    
        override
        fun removeLogs() {
            executorService.execute {
                logDao.nukeTable()
            }
        }
    }

    이제 메모리에 로그를 저장하는 LoggerDataSource의 또다른 구현을 작성해보겠습니다. data 폴더에 LoggerInMemoryDataSource라는 파일을 만들고 아래와 같이 작성합니다.

    class LoggerInMemoryDataSource: LoggerDataSource {
    
        private val logs = LinkedList<Log>()
    
        override fun addLog(msg: String) {
            logs.addFirst(Log(msg, System.currentTimeMillis()))
        }
    
        override fun getAllLogs(callback: (List<Log>) -> Unit) {
            callback(logs)
        }
    
        override fun removeLogs() {
            logs.clear()
        }
        
    }

    LoggerInMemoryDataSource를 구현체로 사용할 수 있으려면 이 타입의 인스턴스 제공 방법을 Hilt에게 알려야 합니다.
    클래스 생성자에 @Inject Annotation을 추가합니다.

    class LoggerInMemoryDataSource @Inject constructor(): LoggerDataSource {
    
        ...
    
    }

    현재 Hilt는 LoggerInMemoryDataSource와 LoggerLocalDataSource의 인스턴스 제공 방법은 알고 있지만, LoggerDataSource를 요청할 경우 사용할 구현체에 관해 알지 못합니다. 이전 섹션에서 살펴본 것처럼, 모듈에서 @Binds Annotation을 사용하여 Hilt에 사용할 구현체를 알려줄 수 있습니다. 하지만 동일한 프로젝트에서 두 개의 구현을 모두 제공해야 한다면 어떻게 될까요? 예를 들어, 앱이 실행되는 동안 LoggerInMemoryDataSource를 사용하고 Service에서는 LoggerLocalDataSource를 사용하는 경우입니다.

    동일한 인터페이스를 위한 두 개의 구현

    di 폴더에 LoggingModule이라는 새 파일을 만들어보겠습니다. LoggerDataSource의 다양한 구현은 서로 다른 컨테이너로 범위가 지정되므로 동일한 모듈을 사용할 수 없습니다. LoggerInMemoryDataSource의 범위는 Activity컨테이너로, LoggerLocalDataSource는 Application(Singleton) 컨테이너로 지정됩니다.

    @InstallIn(SingletonComponent::class)
    @Module
    abstract class LoggingDatabaseModule {
    
        @Singleton
        @Binds
        abstract  fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
    
    }
    
    @InstallIn(ActivityComponent::class)
    @Module
    abstract class LoggingInMemoryModule {
    
        @ActivityScoped
        @Binds
        abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
    
    }

    @Binds 메소드에 범위 지정 Annotation이 있어야 하므로(타입의 범위가 지정된 경우) 위의 함수에 @Singleton과 @ActivityScoped Annotation이 달립니다. @Binds 또는 @Provides가 특정 타입의 결합으로 사용되면 타입의 범위 지정 Annotation은 더 이상 사용되지 않으므로 다른 구현 클래스에서 이러한 주석을 삭제할 수 있습니다. 지금 이상태에서 빌드를 하면 DuplicateBindings 오류가 표시됩니다.

    이는 LoggerDataSource 타입이 Fragment에 삽입되어 있지만, Hilt는 같은 유형에 두 개의 binding이 있어 어느 구현을 사용해야 하는지 모르기 때문입니다.

    한정자 사용

    Hilt에 동일한 타입의 다른 구현(여러개의 바인딩(결합))을 제공하는 방법을 알리려면 한정자를 사용하면 됩니다.

    한정자는 바인딩(결합)을 식별하는데 사용되는 Annotation입니다.

    각 한정자는 바인딩을 식별하는 데 사용되므로 구현별로 한정자를 정의해야 합니다. Android 클래스에 유형을 삽입할 떄 또는 이 유형을 다른 클래스의 종속항목으로 포함할 때는 모호성을 피하기 위해 산정자 주석을 사용해야 합니다.

    한정자는 Annotation일 뿐이므로 모듈을 추가했던 LoggingModule 파일에서 정의할 수 있습니다.

    @Qualifier
    annotation class InMemoryLogger
    
    @Qualifier
    annotation class DatabaseLogger
    
    @InstallIn(SingletonComponent::class)
    @Module
    abstract class LoggingDatabaseModule {
    
        @DatabaseLogger
        @Singleton
        @Binds
        abstract  fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
    
    }
    
    @InstallIn(ActivityComponent::class)
    @Module
    abstract class LoggingInMemoryModule {
    
        @InMemoryLogger
        @ActivityScoped
        @Binds
        abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
    
    }

    이러한 한정자는 삽입하려는 구현과 함께 삽입 지점에서 사용해야 합니다. 이 경우에는 Fragment에서 LoggerInMemoryDataSource 구현을 사용하도록 하겠습니다.

    @DatabaseLogger 한정자는 SingletonComponent에 설치되므로 LogApplication 클래스에 삽입될 수 있습니다. 그러나 @InMemoryLogger는 ActivityComponent에 설치되므로 Singleton 컨테이너에서 이 결합을 알 수 없기 떄문에 이 한정자는 LogApplication 클래스에 삽입될 수 없습니다.

    LogsFragment를 열고 Logger Field에 @InMemoryLogger 한정자를 사용하여 LoggerInMemoryDataSource 인스턴스를 삽입하도록 Hilt에 알려 줍니다.

    @AndroidEntryPoint
    class LogsFragment : Fragment() {
    
        @InMemoryLogger
        @Inject lateinit var logger: LoggerDataSource
        
        ...
    }
    class ButtonsFragment : Fragment() {
    
        @InMemoryLogger // 사용하려는 구현체를 변경하려면 InMemoryLogger 대신 DatabaseLogger를 사용하면 됩니다.
        @Inject lateinit var logger: LoggerDataSource
        
        ...
    }

    이제 앱을 실행하여 제대로 동작하는지 확인해봅니다.

     

    UI 테스트

    이제 앱이 Hilt로 완전히 이전되었으므로 프로젝트에 있는 계측 테스트도 이전할 수 있습니다. 앱 기능을 확인하는 테스트는 app/androidTest 폴더의 AppTest.kt 파일에 있습니다. 프로젝트에서 ServiceLocator 클래스를 삭제했기 때문에 컴파일되지 않는 것을 확인할 수 있습니다. 클래스에서 @After tearDown 메서드를 삭제하여 더 이상 사용하지 않는 ServiceLocator의 참조를 삭제합니다.

    androitTest 테스트는 에뮬레이터에서 실행됩니다. happyPath 테스트에서는 'Button 1'을 탭할 때 데이터베이스에 로그가 기록된 것을 확인합니다. 앱에서 메모리 내 데이터베이스를 사용하므로 테스트가 종료된 후에는 모든 로그가 사라집니다.

    Hilt를 사용한 UI 테스트

    Hilt는 프로덕션 코드에서 발생하는 것처럼 UI 테스트에 종속 항목을 삽입합니다. Hilt는 각 테스트의 새로운 구성요소 집합을 자동 생성하므로 Hilt를 사용한 테스트에는 유지관리가 필요하지 않습니다.

    테스트 종속 항목 추가

    Hilt는 프로젝트에 추가해야 하는 hilt-android-testing이라는 코드를 더 쉽게 테스트할 수 있도록 테스트 관련 주석이 있는 추가 라이브러리를 사용합니다. 또한, Hilt에서 androidTest 폴더에 클래스 관련 코드를 생성해야 하므로 주석 프로세서도 이 위치에서 실행할 수 있어야 합니다. 이렇게 하려면 app/build.gradle 파일에 두 개의 종속 항목을 포함해야 합니다.

    이러한 종속 항목을 추가하려면 app/build.gradle 열고 dependencies 섹션 하단에 구성을 추가합니다.

    dependencies {
    
        // Hilt testing dependency
        androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
        // Make Hilt generate code in the androidTest folder
        kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
        
    }

    맞춤 TestRunner

    Hilt를 사용하여 계측된 테스트는 Hilt를 지원하는 Application에서 실행되어야 합니다. 라이브러리에는 이미 UI 테스트를 실행하는데 사용할 수 있는 HiltTestApplication이 포함되어 있습니다. 테스트에 사용할 Application을 지정하려면 프로젝트에서 새 테스트 실행기를 생성해야 합니다. androidTest 아래 AppTest.kt 파일과 같은 수준에 CustomTestRunner라는 이름의 새 파일을 생성합니다. CustomTestRunner는 AndroidJUnitRunner에서 확장되며 다음과 같이 구현합니다.

    class CustomTestRunner : AndroidJUnitRunner() {
    
        override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
            return super.newApplication(cl, HiltTestApplication::class.java.name, context)
        }
    }

    그런 다음, 이 테스트 실행기를 계측 테스트에 사용하도록 프로젝트에 알려야 합니다. app/build.gradle의 파일의 testInstrumentationRunner 속성에 테스트 실행기를 명시합니다. 파일을 열고 기본 testInstrumentationRunner콘텐츠를 아래와 같이 변경합니다.

    defaultConfig {
        applicationId "com.example.android.hilt"
        minSdkVersion 16
        targetSdkVersion 31
        versionCode 1
        versionName "1.0"
    
        testInstrumentationRunner 'com.example.android.hilt.CustomTestRunner' //Here
    
        javaCompileOptions {
            annotationProcessorOptions {
                arguments["room.incremental"] = "true"
            }
        }
    }

    이제 UI 테스트에서 Hilt를 사용할 준비가 되었습니다. 다음으로 에뮬레이터 테스트 클래스에서 Hilt를 사용하기 위해 각 테스트의 Hilt 구성요소 생성을 담당하는 @HiltAndroidTest Annotation을 추가합니다. 그리고 구성요소의 상태를 관리하ㅣ고 테스트에 삽입을 시랳ㅇ하는 데 사용되는 HiltAndroidRule을 사용합니다.

    @RunWith(AndroidJUnit4::class)
    class AppTest {
    
        @get:Rule
        var hiltRule = HiltAndroidRule(this)
    
        ...
    }

    이제 클래스 정의 또는 테스트 메소드 정의 옆의 재생 버튼을 사용하여 테스트를 실행하면 에뮬레이터가 시작되고 에뮬레이터가 구성된 경우 테스트가 통과됩니다.

    EntryPoint 주석 하나가 남았는데 글이 너무 길어져서 다음 포스팅으로 미뤄야겠네요. 코드랩은 정말 공부하기 좋게 잘 만들어져있네요 :) 

    댓글

Designed by Tistory.