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 Loopers 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 Schedulers 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
Looperare not automatically executed inline. Similar to the legacyPAUSEDIdleState, tasks posted to the mainLoopermust be explicitly executed viaShadowLooperAPIs. However, we’ve made a couple additional improvements inPAUSEDmode that make this easier: - Robolectric will warn users if a test fails with unexecuted tasks in the main
Looperqueue. This is a hint that the unexecuted behavior is important for the test case. - AndroidX Test APIs, like
ActivityScenario,FragmentScenarioand Espresso will automatically idle the mainLooper. - Tasks posted to background
Loopers 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
Schedulercalls for controllingLoopers toshadowOf(looper). - Recommended, but not mandatory: Convert any foreground
Schedulercalls toshadowOf(getMainLooper()). TheSchedulerAPIs will be deprecated and removed over time. - Convert any
RoboExecutorServiceusages toPausedExecutorServiceorInlineExecutorService. - 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 theLooperqueue, 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
Looperqueue. 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.