ViewModel 에서 api통신후 LiveData를 참조하여 정상적으로 갱신되었는지 확인하여 

정상 동작하는지 테스트를 진행해 보도록 하겠습니다. 

 

LiveData확인을 위한 확장함수를 활용하여 진행 하도록 합니다. 

https://github.com/android/architecture-components-samples/blob/master/LiveDataSample/app/src/test/java/com/android/example/livedatabuilder/util/LiveDataTestUtil.kt

 

android/architecture-components-samples

Samples for Android Architecture Components. . Contribute to android/architecture-components-samples development by creating an account on GitHub.

github.com

 

build.gradle

// Testing dependencies
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.5"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:2.28.3-alpha"
androidTestImplementation "androidx.arch.core:core-testing:2.0.0"
// Espresso dependencies
androidTestImplementation "androidx.test.espresso:espresso-contrib:3.1.1"
androidTestImplementation "androidx.test.espresso:espresso-core:3.1.1"
androidTestImplementation "androidx.test.espresso:espresso-intents:3.1.1"
// Assertions
androidTestImplementation "androidx.test.ext:junit:1.1.0"
androidTestImplementation "com.google.truth:truth:0.42"
androidTestImplementation "androidx.test.uiautomator:uiautomator:2.2.0"
androidTestImplementation "androidx.work:work-testing:2.1.0"
androidTestImplementation "com.google.dagger:hilt-android-testing:2.28.3-alpha"
testImplementation "junit:junit:4.12"

 

MainRepository

class MainRepository(private val apiService: ApiService) {
    suspend fun getLotto(order: Int) = apiService.getLotto(order)
}

 

ApiService

interface ApiService {
    @GET("api/lotto")
    suspend fun getLotto(@Query("order") order: Int ): HomeDto

    companion object {
        private const val BASE_URL = "http://192.168.0.103:8181/"

        fun create(): ApiService {
            val logger = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }

            val client = OkHttpClient.Builder()
                .addInterceptor(logger)
                .build()

            return Retrofit.Builder()
                .baseUrl(BASE_URL)
                .client(client)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(ApiService::class.java)
        }
    }
}

 

LiveDataTestUtil

@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit = {}
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data = o
            latch.countDown()
            this@getOrAwaitValue.removeObserver(this)
        }
    }
    this.observeForever(observer)

    try {
        afterObserve.invoke()
        // Don't wait indefinitely if the LiveData is not set.
        if (!latch.await(time, timeUnit)) {
            throw TimeoutException("LiveData value was never set.")
        }
    } finally {
        this.removeObserver(observer)
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}

 

HomeViewModel

getLotto메소드는 retrofit을 사용하여 서버 api를 호출 후 homeDto 라이브 데이터를 갱신합니다.

class HomeViewModel @ViewModelInject constructor(val mainRepository: MainRepository
) : ViewModel() {
    var isLoading = MutableLiveData<Boolean>()
    var homeDto = MutableLiveData<HomeDto>()

    fun getLotto(order: Int) {
        try {
            isLoading.postValue(true)
            viewModelScope.launch {
                homeDto.postValue(mainRepository.getLotto(order))
            }
        } catch (e: Exception) {
            homeDto.postValue(null)
        } finally {
            isLoading.postValue(false)
        }
    }
}

 

HomeViewModelTest

Api 호출후 livedata를 확인하도록 합니다. 

@HiltAndroidTest
@ExperimentalCoroutinesApi
class HomeViewModelTest {
    private lateinit var viewModel: HomeViewModel
    private val hiltRule = HiltAndroidRule(this)
    private val instantTaskExecutorRule = InstantTaskExecutorRule()

    @get:Rule
    val rule = RuleChain
        .outerRule(hiltRule)
        .around(instantTaskExecutorRule)

    @Inject
    lateinit var mainRepository: MainRepository

    @Before
    fun setUp() {
        hiltRule.inject()
        val context = InstrumentationRegistry.getInstrumentation().targetContext
        viewModel = HomeViewModel(mainRepository)
    }

    @Test
    fun callRequest()  {
        //호출 후 livedata를 확인 
        viewModel.getLotto(0)
        assertTrue(viewModel.homeDto.getOrAwaitValue()._order > 0 )
    }
}

