Android Multi Module Clean Architecture with Hilt, Ktor Client (4) - Test

이태훈·2022년 5월 11일
1

안녕하세요. 이번에는 ViewModel에서 Paging3 에서 받아오는 데이터를 테스팅 해보겠습니다.

코드는 이 프로젝트를 보시면 됩니다.

Unit Test

빠르게 ViewModel의 코드부터 보겠습니다. 다른 항목들은 이전 시리즈의 글을 참고해주시기 바랍니다.

GalleryViewModel.kt

@HiltViewModel
class GalleryViewModel @Inject constructor(
	getSearchResult: GetSearchResultUseCase,
) : ViewModel() {

	val searchResult = getSearchResult(DEFAULT_QUERY)
		.cachedIn(viewModelScope)

	companion object {
		const val DEFAULT_QUERY = "cats"
	}
}

코드만 봐도 알 수 있듯이 PagingData를 가져오는 코드입니다. 이 코드를 테스트 해보겠습니다.

GalleryViewModelTest.kt

@RunWith(RobolectricTestRunner::class)
class GalleryViewModelTest : TestCase() {

	@get:Rule(order = 0)
	val coroutineRule = TestCoroutinesRule()

	private val mockUnsplashRepository = mock(UnsplashRepository::class.java)

	@Before
	fun repositoryInit() {
		`when`(mockUnsplashRepository.getSearchResult(ArgumentMatchers.anyString()))
			.thenReturn(flowOf(PagingData.from(FakePhotoListHolder.fakePhotoList)))
	}

	@Test
	@ExperimentalCoroutinesApi
	fun `뷰모델 리스트 초기화`() = coroutineRule.runTest {
		val viewModel = createViewModel()

		assertEquals(
			viewModel.searchResult.drop(1).first().parseData(),
			PagingData.from(FakePhotoListHolder.fakePhotoList).parseData()
		)
	}

	private fun createViewModel() = GalleryViewModel(GetSearchResultUseCase(mockUnsplashRepository))
}

먼저 coroutinesRule부터 보겠습니다.

class TestCoroutinesRule(
	private val testScope: TestScope = TestScope(),
	private val testDispatcher: TestDispatcher = StandardTestDispatcher(testScope.testScheduler)
) : TestWatcher() {

	override fun starting(description: Description?) {
		super.starting(description)

		Dispatchers.setMain(testDispatcher)
	}

	override fun finished(description: Description?) {
		super.finished(description)

		Dispatchers.resetMain()
	}

	fun runTest(block: suspend TestScope.() -> Unit) = testScope.runTest(testBody = block)
}

기존에 사용하던 TestCoroutineDispatcher가 Deprecated 됨에 따라 위와 같이 TestScope, StandardTestDispatcher로 변경해주었습니다.

마찬가지로, TestCoroutineDispatcher.runBlockingTest도 Deprecated 됨에 따라 TestScope.runTest로 변경해주었습니다.

coroutinesRule은 간단하게 테스트 시작 전 Main Dispatcher를 TestDispatcher로 변경해주고 끝난 후에 reset해주는 역할입니다.

그리고 뷰모델의 유즈케이스에 사용할 레퍼지토리를 mocking 해줍니다.

private val mockUnsplashRepository = mock(UnsplashRepository::class.java)

@Before
fun repositoryInit() {
	`when`(mockUnsplashRepository.getSearchResult(ArgumentMatchers.anyString()))
    	.thenReturn(flowOf(PagingData.from(FakePhotoListHolder.fakePhotoList)))
}

mocking을 해주고 data를 적절한 때에 반환해주지 않으면 NPE가 나기 쉽상이니 적절한 시점에 데이터를 반환해주시면 됩니다.

저같은 경우에는 뷰모델이 생성되면서 repository에서 데이터를 요청하기 때문에 테스트 시작 전마다 임의의 데이터를 반환해주도록 했습니다.

	@Test
	@ExperimentalCoroutinesApi
	fun `뷰모델 리스트 초기화`() = coroutineRule.runTest {
		val viewModel = createViewModel()

		assertEquals(
			viewModel.searchResult.drop(1).first().parseData(),
			PagingData.from(FakePhotoListHolder.fakePhotoList).parseData()
		)
	}

	private fun createViewModel() = GalleryViewModel(GetSearchResultUseCase(mockUnsplashRepository))

마지막으로 테스트 코드입니다. PagingData를 처음에 테스트하기 위해 다음과 같은 코드를 짰었습니다.

assertEquals(
	viewModel.searchResult.first(),
	PagingData.from(FakePhotoListHolder.fakePhotoList)
)

이렇게 하니 같은 내용을 담고 있어도 PagingData 객체에 대한 동등성 검사를 하기 때문에 실패를 해서 다음과 같이 리스트를 뽑아내서 비교하는 형식으로 해주었습니다.

private val emptyCallback = object : DifferCallback {
	override fun onChanged(position: Int, count: Int) = Unit
	override fun onInserted(position: Int, count: Int) = Unit
	override fun onRemoved(position: Int, count: Int) = Unit
}

suspend fun <T: Any> PagingData<T>.parseData(): List<T> = buildList {
	object : PagingDataDiffer<T>(emptyCallback) {
		override suspend fun presentNewList(
			previousList: NullPaddedList<T>,
			newList: NullPaddedList<T>,
			newCombinedLoadStates: CombinedLoadStates,
			lastAccessedIndex: Int,
			onListPresentable: () -> Unit
		): Int? {
			onListPresentable()
			for (idx in 0 until newList.size) {
				add(newList.getFromStorage(idx))
			}

			return null
		}
	}.collectFrom(this@parseData)
}

