DI 에 대한 세번째 포스팅입니다.

의존성 주입

테스트 코드

우리는 이전 포스팅 까지 의존성 주입이 필요한 이유에 대해 알아보았습니다.
결국, 궁극적으로 Testable 한 코드 작성이 그 이유였는데요, 이제 실제 테스트 코드는 어떻게 해야 작성 할 수 있는지 예제를 통해 알아보겠습니다!

이번 예제에서 테스트 코드는 Junit4, Mockito, Mockito-kotlin2, Hamcrest 를 통해 구현합니다.

예제는 여기

🔗 https://github.com/jeonjungin/DIExample
(포스팅의 코드 조각은 예제보다 더 간소화되었습니다.)

앱 요구사항

1. 포켓몬 ID 를 입력하여 포켓몬을 검색할 수 있다.
2. 포켓몬 ID 는 TextField(EditText) 에 입력하여 검색한다.
3. 검색된 포켓몬 이미지와 ID, 이름, HP 를 표시한다.
4. 검색 중 검색 실패, 네트워크 에러 등의 사유 발생시 Empty 상태로 표시한다.

예제 화면

테스트 내용

우리는 이번 예제에서 ViewModel 을 테스트할 것 입니다.

ViewModel 을 테스트하기 위해서는 어떤것이 필요할까요?
먼저 보통 앱들의 구조를 보겠습니다.

우리는 줄 곧 MVVM 형식의 앱을 개발하고 있습니다.
위 그림에서 보듯, ViewModel 은 UI 표시 및 상호작용을 위한 View상태 를 나타내어 화면을 표시합니다.
또한, Model 에 데이터를 요청하고, 이를 정제하는 역할 또한 하고 있습니다.

ViewModel 이 가지는 의존성 측면에서 보자면,

  1. ViewModelFlow 등을 통한 Observe 패턴으로 인해 View 에 대한 의존성을 가지고 있지 않습니다.
  2. ViewModelModel 에 대한 의존성이 있습니다.

따라서, ViewModel 를 테스트하기 위해 중요한것은 Model 에 대한 의존성을 줄이는 것에 있습니다.

이제, 예제를 통해 Model 에 대한 의존성을 줄이는 방법에 대해 알아보겠습니다.

예제

MainViewModel.kt

@HiltViewModel
class MainViewModel @Inject constructor(
    private val repo: PokemonRepository
): ViewModel() {

	private val _uiState = MutableStateFlow(CardUiState.Empty)
    val uiState = _uiState.asStateFlow()
    
	fun updateUi(id: String) {
        viewModelScope.launch {
        	val pokemon = repo.fetchPokemon(id)
            _uiState.emit(CardUiState.Valid(pokemon))
        }
   	}
	// ... 생략
}

이 클래스는 포켓몬을 검색하고, 이를 UI 에 표시하기 위한 데이터를 가지는 역할을 수행합니다.
따라서, '포켓몬 검색 기능' 을 위한 의존성이 필요한데, 이를 생성자의 인자인 repository 를 통해 주입받고 있습니다.
PokemonRepository 인터페이스는 Model 을 추상화한 클래스입니다.
Model 이 구체화된 Class 가 아닌, 추상화된 Class 를 주입받으므로, Model 에 대한 의존성이 느슨하다고 할 수 있습니다.

PokemonRepository.kt

interface PokemonRepository {

    suspend fun fetchPokemon(id: String): RepoResult<Pokemon>
}

요구 사항 중 1번 '포켓몬 ID 를 통한 검색' 기능을 추상화한 인터페이스 및 구현체 클래스입니다. (Model 의 추상화)
해당 클래스를 통해 Model 에 관한 로직을 구현하거나, 테스트를 위한 모의 클래스를 구현할 수 있습니다.

이렇게 정의된 코드를 가지고 MainViewModel 에 대한 간단한 Unit Test 코드를 작성해보겠습니다.

Unit Test

class MainViewModelTest {

    private lateinit var viewModel: MainViewModel
    private val repository = mock<PokemonRepository>()

    @Before
    fun setup() {
        viewModel = MainViewModel(repository)
    }
	// ... 생략
}

PokemonRepository 는 테스트용 클래스를 직접 정의할 수 있지만, 귀찮으니 Mockito 를 통해 모의 객체를 할당해주었습니다.
할당한 모의 객체를 MainViewModel 에 주입하여 객체를 생성합니다.
이렇게 테스트 코드 작성을 위한 준비가 모두 끝났습니다.

이제, 실제 테스트 로직을 작성해보겠습니다.

    @Test
    fun `정상 값 설정 테스트`() = runTest {
        // given
        val mockResult = RepoResult.Success(
            value = Pokemon(
                id = "1",
                name = "JungIn",
                type = "Fire",
                hp = 100
            )
        )
        whenever(repository.fetchPokemon("1")).thenReturn(mockResult)

        // when
		viewmodel.updateUi("1")

        // then
        val cardState = viewModel.uiState.first()
        assertThat(cardState, IsInstanceOf(CardUiState.Valid::class.java))
    }

Mocktito-kotlin2 의 whenever 메서드를 이용해 repository.fetchPokemon() 메서드의 반환 값을 지정해줍니다. (given)
viewModel.updateUi() 를 통해 MainViewModel 의 '포켓몬 검색 기능' 을 수행하도록 트리거합니다. (when)
assertThat() 메서드를 통해 viewModel.uiState 의 상태를 확인하고 테스트를 종료합니다. (then)

이렇게 테스트가 통과되고, 이렇게 검증된 MainViewModel 을 View 에 활용할 수 있게 됩니다.

어떻나요? 참 쉽죠?😄

만약, Model 에 대한 의존성을 ViewModel 이 가지고 있었다면 테스트가 가능했을까요?

@HiltViewModel
class MainViewModel @Inject constructor(
): ViewModel() {

    private val repo = PokemonRepository(...)

	private val _uiState = MutableStateFlow(CardUiState.Empty)
    val uiState = _uiState.asStateFlow()
	// ... 생략
}

위 코드에서 보시다 싶이 프로퍼티 repo 를 조작할 방법이 없습니다.
따라서.. Model 의 반환 값을 가정한 여러 테스트가 불가능합니다.

끝으로

이렇게 보니 의존성 주입도, 테스트 코드 작성도 참 간단합니다.
다만 우리는 시간에 쫓겨.. 귀찮음으로 인해.. 의존성 주입을 위한 구조 설계나, 더 나아가 테스트 코드 작성을 외면하곤 합니다.

그렇지만, 잘 설계된 구조와 테스트 코드는 앞으로의 코딩 생활에 있어 많은 시간을 단축시켜줄 것이므로🔥
🔥의존성 주입 패턴과 테스트 코드 작성을 생활화 합시다🔥

profile
🕶안드로이드 개발자입니다! 🕶

0개의 댓글