정상적으로 테스트 확인. 

이를 바탕으로 viewmodel livedata을 검증하는 방법에 대해서 확인해 보았습니다.  

'Android' 카테고리의 다른 글

[Android] Activity Result API 사용해보기  (0) 2020.10.22
[Android] EncryptedSharedPreferences 사용해보기  (0) 2020.10.20
[Android] Hilt 사용해보기  (0) 2020.10.09
[Android] 권한 요청  (0) 2020.10.05
[Android] 블루투스 연결  (0) 2020.10.04

Hilt는 프로젝트에서 수동 종속 항목 삽입을 실행하는 상용구를 줄이는 Android용 종속 항목 삽입 라이브러리입니다.

수동 종속 항목 삽입예는 

class LoginActivity: Activity() {
    private lateinit var mainViewModel: MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val apiService = ApiService(retrofit)
        val mainRepository = MainRepository(apiService)
        // Lastly, create an instance of MainViewModel with mainRepository
        mainViewModel = MainViewModel(mainRepository)
    }
}

이 접근 방식은 다음과 같은 문제가 있습니다.

1. 상용구 코드가 많습니다. 코드의 다른 부분에서 MainViewModel의 다른 인스턴스를 만들려면 코드가 중복될 수 있습니다.

2. 종속성은 순서대로 선언해야 합니다. RemoteRepository을 만들려면 MainViewModel 전에 인스턴스화해야 합니다.

3. 객체를 재사용하기가 어렵습니다. 여러 기능에 걸쳐 RemoteRepository를 재사용하려면 싱글톤 패턴을 따르게 해야 합니다. 모든 테스트가 동일한 싱글톤 인스턴스를 공유하므로 싱글톤 패턴을 사용하면 테스트가 더 어려워집니다.

 

 

Hilt를 사용하여 해결하기 

설정  

root build.gradle

buildscript {

    dependencies {
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
    }
}

 

2.app/build.gradle

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    ...
}

dependencies {
    implementation "com.google.dagger:hilt-android:2.29.1-alpha"
    kapt "com.google.dagger:hilt-android-compiler:2.29.1-alpha"
    implementation "androidx.hilt:hilt-common:1.0.0-alpha02"
    implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02"
    kapt "androidx.hilt:hilt-compiler:1.0.0-alpha02"
}

 

3 .Hilt는 자바 8 기능을 사용합니다. 프로젝트에서 자바 8을 사용 설정하려면 app/build.gradle 파일에 다음을 추가합니다.

android {
  ...
  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }
}

 

구현 .

1. 애플리케이션 클래스에 Hilt를 설정합니다. 

@HiltAndroidApp
class ExampleApplication : Application() { ... }

 

2.Hilt 모듈은 @Module 주석이 지정된 클래스입니다.

   인스턴스를 제공할 항목을 @Provides를 사용하여 Hilt에 알려줍니다. 

@Module
@InstallIn(ApplicationComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideApiService(): ApiService {
        return ApiService.create()
    }

    @Provides
    fun provideMainRepository(apiService: ApiService): MainRepository {
        return MainRepository(apiService)
    }
}

Component 수명주기 

생성된 구성요소  생성 위치 제거 위치
ApplicationComponent Application#onCreate()   Application#onDestroy()
ActivityRetainedComponent Activity#onCreate() Activity#onDestroy()
ActivityComponent Activity#onCreate() Activity#onDestroy()
FragmentComponent Fragment#onAttach() Fragment#onDestroy()
ViewComponent View#super()  제거된 뷰
ViewWithFragmentComponent View#super() 제거된 뷰
ServiceComponent Service#onCreate()  Service#onDestroy()

 

3. Jetpack라이브러리 ViewModel의 경우 ViewModel 객체의 생성자에서 @ViewModelInject 을 사용하여 ViewModel을 제공할 수 있습니다

class MainViewModel @ViewModelInject constructor(val mainRepository: MainRepository) : ViewModel() {
    fun getLotto(order: Int) = liveData(Dispatchers.IO) {
        emit(Resource.loading(data = null))
        try {
            emit(Resource.success(data = mainRepository.getLotto(order)))
        } catch (exception: Exception) {
            emit(Resource.error(data = null, message = exception.message ?: "Error Occurred!"))
        }
    }
}

