Mobile architecture
AGP 9 changed a default. Release unit tests stopped running.
The AGP 9 migration looked finished when Gradle help succeeded. Then the verification command asked for testReleaseUnitTest and Gradle could not find it. A small fixture shows the setting behind it and what to verify.
By Mike Salari. Staff mobile engineer · 500+ technical interviews from the hiring side.
5 min read
When I ask a senior Android candidate about a build migration, I listen for what they would check after the build goes green. Most name the compile. Few name the tests that used to run and stopped without a word.
Here is a migration where that happened. The Android modules no longer applied kotlin-android, the convention plugin compiled, and Gradle help configured every module. Then the verification command asked for testReleaseUnitTest. Gradle could not find it.
This failure is different from duplicate Kotlin plugin registration. AGP 9's default test-component policy changed. Replacing the old command with plain test would have hidden the missing release coverage behind a green build.
I am a staff mobile engineer with 500+ technical interviews from the hiring side. A four-module fixture shows what breaks and what restores it.
The fixture
The fixture is one small app built once on AGP 8 and once on AGP 9. Feature has a composable and a function under extra/kotlin. Data has a Room entity, DAO and database generated through KSP. Shared is a KMP library with common code, a JVM target and an Android target. A precompiled buildSrc convention plugin configures the ordinary Android library modules.
That convention plugin is the trap. Neither feature nor data declares org.jetbrains.kotlin.android. In the baseline, applying editorial.android-library applies com.android.library and then the Android Kotlin plugin. If you search only the app's plugins block, you miss the second application site. Search your build logic before you touch versions:
rg 'org.jetbrains.kotlin.android|kotlin-android|kotlin-kapt|kotlinOptions|sourceSets' .Include convention plugins, aliases and included builds, and note which target each match affects. The same spelling in a JVM utility, an Android app and a KMP library does not mean the same migration. Google's built-in Kotlin guide treats KMP separately, and in the fixture Shared keeps org.jetbrains.kotlin.multiplatform and com.android.kotlin.multiplatform.library. Deleting every Kotlin plugin would break it.
For the Android modules, the convention plugin keeps the Android library application and drops the extra Kotlin one. A negative control behind -PduplicateKotlin puts the removed application back. Configuration then rejects the Android Kotlin plugin as no longer required.
Record the whole toolchain
Use the Gradle, Kotlin, KSP and Room versions your project already supports; the versions are not the point. AGP 9.0's release notes give Gradle 9.1.0 as the minimum.
Two baseline failures are common in a fixture like this. buildSrc already supplied AGP on the classpath and the root plugins block requested it again with a version Gradle could not verify. The fix is to make buildSrc the one visible owner of that pin. A newer JDK daemon can also produce bytecode that disagrees with a Java 17 target. Set Kotlin compilerOptions.jvmTarget to JVM_17 next to Java's targetCompatibility. Do not turn validation off.
Keep a table with the wrapper, daemon JDK, AGP, KGP, Kotlin language mode, Compose compiler and processors. "We use Kotlin 2" does not say which compiler built your app.
A custom source directory needs a call site
The feature module's extra/kotlin directory holds a label() function. The composable calls it, the app consumes it, and a unit test asserts its value. The migrated module registers the directory through the Android source set:
android.sourceSets.named("main") {
kotlin.directories += "extra/kotlin"
}The IDE tree can show a directory the compiler never reads. A call site turns that omission into a compile failure. For generated directories, do not swap a generator for a hard-coded path under build and hope Gradle schedules the producer.
Prove generation survives both variants
Data references NotesDb_Impl directly, so a missing Room implementation fails compilation. A unit test also loads the generated class and checks its name. Keep the debug and release generated sources and exported schemas, and check that all four APK class inventories contain the generated database and the feature and shared packages.
That does not open a database or run a migration. For a real database module, run your schema migration tests in the same command. If you still use kapt, Google's guide has a migration path that this fixture does not cover.
The release tests that disappeared
The first migrated verification asks for the baseline tasks, including feature:testReleaseUnitTest and data:testReleaseUnitTest. Gradle rejects the missing task. A task inventory shows debug unit tests and no release unit-test component.
The AGP 9.0 release notes document android.onlyEnableUnitTestForTheTestedBuildType changing its default to true, so only the tested build type gets unit tests. This fixture requires both build types, so the migrated gradle.properties says so:
android.onlyEnableUnitTestForTheTestedBuildType=falseAfter that line, the release test tasks exist again. If your project only needs one tested build type, the narrower default may be fine. Make that a decision in the review before you delete a command that used to protect release behaviour.
Check the result inventory, not the task name. Each tree should show five test executions: feature and generated-data checks in debug and release, plus the shared JVM check. Expect ten with no failures, errors or skips. They are three logical tests across those configurations, not ten business behaviours.
What the finished command proves
| Contract | What to check |
|---|---|
| Configure baseline and migration | help succeeds in each |
| Build the app variants | Debug and release APKs build in each |
| Compile the custom feature source | Consumer compiles, value assertion holds |
| Generate Room code | Debug and release implementations and schemas are kept |
| Keep packaged code | Four APK inventories hold the expected classes |
| Keep Android release tests | Explicit property restores the components; tests run |
| Keep KMP separation | Shared JVM test runs; Android library consumed by app |
| Catch a stale plugin application | Duplicate-plugin control fails configuration |
Limits: the release build is unsigned and not shrunk, so signing, store upload and obfuscation rules are separate gates. The fixture runs Gradle from the command line, not Android Studio sync, and does not measure build times. The decision rule behind this is in the Brief on why a compiler accepting your code is not correctness.
Next steps
Before you merge an AGP 9 migration, diff the task list for every module against the last green CI run. A missing task is the signal. For the interview version of this question, how you would verify a build migration and what you would refuse to call done, use the Android preparation route and The Senior Signal.
I wrote Controlled Change for the part that comes after the diagram is agreed, when the shipped app still has to change safely.