Add test dispatchers

This adds a test implementation of dispatchers with an injected TestDispatcher.
The normal IO dispatcher is automatically swapped out via a TestInstallsIn in core-testing.

This update also updates DataStore to use an injected dispatcher, which allows removing the waitUntil in the navigation tests because we can eagerly read from disk with runTest.

Change-Id: I82abb8c8a57c2beed5ef37bf1e252acf379278cd
pull/2/head
Alex Vanyo 3 years ago
parent 9c83cb7412
commit 506c98cc90

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@ -84,8 +83,6 @@ class NavigationTest {
@Test @Test
fun firstScreen_isForYou() { fun firstScreen_isForYou() {
composeTestRule.apply { composeTestRule.apply {
// WAIT for initial content to be shown
waitUntil { onAllNodes(hasText("HEADLINES")).fetchSemanticsNodes().isNotEmpty() }
// VERIFY first topic is displayed // VERIFY first topic is displayed
onNodeWithText("HEADLINES").assertExists() onNodeWithText("HEADLINES").assertExists()
} }
@ -101,8 +98,6 @@ class NavigationTest {
@Test @Test
fun navigationBar_navigateToPreviouslySelectedTab_restoresContent() { fun navigationBar_navigateToPreviouslySelectedTab_restoresContent() {
composeTestRule.apply { composeTestRule.apply {
// WAIT for initial content to be shown
waitUntil { onAllNodes(hasText("HEADLINES")).fetchSemanticsNodes().isNotEmpty() }
// GIVEN the user follows a topic // GIVEN the user follows a topic
onNodeWithText("HEADLINES").performClick() onNodeWithText("HEADLINES").performClick()
// WHEN the user navigates to the Topics destination // WHEN the user navigates to the Topics destination
@ -120,8 +115,6 @@ class NavigationTest {
@Test @Test
fun navigationBar_reselectTab_keepsState() { fun navigationBar_reselectTab_keepsState() {
composeTestRule.apply { composeTestRule.apply {
// WAIT for initial content to be shown
waitUntil { onAllNodes(hasText("HEADLINES")).fetchSemanticsNodes().isNotEmpty() }
// GIVEN the user follows a topic // GIVEN the user follows a topic
onNodeWithText("HEADLINES").performClick() onNodeWithText("HEADLINES").performClick()
// WHEN the user taps the For You navigation bar item // WHEN the user taps the For You navigation bar item
@ -179,8 +172,6 @@ class NavigationTest {
@Test @Test
fun navigationBar_backFromAnyDestination_returnsToForYou() { fun navigationBar_backFromAnyDestination_returnsToForYou() {
composeTestRule.apply { composeTestRule.apply {
// WAIT for initial content to be shown
waitUntil { onAllNodes(hasText("HEADLINES")).fetchSemanticsNodes().isNotEmpty() }
// GIVEN the user navigated to the Episodes destination // GIVEN the user navigated to the Episodes destination
onNodeWithText(episodes).performClick() onNodeWithText(episodes).performClick()
// and then navigated to the Topics destination // and then navigated to the Topics destination

@ -16,6 +16,7 @@
plugins { plugins {
id 'com.android.library' id 'com.android.library'
id 'kotlin-android' id 'kotlin-android'
id 'kotlin-kapt'
} }
android { android {
@ -32,4 +33,10 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '1.8'
} }
}
dependencies {
implementation libs.kotlinx.coroutines.android
implementation libs.hilt.android
kapt libs.hilt.compiler
} }

@ -0,0 +1,28 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.network
import javax.inject.Qualifier
import kotlin.annotation.AnnotationRetention.RUNTIME
@Qualifier
@Retention(RUNTIME)
annotation class Dispatcher(val niaDispatcher: NiaDispatchers)
enum class NiaDispatchers {
IO
}

@ -14,26 +14,21 @@
* limitations under the License. * limitations under the License.
*/ */
package com.google.samples.apps.nowinandroid.core.network package com.google.samples.apps.nowinandroid.core.network.di
import javax.inject.Inject import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
interface NiaDispatchers { @Module
val IO: CoroutineDispatcher @InstallIn(SingletonComponent::class)
object DispatchersModule {
val Default: CoroutineDispatcher @Provides
@Dispatcher(IO)
val Main: MainCoroutineDispatcher fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
val Unconfined: CoroutineDispatcher
}
class DefaultNiaDispatchers @Inject constructor() : NiaDispatchers {
override val IO: CoroutineDispatcher = Dispatchers.IO
override val Default: CoroutineDispatcher = Dispatchers.Default
override val Main: MainCoroutineDispatcher = Dispatchers.Main
override val Unconfined: CoroutineDispatcher = Dispatchers.Unconfined
} }

@ -57,6 +57,8 @@ protobuf {
} }
dependencies { dependencies {
implementation project(':core-common')
testImplementation project(':core-testing') testImplementation project(':core-testing')
implementation libs.kotlinx.coroutines.android implementation libs.kotlinx.coroutines.android

@ -22,12 +22,17 @@ import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile import androidx.datastore.dataStoreFile
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -37,10 +42,12 @@ object DataStoreModule {
@Singleton @Singleton
fun providesUserPreferencesDataStore( fun providesUserPreferencesDataStore(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
userPreferencesSerializer: UserPreferencesSerializer userPreferencesSerializer: UserPreferencesSerializer
): DataStore<UserPreferences> = ): DataStore<UserPreferences> =
DataStoreFactory.create( DataStoreFactory.create(
serializer = userPreferencesSerializer serializer = userPreferencesSerializer,
scope = CoroutineScope(ioDispatcher + SupervisorJob())
) { ) {
context.dataStoreFile("user_preferences.pb") context.dataStoreFile("user_preferences.pb")
} }

@ -38,6 +38,7 @@ android {
} }
dependencies { dependencies {
implementation project(':core-common')
implementation project(':core-model') implementation project(':core-model')
implementation project(':core-database') implementation project(':core-database')
implementation project(':core-datastore') implementation project(':core-datastore')

@ -18,8 +18,10 @@ package com.google.samples.apps.nowinandroid.core.domain.repository.fake
import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -31,7 +33,7 @@ import kotlinx.serialization.json.Json
* backend. * backend.
*/ */
class FakeNewsRepository @Inject constructor( class FakeNewsRepository @Inject constructor(
private val dispatchers: NiaDispatchers, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json private val networkJson: Json
) : NewsRepository { ) : NewsRepository {

@ -19,10 +19,12 @@ package com.google.samples.apps.nowinandroid.core.domain.repository.fake
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences
import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
@ -37,7 +39,7 @@ import kotlinx.serialization.json.Json
* backend. * backend.
*/ */
class FakeTopicsRepository @Inject constructor( class FakeTopicsRepository @Inject constructor(
private val dispatchers: NiaDispatchers, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json, private val networkJson: Json,
private val niaPreferences: NiaPreferences private val niaPreferences: NiaPreferences
) : TopicsRepository { ) : TopicsRepository {
@ -52,7 +54,7 @@ class FakeTopicsRepository @Inject constructor(
} }
) )
} }
.flowOn(dispatchers.IO) .flowOn(ioDispatcher)
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) = override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) =
niaPreferences.setFollowedTopicIds(followedTopicIds) niaPreferences.setFollowedTopicIds(followedTopicIds)

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.core.domain.repository package com.google.samples.apps.nowinandroid.core.domain.repository
import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeNewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.network.DefaultNiaDispatchers import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Before import org.junit.Before
@ -25,11 +25,12 @@ class FakeNewsRepositoryTest {
private lateinit var subject: FakeNewsRepository private lateinit var subject: FakeNewsRepository
private val testDispatcher = StandardTestDispatcher()
@Before @Before
fun setup() { fun setup() {
subject = FakeNewsRepository( subject = FakeNewsRepository(
// TODO: Create test-specific NiaDispatchers ioDispatcher = testDispatcher,
dispatchers = DefaultNiaDispatchers(),
networkJson = Json { ignoreUnknownKeys = true } networkJson = Json { ignoreUnknownKeys = true }
) )
} }

@ -38,6 +38,7 @@ android {
} }
dependencies { dependencies {
implementation project(':core-common')
implementation project(':core-model') implementation project(':core-model')
testImplementation project(':core-testing') testImplementation project(':core-testing')

@ -16,9 +16,7 @@
package com.google.samples.apps.nowinandroid.core.network.di package com.google.samples.apps.nowinandroid.core.network.di
import com.google.samples.apps.nowinandroid.core.network.DefaultNiaDispatchers
import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.NiANetwork
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiANetwork import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiANetwork
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@ -37,9 +35,6 @@ interface NetworkModule {
fakeNiANetwork: FakeNiANetwork fakeNiANetwork: FakeNiANetwork
): NiANetwork ): NiANetwork
@Binds
fun bindsNiaDispatchers(defaultNiaDispatchers: DefaultNiaDispatchers): NiaDispatchers
companion object { companion object {
@Provides @Provides
@Singleton @Singleton

@ -16,11 +16,13 @@
package com.google.samples.apps.nowinandroid.core.network.fake package com.google.samples.apps.nowinandroid.core.network.fake
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.NiANetwork
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@ -30,16 +32,16 @@ import kotlinx.serialization.json.Json
* [NiANetwork] implementation that provides static news resources to aid development * [NiANetwork] implementation that provides static news resources to aid development
*/ */
class FakeNiANetwork @Inject constructor( class FakeNiANetwork @Inject constructor(
private val dispatchers: NiaDispatchers, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json private val networkJson: Json
) : NiANetwork { ) : NiANetwork {
override suspend fun getTopics(): List<NetworkTopic> = override suspend fun getTopics(): List<NetworkTopic> =
withContext(dispatchers.IO) { withContext(ioDispatcher) {
networkJson.decodeFromString(FakeDataSource.topicsData) networkJson.decodeFromString(FakeDataSource.topicsData)
} }
override suspend fun getNewsResources(): List<NetworkNewsResource> = override suspend fun getNewsResources(): List<NetworkNewsResource> =
withContext(dispatchers.IO) { withContext(ioDispatcher) {
networkJson.decodeFromString<ResourceData>(FakeDataSource.data).resources networkJson.decodeFromString<ResourceData>(FakeDataSource.data).resources
} }
} }

@ -16,7 +16,7 @@
package com.google.samples.apps.nowinandroid.core.network.fake package com.google.samples.apps.nowinandroid.core.network.fake
import com.google.samples.apps.nowinandroid.core.network.DefaultNiaDispatchers import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -27,17 +27,18 @@ class FakeNiANetworkTest {
private lateinit var subject: FakeNiANetwork private lateinit var subject: FakeNiANetwork
private val testDispatcher = StandardTestDispatcher()
@Before @Before
fun setUp() { fun setUp() {
subject = com.google.samples.apps.nowinandroid.core.network.fake.FakeNiANetwork( subject = com.google.samples.apps.nowinandroid.core.network.fake.FakeNiANetwork(
// TODO: Create test-specific NiaDispatchers ioDispatcher = testDispatcher,
dispatchers = DefaultNiaDispatchers(),
networkJson = Json { ignoreUnknownKeys = true } networkJson = Json { ignoreUnknownKeys = true }
) )
} }
@Test @Test
fun testDeserializationOfTopics() = runTest { fun testDeserializationOfTopics() = runTest(testDispatcher) {
assertEquals( assertEquals(
FakeDataSource.sampleTopic, FakeDataSource.sampleTopic,
subject.getTopics().first() subject.getTopics().first()
@ -45,7 +46,7 @@ class FakeNiANetworkTest {
} }
@Test @Test
fun testDeserializationOfNewsResources() = runTest { fun testDeserializationOfNewsResources() = runTest(testDispatcher) {
assertEquals( assertEquals(
FakeDataSource.sampleResource, FakeDataSource.sampleResource,
subject.getNewsResources().first() subject.getNewsResources().first()

@ -16,6 +16,7 @@
plugins { plugins {
id 'com.android.library' id 'com.android.library'
id 'kotlin-android' id 'kotlin-android'
id 'kotlin-kapt'
} }
android { android {
@ -35,9 +36,13 @@ android {
} }
dependencies { dependencies {
implementation project(':core-common')
implementation project(':core-domain') implementation project(':core-domain')
implementation project(':core-model') implementation project(':core-model')
implementation libs.hilt.android
kapt libs.hilt.compiler
api libs.junit4 api libs.junit4
api libs.mockk api libs.mockk
api libs.androidx.test.core api libs.androidx.test.core

@ -0,0 +1,33 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.testing.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@Module
@InstallIn(SingletonComponent::class)
object TestDispatcherModule {
@Provides
@Singleton
fun providesTestDispatcher(): TestDispatcher = UnconfinedTestDispatcher()
}

@ -0,0 +1,38 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.testing.di
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.di.DispatchersModule
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.TestDispatcher
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DispatchersModule::class],
)
object TestDispatchersModule {
@Provides
@Dispatcher(IO)
fun providesIODispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher
}
Loading…
Cancel
Save