Skip to content

Commit d75d3b9

Browse files
authored
Add firebaseExecutors extension methods for Tests (#4951)
* Migrate to KTS + Upgrade deps * Add extension methods * Add UI support + documentation * Update Executors documentation * Update executors doc with proper kotlin usage * Bump test runner to avoid pom dependency issues * Migrate remaining deps to version catalog * Remove jvmTarget * Fix formatting * Disable RestrictedAPI alerts that were causing crashes * Add missing suppress
1 parent c83d5e5 commit d75d3b9

File tree

6 files changed

+202
-43
lines changed

6 files changed

+202
-43
lines changed

contributor-docs/components/executors.md

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,20 +177,44 @@ Qualified<Executor> bgExecutor = qualified(Background.class, Executor.class);
177177
Executor sequentialExecutor = FirebaseExecutors.newSequentialExecutor(c.get(bgExecutor));
178178
```
179179

180+
#### Proper Kotlin usage
181+
182+
A `CoroutineContext` should be preferred when possible over an explicit `Executor`
183+
or `CoroutineDispatcher`. You should only use your `Executor` at the highest
184+
(or inversely the lowest) level of your implementations. Most classes should not
185+
be concerned with the existence of an `Executor`.
186+
187+
Keep in mind that you can combine `CoroutineContext` with other `CoroutineScope`
188+
or `CoroutineContext`. And that all `suspend` functions inherent their `coroutineContext`:
189+
190+
```kotlin
191+
suspend fun createSession(): Session {
192+
val context = backgroundDispatcher.coroutineContext + coroutineContext
193+
return Session(context)
194+
}
195+
```
196+
197+
To learn more, you should give the following Kotlin wiki page a read:
198+
199+
[Coroutine context and dispatchers](https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html#dispatchers-and-threads)
200+
180201
## Testing
181202

203+
### Using Executors in tests
204+
182205
`@Lightweight` and `@Background` executors have StrictMode enabled and throw exceptions on violations.
183206
For example trying to do Network IO on either of them will throw.
184207
With that in mind, when it comes to writing tests, prefer to use the common executors as opposed to creating
185208
your own thread pools. This will ensure that your code uses the appropriate executor and does not slow down
186209
all of Firebase by using the wrong one.
187210

188-
To do that, you should prefer relying on Components to inject the right executor even in tests. This will ensure
189-
your tests are always using the executor that is actually used in your SDK build.
190-
If your SDK uses Dagger, see [Dependency Injection]({{ site.baseurl }}{% link best_practices/dependency_injection.md %})
211+
To do that, you should prefer relying on Components to inject the right executor even in tests.
212+
This will ensure your tests are always using the executor that is actually used in your SDK build.
213+
If your SDK uses Dagger, see [Dependency Injection]({{ site.baseurl }}{% link
214+
best_practices/dependency_injection.md %})
191215
and [Dagger's testing guide](https://dagger.dev/dev-guide/testing).
192216

193-
When the above is not an option, you can use `TestOnlyExecutors`, but make sure you're testing your code with
217+
When the above is not an option, you can use `TestOnlyExecutors`, but make sure you're testing your code with
194218
the same executor that is used in production code:
195219

196220
```kotlin
@@ -200,7 +224,6 @@ dependencies {
200224
// or
201225
androidTestImplementation(project(":integ-testing"))
202226
}
203-
204227
```
205228

206229
This gives access to
@@ -211,3 +234,53 @@ TestOnlyExecutors.background();
211234
TestOnlyExecutors.blocking();
212235
TestOnlyExecutors.lite();
213236
```
237+
238+
### Policy violations in tests
239+
240+
Unit tests require [Robolectric](https://github.com/robolectric/robolectric) to
241+
function correctly, and this comes with a major drawback; no policy validation.
242+
243+
Robolectric supports `StrictMode`- but does not provided the backing for its
244+
policy mechanisms to fire on violations. As such, you'll be able to do things
245+
like using `TestOnlyExecutors.background()` to execute blocking actions; usage
246+
that would've otherwise crashed in a real application.
247+
248+
Unfortunately, there is no easy way to fix this for unit tests. You can get
249+
around the issue by moving the tests to an emulator (integration tests)- but
250+
those can be more expensive than your standard unit tests, so you may want to
251+
take that into consideration when planning your testing strategy.
252+
253+
### StandardTestDispatcher support
254+
255+
The [kotlin.coroutines.test](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/)
256+
library provides support for a number of different mechanisms in tests. Some of the more
257+
famous features include:
258+
259+
- [advanceUntilIdle](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-scheduler/advance-until-idle.html)
260+
- [advanceTimeBy](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-scheduler/advance-time-by.html)
261+
- [runCurrent](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-coroutine-scheduler/run-current.html)
262+
263+
These features are all backed by `StandardTestDispatcher`, or more appropriately,
264+
the `TestScope` provided in a `runTest` block.
265+
266+
Unfortunately, `TestOnlyExecutors` does not natively bind with `TestScope`.
267+
Meaning, should you use `TestOnlyExecutors` in your tests- you won't be able to utilize
268+
the features provided by `TestScope`.
269+
270+
To help fix this, we provide an extension method on `TestScope` called
271+
`firebaseLibrary`. It facilitates the binding of `TestOnlyExecutors` with the
272+
current `TestScope`.
273+
274+
For example, here's how you could use this extension method in a test:
275+
276+
```kotlin
277+
@Test
278+
fun doesStuff() = runTest {
279+
val scope = CoroutineScope(firebaseExecutors.background)
280+
scope.launch {
281+
// ... does stuff
282+
}
283+
284+
runCurrent()
285+
}
286+
```

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ playservices-tasks = { module = "com.google.android.gms:play-services-tasks", ve
4545
androidx-test-core = { module = "androidx.test:core", version = "1.5.0" }
4646
androidx-test-junit = { module = "androidx.test.ext:junit", version = "1.1.4" }
4747
androidx-test-rules = { module = "androidx.test:rules", version = "1.5.0" }
48-
androidx-test-runner = { module = "androidx.test:runner", version = "1.5.1" }
48+
androidx-test-runner = { module = "androidx.test:runner", version = "1.5.2" }
4949
junit = { module = "junit:junit", version = "4.13.2" }
5050
kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
5151
mockito-core = { module = "org.mockito:mockito-core", version = "2.28.2" }

integ-testing/integ-testing.gradle

Lines changed: 0 additions & 37 deletions
This file was deleted.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
plugins {
16+
id("com.android.library")
17+
kotlin("android")
18+
}
19+
20+
android {
21+
val targetSdkVersion: Int by rootProject
22+
val minSdkVersion: Int by rootProject
23+
24+
compileSdk = targetSdkVersion
25+
26+
defaultConfig {
27+
minSdk = minSdkVersion
28+
targetSdk = targetSdkVersion
29+
}
30+
31+
compileOptions {
32+
sourceCompatibility = JavaVersion.VERSION_1_8
33+
targetCompatibility = JavaVersion.VERSION_1_8
34+
}
35+
}
36+
37+
dependencies {
38+
implementation("com.google.firebase:firebase-common:20.3.2")
39+
implementation("com.google.firebase:firebase-components:17.1.0")
40+
41+
implementation(libs.junit)
42+
implementation(libs.androidx.test.runner)
43+
implementation(libs.kotlin.coroutines.test)
44+
}

integ-testing/src/main/java/com/google/firebase/testing/integ/StrictModeRule.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
package com.google.firebase.testing.integ;
1616

17+
import android.annotation.SuppressLint;
1718
import android.os.Build;
1819
import android.os.StrictMode;
1920
import android.os.StrictMode.ThreadPolicy;
@@ -64,6 +65,7 @@ public class StrictModeRule implements TestRule {
6465
private static final Executor penaltyListenerExecutor = Runnable::run;
6566

6667
/** Runs {@code runnable} on Main thread. */
68+
@SuppressLint("RestrictedApi")
6769
public <E extends Throwable> void runOnMainThread(MaybeThrowingRunnable<E> runnable) throws E {
6870
try {
6971
new UiThreadStatement(
@@ -83,6 +85,7 @@ public void evaluate() throws E {
8385
}
8486

8587
/** Runs {@code callable} on Main thread and returns it result. */
88+
@SuppressLint("RestrictedApi")
8689
public <T, E extends Throwable> T runOnMainThread(MaybeThrowingCallable<T, E> callable) throws E {
8790
try {
8891
AtomicReference<T> result = new AtomicReference<>();
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2023 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@file:OptIn(ExperimentalCoroutinesApi::class)
18+
19+
package com.google.firebase.testing.integ
20+
21+
import androidx.annotation.RestrictTo
22+
import com.google.firebase.concurrent.TestOnlyExecutors
23+
import kotlin.coroutines.CoroutineContext
24+
import kotlinx.coroutines.ExperimentalCoroutinesApi
25+
import kotlinx.coroutines.asCoroutineDispatcher
26+
import kotlinx.coroutines.test.TestScope
27+
28+
/**
29+
* Container for type-safe access to the [CoroutineContext] of [TestOnlyExecutors].
30+
*
31+
* @property ui the instance provided by [TestOnlyExecutors.ui]
32+
* @property blocking the instance provided by [TestOnlyExecutors.blocking]
33+
* @property background the instance provided by [TestOnlyExecutors.background]
34+
* @property lite the instance provided by [TestOnlyExecutors.lite]
35+
* @see TestScope.firebaseExecutors
36+
*/
37+
data class FirebaseTestExecutorsContainer(
38+
val ui: CoroutineContext,
39+
val blocking: CoroutineContext,
40+
val background: CoroutineContext,
41+
val lite: CoroutineContext
42+
)
43+
44+
/**
45+
* Provides a [CoroutineContext] of a given [TestOnlyExecutors] merged with this [TestScope].
46+
*
47+
* Your standard [TestOnlyExecutors] does not support special mechanisms that other [TestScope] may
48+
* provide (such as fast forwarding). To fix this, you must wrap the [CoroutineContext] of a given
49+
* [TestOnlyExecutors] with the inherited one in a [TestScope]. This property facilitates that
50+
* automatically.
51+
*
52+
* Now, you can utilize [TestOnlyExecutors] AND special methods provided to [TestScope].
53+
*
54+
* Example usage:
55+
* ```
56+
* @Test
57+
* fun doesStuff() = runTest {
58+
* val scope = CoroutineScope(firebaseExecutors.background)
59+
* scope.launch {
60+
* // ... does stuff
61+
* }
62+
*
63+
* runCurrent()
64+
* }
65+
* ```
66+
*/
67+
@get:RestrictTo(RestrictTo.Scope.TESTS)
68+
@get:Suppress("RestrictedApi")
69+
val TestScope.firebaseExecutors: FirebaseTestExecutorsContainer
70+
get() =
71+
FirebaseTestExecutorsContainer(
72+
TestOnlyExecutors.ui().asCoroutineDispatcher() + coroutineContext,
73+
TestOnlyExecutors.blocking().asCoroutineDispatcher() + coroutineContext,
74+
TestOnlyExecutors.background().asCoroutineDispatcher() + coroutineContext,
75+
TestOnlyExecutors.lite().asCoroutineDispatcher() + coroutineContext
76+
)

0 commit comments

Comments
 (0)