Robolectric tests have become flaky

Since two weeks our unit tests that use Robolectric seem to be very flaky. On some runs they work perfectly well, on other runs they seem to fail with:

> Task :data:bookmarks:impl:testDebugUnitTest

BookmarksRepositoryImplTest > Removing a stored item succeeds FAILED
java.lang.ExceptionInInitializerError at AndroidTestEnvironment.java:559
Caused by: java.lang.RuntimeException at ShadowWrangler.java:140
Caused by: java.lang.reflect.InvocationTargetException at DirectMethodHandleAccessor.java:118
Caused by: java.lang.NoClassDefFoundError at Bundle.java:1523
Caused by: java.lang.ClassNotFoundException at SandboxClassLoader.java:200
Caused by: java.io.EOFException at RandomAccessFile.java:500

This is odd, because we haven’t done any changes to these tests in the last few months and also no changes to the Robolectric dependencies (or its setup). So I was on the lookout for an external change on Maven or Bitrise’s side.

I saw this post and this seems fishy to me. We don’t use the Gradle Build Cache from Bitrise (because it isn’t on our plan), but I find it highly suspect that we got errors after this change.

Based on my good friend Claude, I got this diagnosis:

So the chain is:
Bitrise default change (Apr 30) → Robolectric now fetches via mirror → mirror is race-prone under parallel reads → June dependency bumps trigger cold-cache builds en masse (our gradle cache was cleared) → flake becomes frequent.

I (think it’s) fixed this by caching ~/.m2/repository/org/robolectric, but other customers will might run into this as well. Perhaps it’s good to check whether this is indeed a problem on your mirror, Bitrise team.

Does this hypothesis make sense?

Let me know.

Hi @vincent.bolta

Thank you for reporting the issue!

Our team has already begun investigating this matter, and we have a hypothesis.

To assist us in narrowing down the root cause, would you be able to share the URL of one of the affected builds, one that failed (where you see the error you shared in the build log)? That’d help a lot!

Hi @viktorbenei . Thanks for replying. I’m not sure if you have access to these private Bitrise projects, but these are some of the URLs:

Look under the "_UnitTest" step.

Hi @vincent.bolta

Can you please enable Support Access for this project?

  1. On the main page of the project, click on the Project settings button.
  2. On the left, select Basic settings from the menu options.
  3. Scroll down to the Support Access and toggle Grant temporary access. It might take a couple of seconds to work and you might need to refresh your page to see the enabled status.

Turned it on for you!

Hi @vincent.bolta,

Thanks for the example builds!

First, we can rule out our dependency mirrors here. These builds were running on GCP US East, where we don’t have any mirrors deployed. You can see it in the log: the mirror activation step ran but skipped, because there’s no mirror deployment in that datacenter:

Switching to workflow: _SetupDependencyMirrors
+------------------------------------------------------------------------------+
| (0) Activate Gradle Mirrors                                                  |
+------------------------------------------------------------------------------+
...
Datacenter "US_EAST1" (region "us_east") has no Bitrise mirror deployment, skipping Gradle mirror activation

So for these builds the Bitrise Gradle dependency mirror was never active, and dependencies were resolved without it.

That said, your workaround is spot-on, and it points right at what’s actually happening. Worth calling out why it works.

Robolectric doesn’t resolve its android-all-instrumented jar through Gradle. It downloads it on demand, at test runtime, using its own resolver, and publishes it into your local Maven repo under ~/.m2/repository/org/robolectric. The way it publishes is the key detail: it stages the download into a temp directory and then moves it into ~/.m2. When the temp dir and ~/.m2 live on different filesystems (which is the case on the build VMs, where temp is on a tmpfs and ~/.m2 is on the disk), that “move” can’t be an atomic rename, so it falls back to a plain copy of a ~200 MB file. And a copy is not atomic.

That’s the race. As soon as two Robolectric test JVMs run concurrently and share the same ~/.m2, one can start downloading + copying the jar while another checks “does the jar exist yet?”, sees the half-written file, and starts reading it mid-copy. In a multi-module build like yours that concurrency comes mostly from Gradle running several modules’ test tasks in parallel (it can also come from maxParallelForks > 1 within a single task), but either way they share one ~/.m2. Reading a partially-written jar fails at the ZIP layer, which is exactly the chain you saw:

EOFException → ClassNotFoundException → NoClassDefFoundError (Bundle)

This is a long-standing, documented Robolectric behaviour: robolectric/robolectric#2346, titled “android-all jar download race (tests failing when maxParallelForks > 1)”. A Robolectric maintainer confirms the exact mechanism in that thread:

Race condition while downloading android-all.jar … first thread starts downloading via MavenDependencyResolver, which creates an empty or truncated file, which the second thread uses prematurely.

