Skip to content
All essays

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:

Shell
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:

Kotlin
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:

properties
android.onlyEnableUnitTestForTheTestedBuildType=false

After 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

ContractWhat to check
Configure baseline and migrationhelp succeeds in each
Build the app variantsDebug and release APKs build in each
Compile the custom feature sourceConsumer compiles, value assertion holds
Generate Room codeDebug and release implementations and schemas are kept
Keep packaged codeFour APK inventories hold the expected classes
Keep Android release testsExplicit property restores the components; tests run
Keep KMP separationShared JVM test runs; Android library consumed by app
Catch a stale plugin applicationDuplicate-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.

Related resources

Free resources that put this essay to work: short enough to use this week, and each one works on its own, with or without a book.

Related books

These books take the subject of this essay further, written from the hiring side from 500+ technical interviews. Each book page shows what is inside, who it is for, and a free sample where there is one.

Share this essay