top of page
Search

Supporting Robolectric tests in Mill



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.properties

Its 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.properties

And 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.MainActivity

And 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


Archiepiskopou Makariou Iii 42
2574 Sia
Lefkosia - Cyprus

engineering@vaslabs.io

  • Instagram
  • Facebook
  • LinkedIn
  • X

©2024 by Vaslabs LTD. Proudly created with Wix.com

bottom of page