Browse Source

Merge pull request #2933 from element-hq/feature/bma/testAnalytics

Add unit test on DefaultAnalyticsService
pull/2946/head
Benoit Marty 4 months ago committed by GitHub
parent
commit
57b4418b10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt
  2. 6
      services/analytics/impl/build.gradle.kts
  3. 5
      services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt
  4. 31
      services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/store/AnalyticsStore.kt
  5. 286
      services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt
  6. 47
      services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/store/FakeAnalyticsStore.kt
  7. 1
      services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt
  8. 3
      services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt
  9. 27
      services/analyticsproviders/test/build.gradle.kts
  10. 40
      services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt

5
services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt

@ -57,11 +57,6 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker {
*/ */
suspend fun setAnalyticsId(analyticsId: String) suspend fun setAnalyticsId(analyticsId: String)
/**
* To be called when a session is destroyed.
*/
suspend fun onSignOut()
/** /**
* Reset the analytics service (will ask for user consent again). * Reset the analytics service (will ask for user consent again).
*/ */

6
services/analytics/impl/build.gradle.kts

@ -43,5 +43,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.test.mockk) testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.analyticsproviders.test)
testImplementation(projects.tests.testutils)
} }

5
services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt

@ -91,11 +91,6 @@ class DefaultAnalyticsService @Inject constructor(
analyticsStore.setAnalyticsId(analyticsId) analyticsStore.setAnalyticsId(analyticsId)
} }
override suspend fun onSignOut() {
// stop all providers
analyticsProviders.onEach { it.stop() }
}
override suspend fun onSessionCreated(userId: String) { override suspend fun onSessionCreated(userId: String) {
// Nothing to do // Nothing to do
} }

31
services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/store/AnalyticsStore.kt