It lines up with everything you’ve seen: it’s intermittent, it correlates with cold caches (the jar isn’t there yet, so the download+copy happens during the test run), it only shows up under parallelism, and it usually self-heals on the next run, since once a complete copy has landed on disk subsequent reads get the full, valid file. The workaround called out in that same issue is exactly what you’d expect: make sure the dependency is already downloaded before the parallel test run starts.

We can see all of this in the build you shared: the mirror step skipped (US_EAST1 … no Bitrise mirror deployment), and the failures are the exact ExceptionInInitializerError @ AndroidTestEnvironment → … → EOFException @ RandomAccessFile chain, hitting several modules’ test tasks (:data:bookmarks:impl, :feature:article:region, …) that were running in parallel and sharing one ~/.m2.

That’s why caching ~/.m2/repository/org/robolectric helps: when the jar is already fully present before tests start, there’s no concurrent download/copy for a parallel fork to read mid-write. It’s a good mitigation and worth keeping in place (it speeds your builds up too).

One caveat though: caching only avoids the race, it doesn’t remove it. Any time the jar isn’t already in the cache (a cache miss, an eviction, a new or changed SDK level, a cleared or rebuilt cache) Robolectric falls back to the runtime download+copy, and the same flake can reappear. So treat the cache as a strong reduction in frequency, not a guaranteed permanent fix.

To close the race properly rather than just dodge it, the cleanest approach is to pre-fetch the jar serially in a setup step, before the parallel test run. The race only exists because the first download+copy happens concurrently with other Robolectric JVMs. If the jar is already complete on disk, every one of them just reads it.

The trick is that the warm-up itself must be serial, so no other Robolectric JVM is resolving/publishing the same missing artifact while that first download+copy completes. Run a tiny slice of your Robolectric suite on its own (a single small test class, no project-level parallelism, and maxParallelForks = 1), then let the full parallel build run as normal. For example, as a Script step before your test step:

# Warm up: run a Robolectric test serially so Robolectric downloads the required
# android-all jar(s) before the parallel test step. Note: maxParallelForks=1 only
# takes effect if your Gradle Test task is wired to read that property.
./gradlew :some:module:testDebugUnitTest --tests "*SomeFastRobolectricTest" \
  --no-parallel --max-workers=1 -PmaxParallelForks=1

A few notes on the knobs:

  • --no-parallel / --max-workers=1 keep Gradle from running other modules’ test tasks alongside the warm-up (the main concurrency source in your build).
  • maxParallelForks = 1 keeps a single test task from forking multiple test JVMs. The -PmaxParallelForks=1 above only does anything if your build script actually maps that property onto the test task’s maxParallelForks, so otherwise hardcode it on the task for the warm-up.

One caveat for a multi-module repo like yours: a single warm-up test only fetches the android-all coordinate that that test’s config needs. If different modules pin different SDK levels (via @Config(sdk = ...)) or different Robolectric versions, they resolve different android-all artifacts, and any one that’s still missing when the parallel run starts can re-trigger the same race. So make sure the warm-up covers every Robolectric runtime jar the full run needs: warm up one representative test per SDK level / Robolectric version in use, or run the affected Robolectric test tasks once serially before the normal parallel run.

Once all the required Robolectric runtime jar(s) are fully present in ~/.m2, your normal parallel test step should only read them, so the truncated-copy race is avoided for those artifacts.

Let us know if you still see flakiness with the cache in place, happy to dig further!

Thanks for the lengthy reply. This is indeed a fix to lower the frequency, not to remove it completely. I updated a few dependencies this morning and the error indeed came back for the first run.

The permanent fixes you proposed are somewhat tedious and liable to break. I don’t want to reference a particular test (like “SomeFastRobolectricTest”) as these names might change and then silently break. Additionally, as you point out, this only downloads a part of test config, not all of it.

Preferably, I want to be able to disable this ‘download at runtime’ behavior of Robolectric, but I’m not sure I can.

PS: Next to this, something keeps gnawing at me as this problem only surfaced last month while we’ve running these tests for more than a year already. I don’t understand why it’s only becoming a problem now and not as soon as I introduced these tests. Did something on the underlying Bitrise VMs change that may have surfaced this issue?

Fair points, and you’ve basically named the fix: stop Robolectric from downloading the android-all jars at runtime. Robolectric’s offline mode does exactly that. You declare the android-all-instrumented jars as normal Gradle dependencies and point Robolectric at them with robolectric.offline=true, so the flaky runtime download/copy path never runs - no matter the cache state, dependency bumps, or parallel forks. As a bonus the jars then come through Gradle’s own resolution, which is atomic and cache-safe.

Docs:

On “why now, after a year?” - honestly could be either side. On ours, a stack change may have put the temp dir and ~/.m2 on different filesystems, which turns Robolectric’s atomic move into a race-prone copy. But it could just as well be a Robolectric version bump changing how it stages those jars (the race itself is a long-standing Robolectric issue). I’ll dig into our stack history to see if anything lines up on our end - either way, offline mode definitely solves the issue.