Improving Robolectric's Looper simulation
TL;DR: We'd love your feedback on improvements we've made to make Robolectric's Looper
behavior more realistic.
Try it out today by annotating your tests with @LooperMode(PAUSED)
and let us know
your experience!
Background
Unlike on a real device, Robolectric shares a single thread for both UI operations and Test code.
By default, Robolectric will execute tasks posted to Looper
s synchronously inline.
This causes Robolectric to execute tasks earlier than they would be on a real device.
While in many cases this has no observable effect, it can lead to bugs that are hard to track down.
Consider the code below. When run on the UI thread on a device, the assertion would pass.
List<String> events = new ArrayList<>();
events.add("before");
new Handler(Looper.getMainLooper()).post(() -> events.add("after"));
events.add("between");
assertThat(events).containsExactly("before", "between", "after").inOrder();
Robolectric’s default behavior is to process posted code synchronously and immediately, so the
assertion fails with [before, after, between]
, which is clearly incorrect.
Robolectric’s current implementation is notoriously prone to deadlocks, infinite loops and other
race conditions. Robolectric will duplicate each task posted to a Looper
into a separate list
stored in Scheduler
. The Looper
’s set of tasks and the Scheduler
s can get out of
sync, causing hard-to-diagnose errors.
Solution
We’ve re-written Robolectric’s threading model and Looper
simulation in a way that we hope will
address the deficiencies of the current behavior. It’s available to try out now by applying a
@LooperMode(PAUSED)
annotation to your test classes or methods. Some of the highlights of the
PAUSED
mode vs. the existing LEGACY
mode include:
- Tasks posted to the main
Looper
are not automatically executed inline. Similar to the legacyPAUSED
IdleState
, tasks posted to the mainLooper
must be explicitly executed viaShadowLooper
APIs. However, we’ve made a couple additional improvements inPAUSED
mode that make this easier: - Robolectric will warn users if a test fails with unexecuted tasks in the main
Looper
queue. This is a hint that the unexecuted behavior is important for the test case. - AndroidX Test APIs, like
ActivityScenario
,FragmentScenario
and Espresso will automatically idle the mainLooper
. - Tasks posted to background
Looper
s are executed in real threads. This will hopefully eliminate the need for hacks when trying to test code that asserts that it is running in a background thread.
Using PAUSED
LooperMode
To switch to PAUSED
:
-
Use Robolectric 4.3. In Gradle, add:
-
Apply the
@LooperMode(PAUSED)
annotation to your test package/class/method. - Convert any background
Scheduler
calls for controllingLooper
s toshadowOf(looper)
. - Recommended, but not mandatory: Convert any foreground
Scheduler
calls toshadowOf(getMainLooper())
. TheScheduler
APIs will be deprecated and removed over time. - Convert any
RoboExecutorService
usages toPausedExecutorService
orInlineExecutorService
. - Run your tests. If you see test failures like
Main looper has queued unexecuted runnables
, you may need to insertshadowOf(getMainLooper()).idle()
calls to your test to drain the mainLooper
. It's recommended to step through your test code with a watch set onLooper.getMainLooper().getQueue()
to see the status of theLooper
queue, to determine the appropriate point to add ashadowOf(getMainLooper()).idle()
call.
Example:
import static android.os.Looper.getMainLooper;
import static org.robolectric.annotation.LooperMode.Mode.PAUSED;
import static org.robolectric.Shadows.shadowOf;
import org.robolectric.annotation.LooperMode;
@RunWith(AndroidJUnit4.class)
@LooperMode(PAUSED)
public class MyTest {
@Test
public void testCodeThatPosts() {
List<String> events = new ArrayList<>();
events.add("before");
new Handler(Looper.getMainLooper()).post(() -> events.add("after"));
events.add("between");
// the 'after' task is posted, but has not been executed yet
assertThat(events).containsExactly("before", "between").inOrder();
// execute all tasks posted to main Looper
shadowOf(getMainLooper()).idle();
assertThat(events).containsExactly("before", "between", "after").inOrder();
}
}
Take a look at Robolectric’s ShadowPausedAsyncTaskTest
for an
example of using PAUSED
LooperMode
and background tasks.
Troubleshooting
- Animations: Use of Animations can cause delayed tasks to be posted to the main
Looper
queue. You can use theShadowLooper.idleFor()
orShadowLooper.runToEndOfTasks()
APIs to execute these tasks.
Feedback
Please let us know of any roadblocks to adopting PAUSED
LooperMode
.
We’d like to make it the default mode for tests in the next release, and thus your feedback would be
most welcome.