관리 메뉴

나만을 위한 블로그

[Android] JUnit4 환경에서 단위 테스트 작성하는 법 (Github API) 본문

Android

[Android] JUnit4 환경에서 단위 테스트 작성하는 법 (Github API)

참깨빵위에참깨빵 2023. 7. 2. 03:12
728x90
반응형

Github API로 앱이 동작하는 건 지난 포스팅에서 확인했으니, 이제 테스트를 작성해서 내가 짠 코드들이 정말 정상적으로 작동하는지 확인할 차례다. 테스트를 먼저 작성한 후 기능을 구현하는 TDD라는 개념도 있지만, 여기선 선 기능구현 후 테스트 작성이라는 상황이라고 가정한다. 이 글의 바탕이 되는 코드는 아래 링크에서 확인할 수 있다.

 

https://onlyfor-me-blog.tistory.com/725

 

[Android] 페이징 라이브러리, Hilt, LiveData로 Github API 사용하기

23.05.02 - GithubPagingSource 소스코드 추가 페이징 라이브러리 3을 사용해 Github API를 사용하는 법을 확인한다. 먼저 사용할 라이브러리는 아래와 같다. 페이징 라이브러리 3 Hilt Flow (repository에서 데이

onlyfor-me-blog.tistory.com

 

깃허브 API를 쓰기 위해 필요한 토큰을 프로젝트에 세팅하는 과정은 생략한다. 아래는 인터페이스와 repository, 뷰모델의 코드다.

 

interface GithubApiService {
    @GET("search/repositories?sort=stars")
    suspend fun searchRepos(
        @Query(QUERY) query: String,
        @Query(PAGE) page: Int,
        @Query(PER_PAGE) perPage: Int
    ): RepoSearchResponse
}
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class GithubRepository @Inject constructor(
    private val service: GithubApiService
) {
    fun getSearchRepoResult(query: String): LiveData<PagingData<Repo>> {
        if (query.isBlank()) {
            return MutableLiveData<PagingData<Repo>>(PagingData.empty()).asFlow().asLiveData()
        }
        return Pager(
            config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false,
                prefetchDistance = 200
            ),
            pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow.asLiveData()
    }
}
import androidx.lifecycle.*
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class GithubViewModel @Inject constructor(
    private val repository: GithubRepository
): ViewModel() {
    private val _searchQuery = MutableLiveData<String>()
    val searchQuery: LiveData<String> = _searchQuery

    private val repoResult: LiveData<PagingData<Repo>> = _searchQuery.switchMap { query ->
        if (query.isBlank()) {
            MutableLiveData(PagingData.empty())
        } else {
            repository.getSearchRepoResult(query).cachedIn(viewModelScope)
        }
    }

    val repos: LiveData<PagingData<Repo>> = repoResult

    fun searchRepos(query: String) {
        _searchQuery.value = query
    }
}

 

이제 앱 gradle에 테스트에 필요한 의존성들을 추가해준다.

 

testImplementation "org.mockito:mockito-core:3.12.4"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4"
testImplementation "org.robolectric:robolectric:4.3.1"
testImplementation "androidx.arch.core:core-testing:2.0.0"
testImplementation "androidx.test:core-ktx:1.5.0"
testImplementation "io.mockk:mockk:1.13.3"
testImplementation "androidx.test.ext:junit:1.1.5"
testImplementation "androidx.test.ext:junit-ktx:1.1.5"
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
androidTestImplementation "androidx.test:runner:1.5.2"
androidTestImplementation "androidx.test:core-ktx:1.5.0"
androidTestImplementation "androidx.test:rules:1.5.0"
testImplementation "org.hamcrest:hamcrest:2.2"

 

중구난방으로 의존성들이 뒤섞여 있는데 크게 보면 아래 의존성들이 필요하다.

 

  • mockk : 단위 테스트 작성 시 사용할 클래스들을 mocking하기 위해 필요한 라이브러리. mockito와 비슷하다.
  • hamcrest : 필요한 경우 Matcher를 써서 assertion 안에서 데이터의 일치성 여부를 검사하는 등의 상황이 발생할 수 있는데, 그 때 사용할 수 있는 프레임워크. 좀 더 직관적인 Matcher를 제공함
  • robolectric : 테스트 케이스는 하나만 작성하지 않는다. 하나의 기능에 대해 몇 개의 테스트를 작성할 수 있는데, 각 테스트마다 필요한 안드로이드 환경이 다른 경우가 발생할 수 있다. 이 때 테스트들이 각각의 다른 안드로이드 환경에서 실행되게 도와주는 라이브러리

 

이제 테스트를 작성해 본다. java 패키지는 총 3가지 종류의 패키지를 갖고 있다.

 

  • com.example.xxx
  • com.example.xxx (androidTest)
  • com.example.xxx (test)

 

첫 번째는 앱 구현에 필요한 클래스들을 모아둔 패키지고 androidTest는 UI 테스트와 통합 테스트, test는 단위 테스트를 작성하는 파일들을 모아놓는 패키지다.

이번에 작성하는 건 단위 테스트기 때문에 (test)라고 써진 패키지에 새 클래스를 만든다.

 

class MyViewModelTest {
}

 

그리고 아래와 같이 클래스명 위에 어노테이션을 하나 추가해준다. import도 빼먹지 말고 해준다.

 

import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class MyViewModelTest {
}

 

별도로 프로젝트 환경설정을 바꾸지 않았다면 프로젝트는 JUnit4로 설정돼 있을 것이다. 하지만 MyViewModelTest 클래스가 어떤 환경에서 테스트되는지는 어노테이션이 붙기 전까진 컴파일러가 알 수 없으니, 해당 테스트를 JUnit4 환경에서 구동하겠다는 뜻으로 @RunWith 어노테이션을 쓰고 JUnit4를 명시해준다.

이제 테스트를 작성해야 할까 생각되겠지만 아니다. 아직 테스트할 코드의 환경설정이 끝나지 않았다.

 

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.example.kotlinprac.paging.prac.GithubRepository
import com.example.kotlinprac.paging.prac.GithubViewModel
import org.junit.Rule
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class MyViewModelTest {
    @get:Rule
    var instantExecutorRule: TestRule = InstantTaskExecutorRule()

    private lateinit var repository: GithubRepository
    private lateinit var viewModel: GithubViewModel
}

 

InstantTaskExecutorRule()은 꼭 필요하다. 왜냐면 뷰모델의 코드를 다시 확인해 보면 LiveData를 사용하고 있다. 그리고 LiveData.postValue()는 백그라운드에서 작동한다.

테스트를 실행하면 테스트 실행기는 postValue()가 백그라운드에서 실행 중인 걸 알 수가 없다. 그래서 나중에 테스트 케이스 안에서 뷰모델의 함수를 호출할 경우 NullPointerException이 발생하면서 테스트가 깨지거나 LiveData의 값이 변경되지 않아 assertion과 다르다는 에러를 뿜으며 테스트가 실패할 수 있다. 그래서 InstantTastkExecutorRule()은 무조건 써 줘야 한다.

 

다음으로 내가 작성할 테스트 케이스(=@Test 어노테이션이 붙은 함수들)들이 각각 실행되기 전, 후에 수행할 코드들을 @Before, @After 어노테이션이 붙은 함수에 작성해준다.

 

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.example.kotlinprac.paging.prac.GithubRepository
import com.example.kotlinprac.paging.prac.GithubViewModel
import io.mockk.mockk
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class MyViewModelTest {
    @get:Rule
    var instantExecutorRule: TestRule = InstantTaskExecutorRule()

    private lateinit var repository: GithubRepository
    private lateinit var viewModel: GithubViewModel

    @Before
    fun setUp() {
        repository = mockk(relaxed = true)
        viewModel = GithubViewModel(repository)
    }

    @After
    fun tearDown() { /* 각 테스트 종료 후 수행해야 하는 코드 작성 */ }
}

 

테스트 케이스마다 repository, 뷰모델을 초기화하는 코드를 넣으면 테스트 케이스들이 쓸데없이 길어져서 가독성에 좋지 않다. 그러므로 @Before를 붙인 함수를 만들고, 이 함수 안에서 각 테스트 케이스가 실행되기 전에 수행돼야 하는 코드들을 넣는다. 아래 경우들을 생각할 수 있다.

 

  • MockWebServer 인스턴스 초기화
  • 필요할 경우 테스트 케이스의 코루틴이 실행될 쓰레드를 테스트 환경에서 사용하는 쓰레드로 변경

 

@After가 붙은 함수 안에는 테스트가 끝난 후 실행돼야 하는 코드들을 넣는다. 아래 경우들을 생각할 수 있다.

 

  • MockWebServer를 사용한 특정 테스트가 끝나면 해당 MockWebServer 인스턴스를 닫아야 한다
  • @Before에서 코루틴이 실행되는 쓰레드를 바꾼 경우 해당 테스트가 끝나면 되돌려놔야 한다

 

이외에도 여러 경우가 있지만 이 포스팅에선 @After에 작성해야 하는 코드가 없으니 무시한다.

여기까지 끝났다면 드디어 테스트를 작성할 수 있다. 먼저 특정 검색어를 입력한 경우 뷰모델의 LiveData에 해당 검색어가 들어가는지 확인하는 테스트 케이스를 아래와 같이 작성한다.

 

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.example.kotlinprac.paging.prac.GithubRepository
import com.example.kotlinprac.paging.prac.GithubViewModel
import io.mockk.mockk
import org.junit.*
import org.junit.Assert.assertEquals
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class MyViewModelTest {
    @get:Rule
    var instantExecutorRule: TestRule = InstantTaskExecutorRule()

    private lateinit var repository: GithubRepository
    private lateinit var viewModel: GithubViewModel

    @Before
    fun setUp() {
        repository = mockk(relaxed = true)
        viewModel = GithubViewModel(repository)
    }

    @Test
    fun `특정 검색어를 입력하고 검색한 경우 LiveData의 값이 바뀐다`() {
        // Given
        val query = "android"

        // When
        viewModel.searchRepos(query)

        // Then
        assertEquals(viewModel.searchQuery.value, query)
    }
}

 

테스트 이름은 백틱을 사용하면 한글로 작성할 수 있으니 참고한다. 이렇게 작성하고 테스트를 실행하면 테스트는 성공할 것이다. Run 탭은 아래처럼 표시될 것이다.

 

 

이제 다른 테스트 케이스들을 추가해서 프로젝트를 더 견고하게 만들 수 있다.

반응형
Comments