How One Missing `return` Cut My Android Test Runtime by 72%

Featured in Android Weekly · #737Featured in Software Testing Weekly · #321
How One Missing `return` Cut My Android Test Runtime by 72%

Android developers spend a lot of time thinking about build performance. I wrote previously about cutting Gradle build time by 50%, mostly through practical configuration, benchmarking, and a few targeted improvements.

But once the build itself gets faster, another bottleneck starts to stand out very quickly: tests.

In my case, the local Android test run had become slow enough that it was no longer just a CI problem. It affected the feedback loop during development, made small changes feel more expensive than they should, and encouraged the kind of behaviour most teams try to avoid: running only the tests they think are relevant and hoping the pipeline catches the rest.

That was the wrong incentive.

So I set myself a simple rule before making any changes:

The test suite had to get faster without skipping tests.

No default exclusions. No reducing coverage. No moving slow tests out of the normal path just to make the numbers look better.

The result, on this branch, was a reduction from around 29 minutes 52 seconds to around 8 minutes 18 seconds for the same forced test run. That is roughly a 72% reduction, or about 3.6x faster.

The interesting part is that the biggest improvement did not come from a complicated Gradle trick.

It came from one missing return.

Start With A Baseline

Before changing anything, I needed a repeatable way to measure the suite.

This sounds obvious, but it is easy to accidentally benchmark the wrong thing. Gradle can reuse outputs, the daemon can warm up, tests can be up-to-date, and one run on a busy laptop can look very different from another.

So I measured a few different scenarios:

  • A full forced test run using  — rerun.
  • Warm runs where Gradle could reuse whatever it was allowed to reuse.
  • Smaller targeted runs for the slowest groups of tests.
  • A basic slow-test report from the generated test result XML files.

The full forced run was the number I cared about most because it represented the cost of actually executing the suite, not just asking Gradle whether it could avoid work.

The baseline was painful:

Before: 29m 52s
After: 8m 18s
Saved: 21m 34s

That number became the anchor for every decision afterwards.

If an optimisation made one tiny path faster but made the full run unstable, it did not count. If it skipped tests, it did not count. If it only helped when Gradle avoided running anything, it was useful information, but not the main win.

The Numbers

Here are the headline numbers from the full forced test runs.

The final run was 1,294 seconds faster than the baseline, which is 21 minutes and 34 seconds saved.

These numbers are from one Android test suite on one branch. The exact timings are less important than the pattern: measure the real bottleneck, make small changes, and reject anything that makes the suite less trustworthy.

Slowest Test Groups After Optimization

After the main fix, the remaining slowest groups looked like this.

The Tempting Shortcuts Did Not Work

The first ideas were the obvious ones.

Could the tests run with more forks? Could the slow UI-style tests be split up? Could a few expensive tests be excluded by default? Could optional tooling be disabled for local runs?

Some of these ideas improved isolated numbers. Some made the suite worse. Some introduced timeout or idling failures. Some were simply the wrong trade-off.

The most important decision was rejecting anything that made the suite less trustworthy.

For example, increasing parallel forks sounds like the kind of change that should help. In practice, UI-heavy JVM tests can have shared assumptions around schedulers, loopers, static state, mocked Android framework behaviour, or resource usage. More concurrency does not automatically mean more throughput. Sometimes it just means more contention and less determinism.

The same applied to skipping the slowest tests. That would have produced a very nice chart, but not a better engineering system.

If the default test command stops running the tests that catch regressions, the feedback loop is faster only in the most superficial sense.

The failed experiments were still useful:

  • Increase test forks: caused timeout or idling failures, so I rejected it.
  • Split slow UI-flow classes: timed out or failed, so I rejected it.
  • Exclude slow tests by default: made the suite faster, but reduced trust so I rejected it.
  • Suppress logging: produced no useful improvement.
  • Disable optional tooling: made the measured result worse.

This is one of the parts of performance work that does not always make it into the final pull request. The rejected ideas matter because they explain why the final solution is shaped the way it is.

Use A Small Benchmark Harness

The most useful tool in the process was not an optimisation. It was a small local benchmark helper.

It wrapped the test commands I cared about, supported forced reruns, and parsed test result XML into slow class and slow method reports.

The goal was not to build a perfect profiling system. It was to make repeated measurement boring.

That mattered because performance work is full of plausible explanations. A change can feel faster because the daemon is warm. A run can look slower because something else was happening on the machine. A test class can look suspicious because it is large, even though the real issue is a helper it calls hundreds of times.

Having a simple report changed the conversation from:

This feels slow.

to:

These classes dominate the run, these helpers are used everywhere, and this change moved the full-suite number by this much.

That made it much easier to avoid guessing.

The One-Line Fix

The biggest win came from a shared UI test helper.

It was designed to wait for a view to become visible. Conceptually, it did something like this:

do {
    try {
        onView(withId(resourceId)).check(matches(isDisplayed()));
    } catch (AssertionError | RuntimeException ignored) {
        // keep waiting
    }
} while (System.currentTimeMillis() < endTime);
View code on GitHub Gist

At a glance, this looked reasonable. Try the assertion until it passes or the timeout expires.

But there was one problem: when the assertion passed, the helper did not return.

So even if the view was visible immediately, the test still waited until the timeout expired.

The fix was just this:

onView(withId(resourceId)).check(matches(isDisplayed()));
return;

That is the kind of change that feels almost too small to explain a major runtime reduction. But shared helpers are multipliers.

This helper was used by several higher-level test actions. Those actions appeared throughout the UI-style test suite. A one-second timeout did not cost one second if it was accidentally paid hundreds of times. It became death by a thousand cuts.

