2023. 4. 21. 20:33ㆍandroid
안녕하세요 이번 편에서는 JUnit4를 베이스로 mockk와 함께 mvvm 패턴이 적용된 프로젝트를 unit test 하는 방법에 대해 알아보도록 하겠습니다. 부족한 점이 많아 이해가 안 되시거나 잘못된 점이 있다면 감사히 듣겠습니다! 간단한 Note 앱을 가지고 유닛테스트를 어떻게 적용해야 하는지 실습하면서 겪었던 오류나 어려웠던 점들을 함께 공유하며 성장하는 시간이 되길 바랍니다.
- 아키텍처 패턴은 MVVM 패턴
- Gradle Plugins version (Groovy): 8.0.0
- kotlin version : 1.7.20
- room version : 2.5.1
- mock version : 1.10.1
- Gradle JDK : jetbrains Runtime 17.0.6
Dependency
dependencies {
//room
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
kapt "androidx.room:room-compiler:$room_version"
//test core
implementation 'androidx.test:core-ktx:1.5.0'
//junit
implementation 'androidx.test.ext:junit-ktx:1.1.5'
//room testing
testImplementation "androidx.room:room-testing:$room_version"
androidTestImplementation "androidx.room:room-testing:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
//mock testing
testImplementation "io.mockk:mockk:1.13.5"
androidTestImplementation "io.mockk:mockk-android:1.13.5"
//coroutine Testing
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
//junit4 Testing
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
//espresso Testing
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
//core AndroidTest
androidTestImplementation 'androidx.test:core:1.5.0'
//compose-bom Testing
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
//compose ui testing
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
//debug
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
dependency는 확인을 해주셔야 합니다. 버전끼리 충돌이 생길 수도 있습니다. 그럴 경우는 알맞은 버전에 맞춰서 해주세요! 안 그러면 아시죠?? 빌드 오류 늪에 빠져서 시간이 사라질 수 있습니다;; 그리고
build.app 앱 수준에서
android {
defaultConfig {
====여기 안에 추가=====
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
위에 위치에 러너를 적어줘야 합니다. 기본적으로 적혀있지만 확인 한 번 해보세요~
이제 시작하도록 하겠습니다.
먼저 두 개가 필요합니다. 당연히 검사할 메소드, 함수가 들어있는 클래스
1. Repository
2. ViewModel
2개는 필수적으로 필요합니다. 여기에 저는 usecase를 사용하여 usecase를 포함한 unit test 방법입니다.
Mainviewmodel
@HiltViewModel
class MainViewModel @Inject constructor(
private val getNotes: GetNotes,
private val addNote: AddNote,
private val deleteNote: DeleteNote
) : ViewModel() {
init {
getAllNotes()
}
fun getAllNotes() {
getNotesFlow().launchIn(viewModelScope)
}
// test의 용이성을 위해 flow를 나누어 아래와 같이 처리함
// return값으로 flow를 받기 때문에 테스트가능해짐
fun getNotesFlow() : Flow<List<Note>> = getNotes()
.onEach {
val list = it.sortedByDescending { note-> note.timestamp }
state.value = state.value.copy(notes = list)
}
}
viewmodel은 3개의 usecase를 주입받습니다.
1. getNotes = 저장된 모든 노트를 불러오는 usecase
2. addNote = 저장하는 usecase
3. deleteNote = 삭제하는 usecase
init으로 getAllNotes()를 실행해서 getAllNotes()에서는 flow를 launch 합니다.
Mock Repository 만들기
viewmodel 생성자로 usecase를 주입받는다면 mock usecase를 만들어야 합니다.
하지만 usecase를 그냥 mockk를 이용해 만들면 initialize 가 안된다고 오류가 발생합니다.
이유는 usecase를 불러올때 mock 객체로 만들 경우 어떤 값을 반환할지 모르기 때문입니다. repository가 구현되어있지 않아 repository를 주입해야하는데 repository를 직접 구현하는 방식으로 접근 가능합니다.
Fake Repository 구현
Test 폴더 안에 Repository를 만들어줍니다. 그리고 Repository 인터페이스를 상속하여 함수들을 작성해 줍니다.
이 repository가 mock의 역할을 해 줍니다. 구현하려는 Repository 인터페이스를 상속하여 적절하게 구현하시면 됩니다.
class FakeRepository : Repository {
private val notes = mutableListOf<Note>()
override fun getNotes(): Flow<List<Note>> {
return flow { emit(notes) }
}
override suspend fun getNote(id: Int): Note {
return notes.find { it.id == id }!!
}
override suspend fun insertNote(note: Note) {
notes.add(note)
}
override suspend fun deleteNote(note: Note) {
notes.remove(note)
}
}
Usecase ( GetNotes )
아래는 usecase인데 참고하시기 바랍니다.
class GetNotes @Inject constructor(
private val repository: Repository
){
operator fun invoke(): Flow<List<Note>> {
return repository.getNotes()
}
}
MainViewModelTest 만들기
이제 MainViewModel을 만들어줍니다. 동일하게 Test파일 안에 클래스를 생성해 주시면 됩니다.
코드를 보시겠습니다.
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(JUnit4::class)
class MainViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val dispatcher = StandardTestDispatcher()
private val scope = TestScope(dispatcher)
private val notesTest = mutableListOf<Note>()
private lateinit var viewModel: MainViewModel
private lateinit var repository: FakeRepository
private lateinit var getNotes: GetNotes
private lateinit var addNote: AddNote
private lateinit var deleteNote: DeleteNote
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
(0..10).forEach { i ->
notesTest.add(Note(i, "$i", "$i", i.toLong(), i))
}
repository = FakeRepository()
getNotes = GetNotes(repository)
addNote = AddNote(repository)
deleteNote = DeleteNote(repository)
viewModel = MainViewModel(getNotes, addNote, deleteNote)
runBlocking {
notesTest.forEach {
repository.insertNote(it)
}
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `when getAllNotes() is called, then the notes are sorted and updated in the state`() =
runTest {
//when
val state = viewModel.state.value
viewModel.getAllNotes()
delay(2000)
//then
val result = viewModel.state.value.notes.sortedBy { it.id }
assertEquals(notesTest, result)
}
}
1. 어노테이션
ViewModelTest를 만들 때 클래스명 위에 2개의 annotation이 있습니다.
- @OptIn : 이 어노테이션은 Couroutines Test 라이브러리를 사용하기 위한 옵션입니다.
현재 이 라이브러리의 사용이 Experimental 하다는 의미입니다.
- @RunWith(JUnit4::class) : JUnit4의 테스트 환경으로 세팅한다는 의미입니다.
테스트 러너가 환경을 세팅하고 테스트 가능하게 해 줍니다. 이 러너를 지정해 주는 의미입니다.
2. Rule
@get:Rule : JUnit에서 코틀린 언어가 사용하는 어노테이션입니다. Rule은 테스트 코드에서 공통적으로 사용하는 설정이나 동작을 별도의 클래스로 분리하여 재사용성을 높이고 객체를 자동으로 주입해 줍니다. 이렇게 주입된 Rule 객체를 사용하여 테스트 전후에 필요한 동작을 수행할 수 있습니다. 여기서 사용된 MainDispatherRule()은 coroutine Test시에 스레드를 지정해 테스트 코드에서 코루틴이 동작할 수 있게 해 줍니다.
( 코루틴 디스패처(coroutine dispatcher)는 비동기적으로 실행되는 코루틴(coroutine)들을 관리하고 스케줄링하는 역할을 합니다. )
코루틴 디스패처를 지정해주지 않으면 아래와 같은 오류가 발생합니다.
See https://www.slf4j.org/codes.html#noProviders for further details.
Exception in thread "Test worker" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
MainDispatcherRule ( test 폴더 안에 추가하시면 됩니다. )
class MainDispatcherRule @OptIn(ExperimentalCoroutinesApi::class) constructor(
private val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
@OptIn(ExperimentalCoroutinesApi::class)
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
}
rule을 사용하지 않고 아래와 같이 Dispatchers를 직접 지정해서 사용하셔도 무방합니다.
@Before
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
SetUp 함수
@Before 어노테이션을 사용하여 테스트에 필요한 객체들의 초기화를 진행합니다.
현재 setUP에 두 개로 나눠서 repository와 usecase를 초기화 한 걸 보실 수 있습니다.
먼저
1. 과정은 notesTest 변수에 Note 객체 0부터 10까지를 저장합니다.
2. repository를 생성해 줍니다. 이때 구현 방식은 FakeRepository class를 통해 만들고 mockk를 이용하는 객체는 mockk() 함수를 통해 만듭니다.
3. usecase 변수를 설정합니다.
4. mockk repository의 사용을 위해 미리 every 함수를 통해 반환값을 지정해 줍니다.
5. viewModel 객체를 만들어줍니다.
6. 1번 구현방식을 위해 runTest 안에서 fakeRepository안에 noteTest 노트들을 저장해 줍니다.
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
(0..10).forEach { i ->
notesTest.add(Note(i, "$i", "$i", i.toLong(), i))
}
repository = FakeRepository()
getNotes = GetNotes(repository)
addNote = AddNote(repository)
deleteNote = DeleteNote(repository)
viewModel = MainViewModel(getNotes, addNote, deleteNote)
runBlocking {
notesTest.forEach {
repository.insertNote(it)
}
}
}
Test 함수
BDD 형식으로 간단하게 만들었습니다.
@Test
fun `when getAllNotes() is called, then the notes are sorted and updated in the state`() =
runTest {
//when
val state = viewModel.state.value
viewModel.getAllNotes()
delay(2000)
//then
val result = viewModel.state.value.notes.sortedBy { it.id }
assertEquals(notesTest, result)
}
'android' 카테고리의 다른 글
Retrofit2 MockServer test case 작성 (0) | 2023.04.27 |
---|---|
JUnit4를 이용한 Room DabaBase Test (0) | 2023.04.23 |
한 번의 글로는 이해가 안 가는 안드로이드 테스트 ( 유닛 테스트 & 계측 테스트 ) (0) | 2023.04.21 |
Android Compose (0) | 2022.09.28 |
안드로이드 4대 컴포넌트 (0) | 2022.09.26 |