Supporting Robolectric tests in Mill
- Stylianos Anastasiou

- Feb 22
- 3 min read
While building Android support for the Mill build tool, our approach has been to treat the Android Gradle Plugin (AGP) as a black box at first, inspect what it produces, and replicate its behaviour in a Mill implementation. In addition, since AGP is open source, this process often includes us inspecting Gradle code as an extra helping hand to reverse engineer the contract between the build system and the Android ecosystem.
In order to validate real-world compatibility, we started migrating the Jetpack Compose samples to Mill. This migration is now complete and can be found in our repository.
These samples served as an excellent way for us to test correctness and parity with Gradle-based builds while also find and develop missing pieces. This blog shows an example of this approach: getting Robolectric tests to work properly under Mill.
The Robolectric Confusion
While migrating the Jetcaster Wear variant, we encountered failing Robolectric tests.
At first glance, we thought that the Wear module was using a Robolectric Gradle plugin (which does exist but apparently not needed anymore for the tests to work). During the migration, we also noticed that the Koin sample also used Robolectric without applying any explicit Gradle plugin at all.
This observation that Robolectric does not fundamentally rely on a Gradle plugin but possibly on an environment that Gradle prepares for unit tests was crucial in our investigation process.
That realisation shifted the question from "Which plugin implementation are we missing?" to:
What exactly is Gradle generating that Robolectric expects?
And that's where the reverse-engineering process began.
The failure
java.lang.RuntimeException: Unable to resolve activity for Intent {
act=android.intent.action.MAIN
cat=[android.intent.category.LAUNCHER]
cmp=org.robolectric.default/com.example.jetcaster.MainActivity
}What was interesting about this was org.robolectric.default, which meant that there was a namespace mismatch that shouldn't have happened.
What this meant
Robolectric was trying to resolve the activity under a default package instead of the one defined in the manifest, which was com.example.jetcaster.MainActivity
So either:
the manifest wasn’t visible to the test runtime
or Robolectric didn’t know where to find the processed Android resources
In Gradle builds, this "just works" when you enable:
testOptions {
unitTests {
includeAndroidResources = true
}
}includeAndroidResources was a flag that we recently stumbled upon with a different unit test failure, for which we instinctively thought to include the R.jar to the unit test classpath. This did seem to fix the problem at the time but maybe this solution was incomplete and was time to really investigate it.
Reverse Engineering Gradle
We inspected Gradle’s intermediates and found this file which felt relevant:
build/intermediates/unit_test_config_directory/
debugUnitTest/generateDebugUnitTestConfig/out/
com/android/tools/test_config.propertiesIts contents:
android_custom_package=com.example.jetcaster
android_merged_assets=...
android_merged_manifest=...
android_resource_apk=...This seemed like the real mechanism on how unit tests are informed on the app's resources.
Verifying our assumption with the Android Documentation, when includeAndroidResources = true, Gradle doesn’t just add R.jar.
It generates a properties file on the test classpath:
com/android/tools/test_config.propertiesAnd Robolectric reads it to:
determine the correct application package
locate the merged manifest
load merged assets
load the resource APK
Without this file, Robolectric falls back to org.robolectric.default.
That’s exactly what we were seeing.
Replicating this behaviour in Mill
To properly support includeAndroidResources, we implemented two tasks in Mill.
Produce the required properties
def androidTestConfigProperties = Task {
Map(
"android_custom_package" -> outer.androidNamespace,
"android_merged_manifest" -> outer.androidMergedManifest().path.toString,
"android_resource_apk" ->
(outer.androidLinkedResources().path / "apk" / "res.apk").toString,
"android_merged_assets" ->
outer.androidTransitiveMergedAssets().path.toString
)
}Generate the required field on the test classpath
def androidGeneratedTestConfigSources: T[Seq[PathRef]] = Task {
val content = androidTestConfigProperties().map { case (key, value) =>
s"$key=$value"
}.mkString("\n")
val configFile =
Task.dest / "com" / "android" / "tools" / "test_config.properties"
os.write(configFile, content, createFolders = true)
Seq(PathRef(Task.dest))
}Then we added the generated sources directory to the unit test classpath.
That’s it.
Robolectric now resolved:
com.example.jetcaster.MainActivityAnd the tests passed.
Takeaway
When developing Android support, it is crucial to see how Gradle behaves, what the frameworks expect, and replicate that contract.
In this case, when includeAndroidResources = true, the build system must:
provide compiled resources
provide the merged manifest
provide merged assets
generate com/android/tools/test_config.properties
Once Mill did that, Robolectric behaved exactly like it does under Gradle.




Comments