@ -23,7 +23,9 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@ -41,44 +43,55 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
* - did ask user consent (Boolean); * - did ask user consent (Boolean);
* - analytics Id (String). * - analytics Id (String).
*/ */
class AnalyticsStore @Inject constructor( interface AnalyticsStore {
val userConsentFlow: Flow<Boolean>
val didAskUserConsentFlow: Flow<Boolean>
val analyticsIdFlow: Flow<String>
suspend fun setUserConsent(newUserConsent: Boolean)
suspend fun setDidAskUserConsent(newValue: Boolean = true)
suspend fun setAnalyticsId(newAnalyticsId: String)
suspend fun reset()
}
@ContributesBinding(AppScope::class)
class DefaultAnalyticsStore @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) { ) : AnalyticsStore {
private val userConsent = booleanPreferencesKey("user_consent") private val userConsent = booleanPreferencesKey("user_consent")
private val didAskUserConsent = booleanPreferencesKey("did_ask_user_consent") private val didAskUserConsent = booleanPreferencesKey("did_ask_user_consent")
private val analyticsId = stringPreferencesKey("analytics_id") private val analyticsId = stringPreferencesKey("analytics_id")
val userConsentFlow: Flow<Boolean> = context.dataStore.data override val userConsentFlow: Flow<Boolean> = context.dataStore.data
.map { preferences -> preferences[userConsent].orFalse() } .map { preferences -> preferences[userConsent].orFalse() }
.distinctUntilChanged() .distinctUntilChanged()
val didAskUserConsentFlow: Flow<Boolean> = context.dataStore.data override val didAskUserConsentFlow: Flow<Boolean> = context.dataStore.data
.map { preferences -> preferences[didAskUserConsent].orFalse() } .map { preferences -> preferences[didAskUserConsent].orFalse() }
.distinctUntilChanged() .distinctUntilChanged()
val analyticsIdFlow: Flow<String> = context.dataStore.data override val analyticsIdFlow: Flow<String> = context.dataStore.data
.map { preferences -> preferences[analyticsId].orEmpty() } .map { preferences -> preferences[analyticsId].orEmpty() }
.distinctUntilChanged() .distinctUntilChanged()
suspend fun setUserConsent(newUserConsent: Boolean) { override suspend fun setUserConsent(newUserConsent: Boolean) {
context.dataStore.edit { settings -> context.dataStore.edit { settings ->
settings[userConsent] = newUserConsent settings[userConsent] = newUserConsent
} }
} }
suspend fun setDidAskUserConsent(newValue: Boolean = true) { override suspend fun setDidAskUserConsent(newValue: Boolean) {
context.dataStore.edit { settings -> context.dataStore.edit { settings ->
settings[didAskUserConsent] = newValue settings[didAskUserConsent] = newValue
} }
} }
suspend fun setAnalyticsId(newAnalyticsId: String) { override suspend fun setAnalyticsId(newAnalyticsId: String) {
context.dataStore.edit { settings -> context.dataStore.edit { settings ->
settings[analyticsId] = newAnalyticsId settings[analyticsId] = newAnalyticsId
} }
} }
suspend fun reset() { override suspend fun reset() {
context.dataStore.edit { context.dataStore.edit {
it.clear() it.clear()
} }

286
services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt

@ -0,0 +1,286 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* 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
*
* http://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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.services.analytics.impl
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.analytics.plan.PollEnd
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import io.element.android.services.analytics.impl.store.AnalyticsStore
import io.element.android.services.analytics.impl.store.FakeAnalyticsStore
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.services.analyticsproviders.test.FakeAnalyticsProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.runCancellableScopeTest
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultAnalyticsServiceTest {
@Test
fun `getAvailableAnalyticsProviders return the set of provider`() = runCancellableScopeTest {
val providers = setOf(
FakeAnalyticsProvider(name = "provider1", stopLambda = { }),
FakeAnalyticsProvider(name = "provider2", stopLambda = { }),
)
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsProviders = providers
)
val result = sut.getAvailableAnalyticsProviders()
assertThat(result).isEqualTo(providers)
}
@Test
fun `when consent is not provided, capture is no op`() = runCancellableScopeTest {
val sut = createDefaultAnalyticsService(it)
sut.capture(anEvent)
}
@Test
fun `when consent is provided, capture is sent to the AnalyticsProvider`() = runCancellableScopeTest {
val initLambda = lambdaRecorder<Unit> { }
val captureLambda = lambdaRecorder<VectorAnalyticsEvent, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = initLambda,
captureLambda = captureLambda,
)
)
)
initLambda.assertions().isCalledOnce()
sut.capture(anEvent)
captureLambda.assertions().isCalledOnce().with(value(anEvent))
}
@Test
fun `when consent is not provided, screen is no op`() = runCancellableScopeTest {
val sut = createDefaultAnalyticsService(it)
sut.screen(aScreen)
}
@Test
fun `when consent is provided, screen is sent to the AnalyticsProvider`() = runCancellableScopeTest {
val initLambda = lambdaRecorder<Unit> { }
val screenLambda = lambdaRecorder<VectorAnalyticsScreen, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = initLambda,
screenLambda = screenLambda,
)
)
)
initLambda.assertions().isCalledOnce()
sut.screen(aScreen)
screenLambda.assertions().isCalledOnce().with(value(aScreen))
}
@Test
fun `when consent is not provided, trackError is no op`() = runCancellableScopeTest {
val sut = createDefaultAnalyticsService(it)
sut.trackError(anError)
}
@Test
fun `when consent is provided, trackError is sent to the AnalyticsProvider`() = runCancellableScopeTest {
val initLambda = lambdaRecorder<Unit> { }
val trackErrorLambda = lambdaRecorder<Throwable, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = initLambda,
trackErrorLambda = trackErrorLambda,
)
)
)
initLambda.assertions().isCalledOnce()
sut.trackError(anError)
trackErrorLambda.assertions().isCalledOnce().with(value(anError))
}
@Test
fun `setUserConsent is sent to the store`() = runCancellableScopeTest {
val store = FakeAnalyticsStore()
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsStore = store,
)
assertThat(store.userConsentFlow.first()).isFalse()
assertThat(sut.getUserConsent().first()).isFalse()
sut.setUserConsent(true)
assertThat(store.userConsentFlow.first()).isTrue()
assertThat(sut.getUserConsent().first()).isTrue()
}
@Test
fun `setAnalyticsId is sent to the store`() = runCancellableScopeTest {
val store = FakeAnalyticsStore()
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsStore = store,
)
assertThat(store.analyticsIdFlow.first()).isEqualTo("")
assertThat(sut.getAnalyticsId().first()).isEqualTo("")
sut.setAnalyticsId(AN_ID)
assertThat(store.analyticsIdFlow.first()).isEqualTo(AN_ID)
assertThat(sut.getAnalyticsId().first()).isEqualTo(AN_ID)
}
@Test
fun `setDidAskUserConsent is sent to the store`() = runCancellableScopeTest {
val store = FakeAnalyticsStore()
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsStore = store,
)
assertThat(store.didAskUserConsentFlow.first()).isFalse()
assertThat(sut.didAskUserConsent().first()).isFalse()
sut.setDidAskUserConsent()
assertThat(store.didAskUserConsentFlow.first()).isTrue()
assertThat(sut.didAskUserConsent().first()).isTrue()
}
@Test
fun `when a session is deleted, the store is reset`() = runCancellableScopeTest {
val resetLambda = lambdaRecorder<Unit> { }
val store = FakeAnalyticsStore(
resetLambda = resetLambda,
)
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsStore = store,
)
sut.onSessionDeleted("userId")
resetLambda.assertions().isCalledOnce()
}
@Test
fun `when reset is invoked, the user consent is reset`() = runCancellableScopeTest {
val store = FakeAnalyticsStore(
defaultDidAskUserConsent = true,
)
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsStore = store,
)
assertThat(store.didAskUserConsentFlow.first()).isTrue()
sut.reset()
assertThat(store.didAskUserConsentFlow.first()).isFalse()
}
@Test
fun `when a session is added, nothing happen`() = runCancellableScopeTest {
val sut = createDefaultAnalyticsService(
coroutineScope = it,
)
sut.onSessionCreated("userId")
}
@Test
fun `when consent is not provided, updateUserProperties is stored for future use`() = runTest {
val completable = CompletableDeferred<Unit>()
val updateUserPropertiesLambda = lambdaRecorder<UserProperties, Unit> { _ ->
completable.complete(Unit)
}
launch {
val sut = createDefaultAnalyticsService(
coroutineScope = this,
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = { },
stopLambda = { },
updateUserPropertiesLambda = updateUserPropertiesLambda,
)
)
)
sut.updateUserProperties(aUserProperty)
updateUserPropertiesLambda.assertions().isNeverCalled()
// Give user consent
sut.setUserConsent(true)
completable.await()
updateUserPropertiesLambda.assertions().isCalledOnce().with(value(aUserProperty))
cancel()
}
}
@Test
fun `when consent is provided, updateUserProperties is sent to the provider`() = runCancellableScopeTest {
val updateUserPropertiesLambda = lambdaRecorder<UserProperties, Unit> { _ -> }
val sut = createDefaultAnalyticsService(
coroutineScope = it,
analyticsProviders = setOf(
FakeAnalyticsProvider(
initLambda = { },
updateUserPropertiesLambda = updateUserPropertiesLambda,
)
),
analyticsStore = FakeAnalyticsStore(defaultUserConsent = true),
)
sut.updateUserProperties(aUserProperty)
updateUserPropertiesLambda.assertions().isCalledOnce().with(value(aUserProperty))
}
private suspend fun createDefaultAnalyticsService(
coroutineScope: CoroutineScope,
analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider> = setOf(
FakeAnalyticsProvider(
stopLambda = { },
)
),
analyticsStore: AnalyticsStore = FakeAnalyticsStore(),
sessionObserver: SessionObserver = NoOpSessionObserver(),
) = DefaultAnalyticsService(
analyticsProviders = analyticsProviders,
analyticsStore = analyticsStore,
coroutineScope = coroutineScope,
sessionObserver = sessionObserver,
).also {
// Wait for the service to be ready
delay(1)
}
private companion object {
private val anEvent = PollEnd()
private val aScreen = MobileScreen(screenName = MobileScreen.ScreenName.User)
private val aUserProperty = UserProperties(
ftueUseCaseSelection = UserProperties.FtueUseCaseSelection.WorkMessaging,
)
private val anError = Exception("a reason")
private const val AN_ID = "anId"
}
}