하지만, 이와 같은 방법은 바람직하지는 않아 보이며 정확하게 테스트를 실시하기 위해서는 PagingSource 단위로 동등성 검사를 하는 것이 정확할 것 같습니다.
https://developer.android.com/topic/libraries/architecture/paging/test#pagingsource-tests

Instrumentation Test

Hilt와 같이 Test를 진행하기 위해 다음과 같이 구성해줍니다.

먼저 Hilt에서 제공하는 HiltTestApplication을 가지고 JUnitRunner를 새로 구성해줍니다.

class CustomJUnitRunner : AndroidJUnitRunner() {

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

Fragment Test를 진행하시기 위해서는 다음과 같은 코드를 사용해야합니다.

inline fun <reified T: Fragment> launchFragmentInHiltContainer(
	fragmentArgs: Bundle? = null,
	@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
	fragmentFactory: FragmentFactory? = null,
	crossinline action: T.() -> Unit = { }
) {
	val mainActivityIntent = Intent.makeMainActivity(
		ComponentName(
			ApplicationProvider.getApplicationContext(),
			HiltTestActivity::class.java,
		)
	).putExtra("androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity"
			+ ".THEME_EXTRAS_BUNDLE_KEY", themeResId)

	ActivityScenario.launch<HiltTestActivity>(mainActivityIntent).onActivity { activity ->
		fragmentFactory?.let {
			activity.supportFragmentManager.fragmentFactory = it
		}
		val fragment = activity.supportFragmentManager.fragmentFactory.instantiate(
			Preconditions.checkNotNull(T::class.java.classLoader),
			T::class.java.name
		)

		fragment.arguments = fragmentArgs
		activity.supportFragmentManager.beginTransaction()
			.add(android.R.id.content, fragment, "")
			.commitNow()

		(fragment as T).action()
	}
}

다음으로, 테스트에서 갈아끼울 모듈을 설정해줍니다.

@Module
@TestInstallIn(
	components = [SingletonComponent::class],
	replaces = [RepositoryModule::class]
)
object FakeModule {

	@Provides
	fun provideUnsplashService(): UnsplashService = object : UnsplashService {
		override suspend fun searchPhotos(query: String, page: Int, perPage: Int): Result<UnsplashResponse> {
			val photoList = listOf(
				UnsplashPhoto(
					id = "1",
					description = "first",
					urls = UnsplashPhoto.UnsplashPhotoUrls("", "", "", "", ""),
					user = UnsplashPhoto.UnsplashUser("first_user", "first_user")
				)
			)
			val response = UnsplashResponse(
				photoList, 1
			)

			return Result.Success(response, 200)
		}
	}

	@Singleton
	@Provides
	fun provideUnsplashRepository(
		unsplashService: UnsplashService
	): UnsplashRepository {
		return UnsplashRepositoryImpl(unsplashService)
	}

	@Singleton
	@Provides
	fun provideItemRepository(
		itemService: ItemService,
		itemDao: ItemDao
	): ItemRepository = ItemRepositoryImpl(itemService, itemDao)

}

그런 다음 @HiltAndroidTest 어노테이션을 추가해주고, HiltAndroidRule을 설정해줍니다.

다음으로 Fragment를 설정해주고 Instrumentation Test를 진행해주면 됩니다.

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class GalleryFragmentTest {

	@get:Rule(order = 0)
	val hiltRule = HiltAndroidRule(this)

	@get:Rule(order = 1)
	val instantTaskExecutorRule = InstantTaskExecutorRule()

	@Before
	fun launchScreen() {
		launchFragmentInHiltContainer<GalleryFragment>()
	}

	@Test
	fun isRecyclerViewVisible_OnStart() {
		Espresso.onView(ViewMatchers.withId(R.id.rvPhoto))
			.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
	}
}

SharedViewModel 환경

일반적으로 Fragment간의 데이터를 공유하기 위해 ViewModel을 Activity를 ViewModelStoreOwner로 설정하여 사용하곤 합니다.

이러한 환경에서 Jetpack Navigation을 이용해 savedStateHandle을 통해 NavArgs를 넘겨줄 경우 savedStateHandle에 값이 제대로 전달이 되지 않는 경우가 있습니다.

이러한 경우에 launchFragmentInHiltContainer 함수에서 mainActivity를 생성할 때 savedStateHandle로 넘겨줄 bundle을 설정해주면 됩니다.

예를 들어 다음과 같은 SharedViewModel이 있을 때 launchFragmentInHiltContainer함수를 다음과 같이 구성해주면 됩니다.

inline fun <reified T: Fragment> launchFragmentInHiltContainer(
	fragmentArgs: Bundle? = null,
	@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
	fragmentFactory: FragmentFactory? = null,
	crossinline action: T.() -> Unit = { }
) {
	val mainActivityIntent = Intent.makeMainActivity(
		ComponentName(
			ApplicationProvider.getApplicationContext(),
			HiltTestActivity::class.java,
		)
	).putExtra("androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity"
			+ ".THEME_EXTRAS_BUNDLE_KEY", themeResId)
            .putExtras( --> savedStateHandle에 넘겨줄 bundle <-- )

	...
    
}


@HiltViewModel
class SharedViewModel(
	savedStateHandle: SavedStateHandle
): ViewModel() {
	--> 테스트 코드에서 넘겨주는 bundle값의 key로 savedStateHandle 값을 꺼내어 사용  <--
}
profile
https://www.linkedin.com/in/%ED%83%9C%ED%9B%88-%EC%9D%B4-7b9563237

0개의 댓글