This was also a good reminder that not all test performance problems are Gradle problems.

Sometimes Gradle is doing exactly what you asked it to do. The test suite itself is just spending a lot of time waiting for things that have already happened.

Small Data Loading Wins Still Help

The one-line wait fix was the main improvement, but I also cleaned up a couple of smaller test-data inefficiencies.

One helper repeatedly loaded resource files from the classpath. Those files were static test fixtures, so reading and decoding them again and again added unnecessary overhead.

The change was simple: cache the resource content in memory by file name.

Another helper repeatedly searched through a list of test definitions by ID. That was fine when the data set was small, but it happened often enough to be worth fixing. I built a map once when the fixture was loaded, then used constant-time lookup for each test case.

Neither of these changes was as dramatic as the wait-loop fix. But they were safe, easy to reason about, and kept the suite doing the same work with less repeated overhead.

That is usually the kind of test optimisation I prefer:

  • Keep the behaviour the same.
  • Remove repeated mechanical work.
  • Avoid cleverness unless the measurement justifies it.

Pipeline Time Matters Too

Local runtime was only part of the story. The CI pipeline also had sequential verification stages that did not all depend on each other.

The original flow was broadly:

Assemble
Lint
Unit tests
SonarQube
Security analysis

Some of those stages really do have ordering constraints. SonarQube, for example, needs the test and coverage reports if you want useful analysis. Running it before tests would make the pipeline faster only by making it less useful.

But assemble, lint, unit tests, and security analysis were independent enough to run in parallel.

The safer shape became:

Verify
+-- Assemble
+-- Lint
+-- Unit tests
+-- Security analysis

SonarQube
View code on GitHub Gist

This was not about making every stage parallel. It was about making independent stages parallel without breaking report handoff.

The key detail is that I did not just run several Gradle commands in the same workspace at the same time.

That is asking for trouble.

Gradle has caches, daemons, build directories, generated files, reports, and all sorts of shared mutable state. If you parallelize CI stages without isolating their workspaces, you can create failures that are almost impossible to reproduce locally.

Each parallel branch got its own clean checkout and isolated Gradle user home. The unit test branch stashed the reports SonarQube needed, and SonarQube ran afterwards in the normal ordered stage.

That preserved the dependency that mattered while removing the ordering that did not.

What I Would Recommend To Other Teams

If I had to turn this into general advice, it would be this.

Do Not Start By Skipping Tests

Skipping tests can be valid in some workflows, especially when you have explicit smoke, changed-module, or pre-submit strategies.

But it should not be the first move when optimizing a slow suite.

First, understand why the suite is slow. If you skip the slowest tests before measuring them, you may hide exactly the problem that needs fixing.

Benchmark The Thing You Actually Care About

Warm Gradle runs are useful. Incremental runs are useful. Up-to-date checks are useful.

But if your pain is that the full test suite takes 30 minutes when it actually runs, benchmark that.

Use forced reruns when you need to measure execution time. Use warm runs when you need to measure local feedback after no-op changes. Keep the numbers separate.

Look For Shared Helpers

Slow test suites often have a few central helpers used by hundreds or thousands of assertions.

A small inefficiency in a shared helper can dominate the whole runtime.

Search for patterns like:

  • Fixed sleeps.
  • Retry loops.
  • Polling helpers.
  • Helpers that wait after success.
  • Repeated fixture loading.
  • Repeated parsing or mapping work.

The goal is not to remove all waiting. Tests need synchronization. The goal is to stop waiting once the condition has already been met.

Be Careful With Parallelism

Parallelism is powerful, but it is not free.

For local tests, more forks can expose hidden shared state and make previously stable tests flaky. For CI, parallel stages can corrupt each other if they share the same workspace or Gradle home.

Parallelize independent work, but isolate it properly.

If a stage produces reports that another stage needs, make that dependency explicit with artifacts, stash/unstash, or whatever your CI system provides.

Keep SonarQube And Coverage Honest

Static analysis stages are often treated as independent, but coverage-aware analysis depends on test outputs.

If you move analysis earlier just to save time, you may silently reduce the quality of the signal.

In my case, SonarQube stayed after tests. The pipeline got faster because the independent verification work moved earlier and ran in parallel, not because the analysis lost information.

Record The Failed Experiments

The changes that did not work were still useful.

I tried options that looked promising and rejected them because they caused timeouts, instability, or worse runtime. Keeping track of those attempts helped avoid going in circles.

Performance work benefits from a small lab notebook mindset:

  • What did I change?
  • What did I expect?
  • What happened?
  • Did it improve the full suite or only a narrow case?
  • Did it affect reliability?

That last question matters more than the chart.

The Final Result

The full forced test run went from about 29 minutes 52 seconds to about 8 minutes 18 seconds.

That is around 21 minutes 34 seconds saved on the same test suite.

Or, put another way, roughly 72% faster and 3.6x quicker.

The most satisfying part is that this did not come from making the test suite less useful. The tests still ran. The pipeline still produced coverage. The analysis still happened after the reports existed. The changes were mostly about removing accidental waiting and making independent CI work actually independent.

Conclusion

After improving Gradle build time, I expected the next big win to come from another Gradle setting or some deeper build-cache trick.

It did not.

The biggest improvement came from reading the test helpers carefully, benchmarking the full run, and refusing to accept shortcuts that made the suite less trustworthy.

That is probably the main lesson I would take from this work:

Fast feedback is not just about making tools faster. It is about making the whole loop honest, measured, and safe to rely on.

Sometimes that means tuning Gradle.

Sometimes it means parallelizing CI properly.

And sometimes it means adding one missing return.


How One Missing return Cut My Android Test Runtime by 72% was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

Back to all articles