4. 종목 항목 삽입  

    @AndroidEntryPoint를 사용하여 Android 클래스에 종속 항목을 제공합니다.

@AndroidEntryPoint
class HomeFragment : Fragment() {
    //@Inject lateinit var mainRepository: MainRepository
    private val viewModel: MainViewModel by viewModels()
    ...
}

Hilt는 현재 다음 Android 클래스를 지원합니다.

  • Application(@HiltAndroidApp을 사용하여)

  • Activity

  • Fragment

  • View

  • Service

  • BroadcastReceiver

 

마무리

Hilt와 같이 종속 항목 삽입(DI)은 프로그래밍에 널리 사용되는 기법으로, Android 개발에 적합합니다. DI의 원칙을 따르면 훌륭한 앱 아키텍처를 위한 토대를 마련할 수 있습니다.

종속 항목 삽입을 구현하면 다음과 같은 이점을 누릴 수 있습니다.

  • 코드 재사용 가능

  • 리팩터링 편의성

  • 테스트 편의성

developer.android.com/training/dependency-injection/hilt-android?hl=ko

 

Hilt를 사용한 종속 항목 삽입  |  Android 개발자  |  Android Developers

Hilt는 프로젝트에서 수동 종속 항목 삽입을 실행하는 상용구를 줄이는 Android용 종속 항목 삽입 라이브러리입니다. 수동 종속 항목 삽입을 실행하려면 모든 클래스와 종속 항목을 수동으로 구성�

developer.android.com

 

모든 Android 앱은 액세스가 제한된 샌드박스에서 실행됩니다. 자체 샌드박스 밖에 있는 리소스나 정보를 앱이 사용해야 하는 경우에는 앱이 적절한 권한을 요청해야 합니다. 앱에 권한이 필요하다고 선언하려면 권한을 앱 매니페스트에 표시한 후 사용자가 런타임에 각 권한을 승인하도록 요청합니다(Android 6.0 이상). 

 

1.매니페스트에 권한 추가

<uses-permission android:name="android.permission.CAMERA" />

 

2 6.0이상 버전에 대해서 요청을 합니다. 

private fun setupUI() {
    binding.request.setOnClickListener {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
            showCamera()
        } else {
            showCameraWithPermission(Manifest.permission.CAMERA
                , "게임에 필요한 권한 필요” //거절한 경우 필요 설명 
                , "카메라 권한 요청”) //거절한 경우 버튼 설명 
        }
    }
}

 

3. 권한 요청 

@RequiresApi(Build.VERSION_CODES.M)
private fun showCameraWithPermission(permissionName: String, reasonDesc: String, reasonButtonText: String) {
    if (checkSelfPermission(permissionName) == PackageManager.PERMISSION_GRANTED) {
        //허용
        showCamera()
    } else {
        if (shouldShowRequestPermissionRationale(permissionName)) {
            //거절한 경우 설명 후 재 요청.
            Snackbar.make(binding.mainLayout, reasonDesc, Snackbar.LENGTH_INDEFINITE)
                .setAction(reasonButtonText
                ) {
                    requestPermissions(
                        arrayOf(permissionName),
                        PERMISSION_REQUEST_CAMERA
                    )
                }.show()
        } else {
            //거절기록 없을때 최초 false 권한 요청
            requestPermissions(
                arrayOf(permissionName),
                PERMISSION_REQUEST_CAMERA
            )
        }
    }
}

 

4. 콜백 응답 

@RequiresApi(Build.VERSION_CODES.M)
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode == PERMISSION_REQUEST_CAMERA) {
        if (grantResults.size == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            Toast.makeText(this, "권한 성공", Toast.LENGTH_SHORT).show()
            showCamera()
        } else {
            if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
                Toast.makeText(this, "권한 거절", Toast.LENGTH_SHORT).show()
            } else {
                //거부 및 다시 묻지 않기
                Toast.makeText(this,  "앱 기능 실행을 위해서는 설정화면에서 권한을 허용해주세요 ", Toast.LENGTH_LONG).show();
            }
        }
    }
}

+ Recent posts