47
services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/store/FakeAnalyticsStore.kt

@ -0,0 +1,47 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* 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
*
* http://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 io.element.android.services.analytics.impl.store
import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow
class FakeAnalyticsStore(
defaultUserConsent: Boolean = false,
defaultDidAskUserConsent: Boolean = false,
defaultAnalyticsId: String = "",
private val resetLambda: () -> Unit = { lambdaError() },
) : AnalyticsStore {
override val userConsentFlow = MutableStateFlow(defaultUserConsent)
override val didAskUserConsentFlow = MutableStateFlow(defaultDidAskUserConsent)
override val analyticsIdFlow = MutableStateFlow(defaultAnalyticsId)
override suspend fun setUserConsent(newUserConsent: Boolean) {
userConsentFlow.emit(newUserConsent)
}
override suspend fun setDidAskUserConsent(newValue: Boolean) {
didAskUserConsentFlow.emit(newValue)
}
override suspend fun setAnalyticsId(newAnalyticsId: String) {
analyticsIdFlow.emit(newAnalyticsId)
}
override suspend fun reset() {
resetLambda()
}
}

1
services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt

@ -38,7 +38,6 @@ class NoopAnalyticsService @Inject constructor() : AnalyticsService {
override suspend fun setDidAskUserConsent() = Unit override suspend fun setDidAskUserConsent() = Unit
override fun getAnalyticsId(): Flow<String> = flowOf("") override fun getAnalyticsId(): Flow<String> = flowOf("")
override suspend fun setAnalyticsId(analyticsId: String) = Unit override suspend fun setAnalyticsId(analyticsId: String) = Unit
override suspend fun onSignOut() = Unit
override suspend fun reset() = Unit override suspend fun reset() = Unit
override fun capture(event: VectorAnalyticsEvent) = Unit override fun capture(event: VectorAnalyticsEvent) = Unit
override fun screen(screen: VectorAnalyticsScreen) = Unit override fun screen(screen: VectorAnalyticsScreen) = Unit

3
services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt

@ -54,9 +54,6 @@ class FakeAnalyticsService(
override suspend fun setAnalyticsId(analyticsId: String) { override suspend fun setAnalyticsId(analyticsId: String) {
} }
override suspend fun onSignOut() {
}
override fun capture(event: VectorAnalyticsEvent) { override fun capture(event: VectorAnalyticsEvent) {
capturedEvents += event capturedEvents += event
} }

27
services/analyticsproviders/test/build.gradle.kts

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* 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
*
* http://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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.services.analyticsproviders.test"
}
dependencies {
implementation(projects.services.analyticsproviders.api)
implementation(projects.tests.testutils)
}

40
services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt

@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* 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
*
* http://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 io.element.android.services.analyticsproviders.test
import im.vector.app.features.analytics.itf.VectorAnalyticsEvent
import im.vector.app.features.analytics.itf.VectorAnalyticsScreen
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.services.analyticsproviders.api.AnalyticsProvider
import io.element.android.tests.testutils.lambda.lambdaError
class FakeAnalyticsProvider(
override val name: String = "FakeAnalyticsProvider",
private val initLambda: () -> Unit = { lambdaError() },
private val stopLambda: () -> Unit = { lambdaError() },
private val captureLambda: (VectorAnalyticsEvent) -> Unit = { lambdaError() },
private val screenLambda: (VectorAnalyticsScreen) -> Unit = { lambdaError() },
private val updateUserPropertiesLambda: (UserProperties) -> Unit = { lambdaError() },
private val trackErrorLambda: (Throwable) -> Unit = { lambdaError() }
) : AnalyticsProvider {
override fun init() = initLambda()
override fun stop() = stopLambda()
override fun capture(event: VectorAnalyticsEvent) = captureLambda(event)
override fun screen(screen: VectorAnalyticsScreen) = screenLambda(screen)
override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties)
override fun trackError(throwable: Throwable) = trackErrorLambda(throwable)
}
Loading…
Cancel
Save