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

@ -16,6 +16,7 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
}
android {
@ -32,4 +33,10 @@ android {
kotlinOptions {
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.
*/
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.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
interface NiaDispatchers {
val IO: CoroutineDispatcher
val Default: CoroutineDispatcher
val Main: MainCoroutineDispatcher
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
@Module
@InstallIn(SingletonComponent::class)
object DispatchersModule {
@Provides
@Dispatcher(IO)
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO
}

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

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

@ -38,6 +38,7 @@ android {
}
dependencies {
implementation project(':core-common')
implementation project(':core-model')
implementation project(':core-database')
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.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 kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.serialization.json.Json
@ -31,7 +33,7 @@ import kotlinx.serialization.json.Json
* backend.
*/
class FakeNewsRepository @Inject constructor(
private val dispatchers: NiaDispatchers,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json
) : 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.domain.repository.TopicsRepository
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.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
@ -37,7 +39,7 @@ import kotlinx.serialization.json.Json
* backend.
*/
class FakeTopicsRepository @Inject constructor(
private val dispatchers: NiaDispatchers,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json,
private val niaPreferences: NiaPreferences
) : TopicsRepository {
@ -52,7 +54,7 @@ class FakeTopicsRepository @Inject constructor(
}
)
}
.flowOn(dispatchers.IO)
.flowOn(ioDispatcher)
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) =
niaPreferences.setFollowedTopicIds(followedTopicIds)

@ -17,7 +17,7 @@
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.network.DefaultNiaDispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.serialization.json.Json
import org.junit.Before
@ -25,11 +25,12 @@ class FakeNewsRepositoryTest {
private lateinit var subject: FakeNewsRepository
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
subject = FakeNewsRepository(
// TODO: Create test-specific NiaDispatchers
dispatchers = DefaultNiaDispatchers(),
ioDispatcher = testDispatcher,
networkJson = Json { ignoreUnknownKeys = true }
)
}

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

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

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

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

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