From 9e3bbe58ba4c68163b84856c645d45fd4cfc2524 Mon Sep 17 00:00:00 2001 From: Sean Mac Gillicuddy Date: Mon, 27 May 2019 17:02:04 +0100 Subject: [PATCH] #1174 Introduce Unit Testing - example of unit testing of a viewmodel --- app/build.gradle | 11 + .../di/modules/ApplicationModule.java | 6 + .../downloader/model/BookOnDisk.kt | 4 +- .../downloader/model/DownloadModel.kt | 7 +- .../downloader/model/DownloadStatus.kt | 32 +-- .../ConnectivityBroadcastReceiver.kt | 8 +- .../zim_manager/ZimManageViewModel.kt | 13 +- .../kiwixmobile/InstantExecutorExtension.kt | 44 ++++ .../zim_manager/ZimManageViewModelTest.kt | 208 ++++++++++++++++++ .../test/resources/junit-platform.properties | 1 + 10 files changed, 309 insertions(+), 25 deletions(-) create mode 100644 app/src/test/java/org/kiwix/kiwixmobile/InstantExecutorExtension.kt create mode 100644 app/src/test/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModelTest.kt create mode 100644 app/src/test/resources/junit-platform.properties diff --git a/app/build.gradle b/app/build.gradle index f7fbf6b5f..bcb4d8e40 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -136,6 +136,14 @@ dependencies { implementation "android.arch.lifecycle:extensions:1.1.1" implementation "io.objectbox:objectbox-kotlin:$objectboxVersion" implementation "io.objectbox:objectbox-rxjava:$objectboxVersion" + + testImplementation "org.junit.jupiter:junit-jupiter:5.4.2" + testImplementation "io.mockk:mockk:1.9" + testImplementation "org.assertj:assertj-core:3.11.1" + //update this with androidx + testImplementation 'com.jraska.livedata:testing-ktx:0.2.1' + testImplementation 'android.arch.core:core-testing:1.1.1' + } // Set custom app import directory @@ -364,6 +372,9 @@ android { androidExtensions { experimental = true } + testOptions { + unitTests.returnDefaultValues = true + } } // Testdroid deployment configuration diff --git a/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ApplicationModule.java b/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ApplicationModule.java index 794afc49b..6c91b50ac 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ApplicationModule.java +++ b/app/src/main/java/org/kiwix/kiwixmobile/di/modules/ApplicationModule.java @@ -25,6 +25,7 @@ import dagger.Module; import dagger.Provides; import dagger.android.AndroidInjectionModule; import javax.inject.Singleton; +import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter; import org.kiwix.kiwixmobile.utils.BookUtils; @Module(includes = { @@ -52,4 +53,9 @@ public class ApplicationModule { BookUtils provideBookUtils() { return new BookUtils(); } + + @Provides @Singleton + UriToFileConverter provideUriToFIleCOnverter() { + return new UriToFileConverter.Impl(); + } } diff --git a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/BookOnDisk.kt b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/BookOnDisk.kt index a5076d04a..147ec3676 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/BookOnDisk.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/BookOnDisk.kt @@ -6,8 +6,8 @@ import java.io.File data class BookOnDisk( val databaseId: Long? = null, - val book: Book, - val file: File + val book: Book = Book().apply { id = "" }, + val file: File = File("") ) { constructor(bookOnDiskEntity: BookOnDiskEntity) : this( bookOnDiskEntity.id, diff --git a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadModel.kt b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadModel.kt index 8fdc34594..dc0fa25ab 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadModel.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadModel.kt @@ -17,12 +17,11 @@ */ package org.kiwix.kiwixmobile.downloader.model -import android.view.WindowId -import org.kiwix.kiwixmobile.database.newdb.entities.DownloadEntity import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity +import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book data class DownloadModel( val databaseId: Long? = null, - val downloadId: Long, - val book: LibraryNetworkEntity.Book + val downloadId: Long = 0, + val book: LibraryNetworkEntity.Book = Book().apply { id = "" } ) diff --git a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadStatus.kt b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadStatus.kt index f2c83d8e8..9636b35a9 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadStatus.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/downloader/model/DownloadStatus.kt @@ -55,21 +55,22 @@ import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity.Book import java.io.File class DownloadStatus( - val downloadId: Long, - val title: String, - val description: String, - val state: DownloadState, - val bytesDownloadedSoFar: Long, - val totalSizeBytes: Long, - val lastModified: String, - val localUri: String?, - val mediaProviderUri: String?, - val mediaType: String?, - val uri: String?, - val book: Book + val downloadId: Long = 0L, + val title: String = "", + val description: String = "", + val state: DownloadState = DownloadState.Pending, + val bytesDownloadedSoFar: Long = 0, + val totalSizeBytes: Long = 0, + val lastModified: String = "", + val localUri: String? = null, + val mediaProviderUri: String? = null, + val mediaType: String? = null, + val uri: String? = null, + val book: Book = Book().apply { id = "" } ) { - fun toBookOnDisk() = BookOnDisk(book = book, file = File(Uri.parse(localUri).path)) + fun toBookOnDisk(uriToFileConverter:UriToFileConverter) = + BookOnDisk(book = book, file = uriToFileConverter.convert(localUri)) constructor( cursor: Cursor, @@ -90,6 +91,11 @@ class DownloadStatus( ) } +interface UriToFileConverter { + fun convert(uriString: String?) = File(Uri.parse(uriString).path) + class Impl:UriToFileConverter{} +} + sealed class DownloadState(val stringId: Int) { companion object { fun from( diff --git a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ConnectivityBroadcastReceiver.kt b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ConnectivityBroadcastReceiver.kt index ff8c9ab5a..a24280c91 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ConnectivityBroadcastReceiver.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ConnectivityBroadcastReceiver.kt @@ -3,8 +3,8 @@ package org.kiwix.kiwixmobile.zim_manager import android.content.Context import android.content.Intent import android.net.ConnectivityManager +import io.reactivex.Flowable import io.reactivex.processors.BehaviorProcessor -import io.reactivex.processors.PublishProcessor import org.kiwix.kiwixmobile.extensions.networkState import javax.inject.Inject @@ -13,14 +13,14 @@ class ConnectivityBroadcastReceiver @Inject constructor(private val connectivity override val action: String = ConnectivityManager.CONNECTIVITY_ACTION - val networkStates = - BehaviorProcessor.createDefault(connectivityManager.networkState) + private val _networkStates = BehaviorProcessor.createDefault(connectivityManager.networkState) + val networkStates: Flowable = _networkStates override fun onIntentWithActionReceived( context: Context, intent: Intent ) { - networkStates.onNext(connectivityManager.networkState) + _networkStates.onNext(connectivityManager.networkState) } } \ No newline at end of file diff --git a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModel.kt b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModel.kt index 22997ee78..09c06f09b 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModel.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModel.kt @@ -21,6 +21,7 @@ package org.kiwix.kiwixmobile.zim_manager import android.app.Application import android.arch.lifecycle.MutableLiveData import android.arch.lifecycle.ViewModel +import android.support.annotation.VisibleForTesting import io.reactivex.Flowable import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable @@ -39,6 +40,7 @@ import org.kiwix.kiwixmobile.downloader.model.DownloadItem import org.kiwix.kiwixmobile.downloader.model.DownloadModel import org.kiwix.kiwixmobile.downloader.model.DownloadState.Successful import org.kiwix.kiwixmobile.downloader.model.DownloadStatus +import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter import org.kiwix.kiwixmobile.extensions.calculateSearchMatches import org.kiwix.kiwixmobile.extensions.registerReceiver import org.kiwix.kiwixmobile.library.entity.LibraryNetworkEntity @@ -71,7 +73,8 @@ class ZimManageViewModel @Inject constructor( private val context: Application, private val connectivityBroadcastReceiver: ConnectivityBroadcastReceiver, private val bookUtils: BookUtils, - private val fat32Checker: Fat32Checker + private val fat32Checker: Fat32Checker, + private val uriToFileConverter: UriToFileConverter ) : ViewModel() { val libraryItems: MutableLiveData> = MutableLiveData() @@ -94,6 +97,11 @@ class ZimManageViewModel @Inject constructor( context.registerReceiver(connectivityBroadcastReceiver) } + @VisibleForTesting + fun onClearedExposed() { + onCleared() + } + override fun onCleared() { compositeDisposable.clear() context.unregisterReceiver(connectivityBroadcastReceiver) @@ -155,6 +163,7 @@ class ZimManageViewModel @Inject constructor( ) .buffer(3, SECONDS) .map(this::downloadIdsWithNoStatusesOverBufferPeriod) + .filter { it.isNotEmpty() } .subscribe( { downloadDao.delete(*it.toLongArray()) @@ -411,7 +420,7 @@ class ZimManageViewModel @Inject constructor( .filter { it.isNotEmpty() } .subscribe( { - bookDao.insert(it.map { downloadStatus -> downloadStatus.toBookOnDisk() }) + bookDao.insert(it.map { downloadStatus -> downloadStatus.toBookOnDisk(uriToFileConverter) }) downloadDao.delete( *it.map { status -> status.downloadId }.toLongArray() ) diff --git a/app/src/test/java/org/kiwix/kiwixmobile/InstantExecutorExtension.kt b/app/src/test/java/org/kiwix/kiwixmobile/InstantExecutorExtension.kt new file mode 100644 index 000000000..cfd015dc4 --- /dev/null +++ b/app/src/test/java/org/kiwix/kiwixmobile/InstantExecutorExtension.kt @@ -0,0 +1,44 @@ +/* + * Kiwix Android + * Copyright (C) 2018 Kiwix + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kiwix.kiwixmobile + +import android.arch.core.executor.ArchTaskExecutor +import android.arch.core.executor.TaskExecutor +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance() + .setDelegate(object : TaskExecutor() { + override fun executeOnDiskIO(runnable: Runnable) = runnable.run() + + override fun postToMainThread(runnable: Runnable) = runnable.run() + + override fun isMainThread(): Boolean = true + }) + } + + override fun afterEach(context: ExtensionContext?) { + ArchTaskExecutor.getInstance().setDelegate(null) + } + +} \ No newline at end of file diff --git a/app/src/test/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModelTest.kt b/app/src/test/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModelTest.kt new file mode 100644 index 000000000..e72f51107 --- /dev/null +++ b/app/src/test/java/org/kiwix/kiwixmobile/zim_manager/ZimManageViewModelTest.kt @@ -0,0 +1,208 @@ +/* + * Kiwix Android + * Copyright (C) 2018 Kiwix + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kiwix.kiwixmobile.zim_manager + +import android.app.Application +import com.jraska.livedata.test +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.reactivex.Scheduler +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.processors.PublishProcessor +import io.reactivex.schedulers.Schedulers +import io.reactivex.schedulers.TestScheduler +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.kiwix.kiwixmobile.InstantExecutorExtension +import org.kiwix.kiwixmobile.database.newdb.dao.NewBookDao +import org.kiwix.kiwixmobile.database.newdb.dao.NewDownloadDao +import org.kiwix.kiwixmobile.database.newdb.dao.NewLanguagesDao +import org.kiwix.kiwixmobile.downloader.Downloader +import org.kiwix.kiwixmobile.downloader.model.BookOnDisk +import org.kiwix.kiwixmobile.downloader.model.DownloadItem +import org.kiwix.kiwixmobile.downloader.model.DownloadModel +import org.kiwix.kiwixmobile.downloader.model.DownloadState +import org.kiwix.kiwixmobile.downloader.model.DownloadStatus +import org.kiwix.kiwixmobile.downloader.model.UriToFileConverter +import org.kiwix.kiwixmobile.network.KiwixService +import org.kiwix.kiwixmobile.utils.BookUtils +import org.kiwix.kiwixmobile.zim_manager.Fat32Checker.FileSystemState +import org.kiwix.kiwixmobile.zim_manager.NetworkState.NOT_CONNECTED +import org.kiwix.kiwixmobile.zim_manager.fileselect_view.StorageObserver +import org.kiwix.kiwixmobile.zim_manager.library_view.adapter.Language +import java.io.File +import java.util.concurrent.TimeUnit.SECONDS + +@ExtendWith(InstantExecutorExtension::class) +class ZimManageViewModelTest { + + private val newDownloadDao: NewDownloadDao = mockk() + private val newBookDao: NewBookDao = mockk() + private val newLanguagesDao: NewLanguagesDao = mockk() + private val downloader: Downloader = mockk() + private val storageObserver: StorageObserver = mockk() + private val kiwixService: KiwixService = mockk() + private val application: Application = mockk() + private val connectivityBroadcastReceiver: ConnectivityBroadcastReceiver = mockk() + private val bookUtils: BookUtils = mockk() + private val fat32Checker: Fat32Checker = mockk() + private val uriToFileConverter: UriToFileConverter = mockk() + lateinit var viewModel: ZimManageViewModel + + private val downloads: PublishProcessor> = PublishProcessor.create() + private val books: PublishProcessor> = PublishProcessor.create() + private val languages: PublishProcessor> = PublishProcessor.create() + private val fileSystemStates: PublishProcessor = PublishProcessor.create() + private val networkStates: PublishProcessor = PublishProcessor.create() + + private val testScheduler = TestScheduler() + + init { + setScheduler(testScheduler) + } + + private fun setScheduler(replacementScheduler: Scheduler) { + RxJavaPlugins.setIoSchedulerHandler { scheduler -> replacementScheduler } + RxJavaPlugins.setComputationSchedulerHandler { scheduler -> replacementScheduler } + RxJavaPlugins.setNewThreadSchedulerHandler { scheduler -> replacementScheduler } + RxAndroidPlugins.setInitMainThreadSchedulerHandler { scheduler -> Schedulers.trampoline() } + } + + @AfterAll + fun teardown() { + RxJavaPlugins.reset() + RxAndroidPlugins.reset(); + } + + @BeforeEach + fun init() { + clearMocks( + newDownloadDao, newBookDao, newLanguagesDao, downloader, + storageObserver, kiwixService, application, connectivityBroadcastReceiver, bookUtils, + fat32Checker, uriToFileConverter + ) + every { connectivityBroadcastReceiver.action } returns "test" + every { newDownloadDao.downloads() } returns downloads + every { newBookDao.books() } returns books + every { newLanguagesDao.languages() } returns languages + every { fat32Checker.fileSystemStates } returns fileSystemStates + every { connectivityBroadcastReceiver.networkStates } returns networkStates + every { application.registerReceiver(any(), any()) } returns mockk() + viewModel = ZimManageViewModel( + newDownloadDao, newBookDao, newLanguagesDao, downloader, + storageObserver, kiwixService, application, connectivityBroadcastReceiver, bookUtils, + fat32Checker, uriToFileConverter + ) + testScheduler.triggerActions() + } + + @Nested + inner class Context { + @Test + fun `registers broadcastReceiver in init`() { + verify { + application.registerReceiver(connectivityBroadcastReceiver, any()) + } + } + + @Test + fun `unregisters broadcastReceiver in onCleared`() { + every { application.unregisterReceiver(any()) } returns mockk() + viewModel.onClearedExposed() + verify { + application.unregisterReceiver(connectivityBroadcastReceiver) + } + } + } + + @Nested + inner class Downloads { + @Test + fun `on emission from database query and render downloads`() { + val expectedStatus = DownloadStatus() + expectStatus(expectedStatus) + viewModel.downloadItems + .test() + .assertValue(listOf(DownloadItem(expectedStatus))) + .dispose() + } + + @Test + fun `on emission of successful status create a book and delete the download`() { + every { newBookDao.insert(any()) } returns Unit + every { newDownloadDao.delete(any()) } returns Unit + every { uriToFileConverter.convert(any()) } returns File("test") + val expectedStatus = DownloadStatus( + downloadId = 10L, + state = DownloadState.Successful + ) + expectStatus(expectedStatus) + val element = expectedStatus.toBookOnDisk(uriToFileConverter) + verify { + newBookDao.insert(listOf(element)) + newDownloadDao.delete(10L) + } + } + + private fun expectStatus(expectedStatus: DownloadStatus) { + val downloadList = listOf(DownloadModel()) + every { downloader.queryStatus(downloadList) } returns listOf(expectedStatus) + downloads.offer(downloadList) + testScheduler.triggerActions() + testScheduler.advanceTimeBy(1, SECONDS) + testScheduler.triggerActions() + } + } + + @Nested + inner class Books { + @Test + fun `emissions from DB sorted by title and observed`() { + books.onNext( + listOf( + BookOnDisk().apply { book.title = "z" }, + BookOnDisk().apply { book.title = "a" } + ) + ) + testScheduler.triggerActions() + viewModel.bookItems.test() + .assertValue( + listOf( + BookOnDisk().apply { book.title = "a" }, + BookOnDisk().apply { book.title = "z" } + ) + ) + .dispose() + } + } + + @Test + fun `network states observed`() { + networkStates.offer(NOT_CONNECTED) + viewModel.networkStates.test() + .assertValue(NOT_CONNECTED) + .dispose() + } +} \ No newline at end of file diff --git a/app/src/test/resources/junit-platform.properties b/app/src/test/resources/junit-platform.properties new file mode 100644 index 000000000..e6d55f8bd --- /dev/null +++ b/app/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.testinstance.lifecycle.default = per_class \ No newline at end of file