From 5cf61c95dbe15b7614d31d67f5bdf1d5604d9a6d Mon Sep 17 00:00:00 2001 From: Colin Lee Date: Tue, 19 Feb 2019 20:10:17 -0600 Subject: [PATCH] Fixes #589: Add sample unit tests for a component --- app/build.gradle | 25 +++++- .../fenix/library/history/HistoryComponent.kt | 2 + .../java/org/mozilla/fenix/ExampleUnitTest.kt | 17 ---- .../test/java/org/mozilla/fenix/TestUtils.kt | 28 ++++++ .../library/history/HistoryComponentTest.kt | 90 +++++++++++++++++++ .../java/org/mozilla/fenix/mvi/UIComponent.kt | 9 +- .../java/org/mozilla/fenix/test/Mockable.kt | 7 ++ build.gradle | 1 + buildSrc/src/main/java/Dependencies.kt | 17 +++- 9 files changed, 170 insertions(+), 26 deletions(-) delete mode 100644 app/src/test/java/org/mozilla/fenix/ExampleUnitTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/TestUtils.kt create mode 100644 app/src/test/java/org/mozilla/fenix/library/history/HistoryComponentTest.kt create mode 100644 architecture/src/main/java/org/mozilla/fenix/test/Mockable.kt diff --git a/app/build.gradle b/app/build.gradle index a86f57eef..08699645a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -65,6 +65,16 @@ android { } } +android.applicationVariants.all { variant -> + boolean hasTest = gradle.startParameter.taskNames.find {it.contains("test") || it.contains("Test")} != null + if (hasTest) { + apply plugin: 'kotlin-allopen' + allOpen { + annotation("org.mozilla.fenix.test.Mockable") + } + } +} + android.applicationVariants.all { variant -> def buildType = variant.buildType.name @@ -152,10 +162,6 @@ dependencies { debugImplementation Deps.leakcanary releaseImplementation Deps.leakcanary_noop - testImplementation Deps.junit - androidTestImplementation Deps.tools_test_runner - androidTestImplementation Deps.tools_espresso_core - armImplementation Deps.geckoview_nightly_arm x86Implementation Deps.geckoview_nightly_x86 aarch64Implementation Deps.geckoview_nightly_aarch64 @@ -167,6 +173,17 @@ dependencies { implementation Deps.android_arch_navigation_ui implementation Deps.autodispose + + androidTestImplementation Deps.tools_test_runner + androidTestImplementation Deps.tools_espresso_core + + testImplementation Deps.junit_jupiter_api + testImplementation Deps.junit_jupiter_params + testImplementation Deps.junit_jupiter_engine + + testImplementation Deps.mockito_core + androidTestImplementation Deps.mockito_android + testImplementation Deps.mockk } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryComponent.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryComponent.kt index 5197e722b..192cceca3 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryComponent.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryComponent.kt @@ -4,6 +4,7 @@ package org.mozilla.fenix.library.history import android.view.ViewGroup +import org.mozilla.fenix.test.Mockable import org.mozilla.fenix.mvi.Action import org.mozilla.fenix.mvi.ActionBusFactory import org.mozilla.fenix.mvi.Change @@ -25,6 +26,7 @@ data class HistoryItem(val id: Int, val url: String) { } } +@Mockable class HistoryComponent( private val container: ViewGroup, bus: ActionBusFactory, diff --git a/app/src/test/java/org/mozilla/fenix/ExampleUnitTest.kt b/app/src/test/java/org/mozilla/fenix/ExampleUnitTest.kt deleted file mode 100644 index 8e685cac9..000000000 --- a/app/src/test/java/org/mozilla/fenix/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.mozilla.fenix - -import org.junit.Test -/* ktlint-disable no-wildcard-imports */ -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/app/src/test/java/org/mozilla/fenix/TestUtils.kt b/app/src/test/java/org/mozilla/fenix/TestUtils.kt new file mode 100644 index 000000000..11881726d --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/TestUtils.kt @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix + +import androidx.lifecycle.LifecycleOwner +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.plugins.RxJavaPlugins +import io.reactivex.schedulers.Schedulers +import org.mozilla.fenix.mvi.ActionBusFactory + +object TestUtils { + fun setRxSchedulers() { + RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() } + RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + val owner = mockk { + every { lifecycle } returns mockk() + every { lifecycle.addObserver(any()) } just Runs + } + val bus: ActionBusFactory = ActionBusFactory.get(owner) +} diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistoryComponentTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistoryComponentTest.kt new file mode 100644 index 000000000..d9ce877b4 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/history/HistoryComponentTest.kt @@ -0,0 +1,90 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.history + +import android.view.ViewGroup +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.spyk +import io.reactivex.Observer +import io.reactivex.observers.TestObserver +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mozilla.fenix.TestUtils.bus +import org.mozilla.fenix.TestUtils.owner +import org.mozilla.fenix.TestUtils.setRxSchedulers +import org.mozilla.fenix.mvi.ActionBusFactory +import org.mozilla.fenix.mvi.UIView +import org.mozilla.fenix.mvi.getManagedEmitter + +class HistoryComponentTest { + + private lateinit var historyComponent: TestHistoryComponent + private lateinit var historyObserver: TestObserver + private lateinit var emitter: Observer + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + setRxSchedulers() + + historyComponent = spyk( + TestHistoryComponent(mockk(), bus), + recordPrivateCalls = true + ) + historyObserver = historyComponent.internalRender(historyComponent.reducer).test() + emitter = owner.getManagedEmitter() + } + + @Test + fun `add and remove one history item normally`() { + val historyItem = HistoryItem(123, "http://mozilla.org") + + emitter.onNext(HistoryChange.Change(listOf(historyItem))) + emitter.onNext(HistoryChange.EnterEditMode(historyItem)) + emitter.onNext(HistoryChange.RemoveItemForRemoval(historyItem)) + emitter.onNext(HistoryChange.AddItemForRemoval(historyItem)) + emitter.onNext(HistoryChange.ExitEditMode) + + historyObserver.assertSubscribed().awaitCount(6).assertNoErrors() + .assertValues( + HistoryState(listOf(), HistoryState.Mode.Normal), + HistoryState(listOf(historyItem), HistoryState.Mode.Normal), + HistoryState(listOf(historyItem), HistoryState.Mode.Editing(listOf(historyItem))), + HistoryState(listOf(historyItem), HistoryState.Mode.Editing(listOf())), + HistoryState(listOf(historyItem), HistoryState.Mode.Editing(listOf(historyItem))), + HistoryState(listOf(historyItem), HistoryState.Mode.Normal) + ) + } + + @Test + fun `try making changes when not in edit mode`() { + val historyItems = listOf( + HistoryItem(1337, "http://reddit.com"), + HistoryItem(31337, "http://leethaxor.com") + ) + + emitter.onNext(HistoryChange.Change(historyItems)) + emitter.onNext(HistoryChange.AddItemForRemoval(historyItems[0])) + emitter.onNext(HistoryChange.EnterEditMode(historyItems[0])) + emitter.onNext(HistoryChange.ExitEditMode) + + historyObserver.assertSubscribed().awaitCount(4).assertNoErrors() + .assertValues( + HistoryState(listOf(), HistoryState.Mode.Normal), + HistoryState(historyItems, HistoryState.Mode.Normal), + HistoryState(historyItems, HistoryState.Mode.Editing(listOf(historyItems[0]))), + HistoryState(historyItems, HistoryState.Mode.Normal) + ) + } + + @Suppress("MemberVisibilityCanBePrivate") + class TestHistoryComponent(container: ViewGroup, bus: ActionBusFactory) : + HistoryComponent(container, bus) { + + override val uiView: UIView + get() = mockk(relaxed = true) + } +} \ No newline at end of file diff --git a/architecture/src/main/java/org/mozilla/fenix/mvi/UIComponent.kt b/architecture/src/main/java/org/mozilla/fenix/mvi/UIComponent.kt index 1ce08dba8..452e2cafa 100644 --- a/architecture/src/main/java/org/mozilla/fenix/mvi/UIComponent.kt +++ b/architecture/src/main/java/org/mozilla/fenix/mvi/UIComponent.kt @@ -17,19 +17,22 @@ abstract class UIComponent( abstract var initialState: S abstract val reducer: Reducer - val uiView: UIView by lazy { initView() } + + open val uiView: UIView by lazy { initView() } abstract fun initView(): UIView open fun getContainerId() = uiView.containerId - /** * Render the ViewState to the View through the Reducer */ fun render(reducer: Reducer): Disposable = + internalRender(reducer) + .subscribe(uiView.updateView()) + + fun internalRender(reducer: Reducer): Observable = changesObservable .scan(initialState, reducer) .distinctUntilChanged() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(uiView.updateView()) } diff --git a/architecture/src/main/java/org/mozilla/fenix/test/Mockable.kt b/architecture/src/main/java/org/mozilla/fenix/test/Mockable.kt new file mode 100644 index 000000000..d98f5dd0a --- /dev/null +++ b/architecture/src/main/java/org/mozilla/fenix/test/Mockable.kt @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.test + +annotation class Mockable \ No newline at end of file diff --git a/build.gradle b/build.gradle index a5d495aeb..3f5923377 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ buildscript { classpath Deps.tools_androidgradle classpath Deps.tools_kotlingradle classpath Deps.androidx_safeargs + classpath Deps.allopen // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 66f8a4e0e..2bfa156d7 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -5,6 +5,7 @@ private object Versions { const val kotlin = "1.3.11" const val android_gradle_plugin = "3.2.1" + const val geckoNightly = "67.0.20190213102848" const val rxAndroid = "2.1.0" const val rxKotlin = "2.3.0" @@ -23,13 +24,16 @@ private object Versions { const val mozilla_android_components = "0.43.0-SNAPSHOT" - const val junit = "4.12" const val test_tools = "1.0.2" const val espresso_core = "2.2.2" const val android_arch_navigation = "1.0.0-beta02" const val autodispose = "1.1.0" + + const val junit_jupiter = "5.3.2" + const val mockito = "2.23.0" + const val mockk = "1.9.kotlin12" } @Suppress("unused") @@ -38,6 +42,8 @@ object Deps { const val tools_kotlingradle = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" const val kotlin_stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.kotlin}" + const val allopen = "org.jetbrains.kotlin:kotlin-allopen:${Versions.kotlin}" + const val rxKotlin = "io.reactivex.rxjava2:rxkotlin:${Versions.rxKotlin}" const val rxAndroid = "io.reactivex.rxjava2:rxandroid:${Versions.rxAndroid}" @@ -96,7 +102,6 @@ object Deps { const val leakcanary = "com.squareup.leakcanary:leakcanary-android:${Versions.leakcanary}" const val leakcanary_noop = "com.squareup.leakcanary:leakcanary-android-no-op:${Versions.leakcanary}" - const val junit = "junit:junit:${Versions.junit}" const val tools_test_runner = "com.android.support.test:runner:${Versions.test_tools}" const val tools_espresso_core = "com.android.support.test.espresso:espresso-core:${Versions.espresso_core}" @@ -115,5 +120,13 @@ object Deps { const val autodispose_android = "com.uber.autodispose:autodispose-android:${Versions.autodispose}" const val autodispose_android_aac = "com.uber.autodispose:autodispose-android-archcomponents:${Versions.autodispose}" const val autodispose_android_aac_test = "com.uber.autodispose:autodispose-android-archcomponents-test:${Versions.autodispose}" + + const val junit_jupiter_api = "org.junit.jupiter:junit-jupiter-api:${Versions.junit_jupiter}" + const val junit_jupiter_params = "org.junit.jupiter:junit-jupiter-params:${Versions.junit_jupiter}" + const val junit_jupiter_engine = "org.junit.jupiter:junit-jupiter-engine:${Versions.junit_jupiter}" + + const val mockito_core = "org.mockito:mockito-core:${Versions.mockito}" + const val mockito_android = "org.mockito:mockito-android:${Versions.mockito}" + const val mockk = "io.mockk:mockk:${Versions.mockk}" }