Configuring JUnit4 on Android — The Right Runner, Registry, and Matrix
Almost every "test suite is misconfigured" report boils down to one of three root causes: the wrong AndroidJUnit4 is imported, the wrong InstrumentationRegistry is imported, or the size-annotation / dispatcher plumbing is missing. This skill encodes the exact Gradle matrix from androidx.test:core 1.7.0 onward, the canonical FQCNs, and the minimal MainDispatcherRule pattern that androidx itself ships in testutils-ktx.
When to use this skill
- The user reports
Cannot resolve symbol AndroidJUnit4,AndroidJUnit4 is deprecated, or asks "whichAndroidJUnit4should I import". - The user reports
IllegalStateException: No instrumentation registeredfromInstrumentationRegistry.getInstrumentation()in a unit test, or asks whyInstrumentationRegistryis crossed out. - The user wants Truth subjects (
IntentSubject,BundleSubject,LocationSubject,ParcelableSubject) but does not know which artifact ships them. - The user wants
am instrument -e size smallto filter tests and asks how to wire@SmallTest/@MediumTest/@LargeTest. - The user is writing a
ViewModel/ coroutine test and needs the canonicalMainDispatcherRuletemplate.
When NOT to use this skill
- The user is wiring Mockito mocks — use
../../mocking/mocking-with-mockito/SKILL.md. - The user is wiring MockK mocks — use
../../mocking/mocking-with-mockk/SKILL.md. - The user is configuring
runTest { … }orTurbine— use../../coroutines/testing-coroutines-with-runtest/SKILL.mdor../../coroutines/testing-flows-with-turbine/SKILL.md. - The user is configuring Robolectric specifically (SDK matrix, resources,
@Config) — use../../robolectric/using-robolectric-correctly/SKILL.md. - The user is running tests on a device with
am instrument— use../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md. - The user is choosing between fakes and mocks — use
../../../fundamentals/doubles/picking-test-doubles/SKILL.md.
Prerequisites
- Android Gradle Plugin applied (
com.android.applicationorcom.android.library). - A
src/test/source set on disk (Gradle creates it lazily —mkdir -p src/test/kotlinif missing). - Kotlin module (these notes assume Kotlin; the same FQCNs work from Java).
- For coroutine-related tests,
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.xalready on the classpath.
Workflow
- 1. Pin the canonical Gradle dependency matrix. These are the released stable versions backing this skill (verified against
~/.gradle/caches/modules-2/files-2.1/androidx.test/...). Pin them directly, do notlatest.release:
dependencies {
// Core JUnit4
testImplementation("junit:junit:4.13.2")
// androidx.test foundations (work in src/test/ via Robolectric and in src/androidTest/)
testImplementation("androidx.test:core:1.7.0")
testImplementation("androidx.test:core-ktx:1.6.1")
testImplementation("androidx.test:runner:1.7.0")
testImplementation("androidx.test:rules:1.7.0")
testImplementation("androidx.test.ext:junit:1.3.0")
testImplementation("androidx.test.ext:junit-ktx:1.3.0")
testImplementation("androidx.test.ext:truth:1.7.0")
testImplementation("com.google.truth:truth:1.4.4")
// Coroutines test (for MainDispatcherRule / runTest)
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
}
For instrumented tests in src/androidTest/, mirror the same artifacts on androidTestImplementation. The artifacts are dual-classpath compatible.
- 2. Annotate test classes with the correct
@RunWith. There are TWO classes namedAndroidJUnit4on the classpath. The deprecated one isandroidx.test.runner.AndroidJUnit4(filerunner-1.7.0/androidx/test/runner/AndroidJUnit4.javaL41-44 —@Deprecated). The canonical one isandroidx.test.ext.junit.runners.AndroidJUnit4from theandroidx.test.ext:junit:1.3.0artifact (filejunit-1.3.0/androidx/test/ext/junit/runners/AndroidJUnit4.javaL49). It delegates to Robolectric on the JVM (whenorg.robolectric.RobolectricTestRunneris on the classpath) and toandroidx.test.internal.runner.junit4.AndroidJUnit4ClassRunneron a device.
import androidx.test.ext.junit.runners.AndroidJUnit4 // canonical
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MyTest { /* ... */ }
- 3. Use the canonical
InstrumentationRegistry.androidx.test.InstrumentationRegistry(no.platform.app.) is@Deprecatedsince:monitor1.x — every member is@InlineMe'd to its replacement. The canonical class isandroidx.test.platform.app.InstrumentationRegistry(filemonitor-1.8.0/androidx/test/platform/app/InstrumentationRegistry.javaL30-85).
import androidx.test.platform.app.InstrumentationRegistry
val instrumentation = InstrumentationRegistry.getInstrumentation()
val args: Bundle = InstrumentationRegistry.getArguments() // copy of -e key/value pairs
getInstrumentation() throws IllegalStateException("No instrumentation registered! Must run under a registering instrumentation.") if called outside an instrumentation host or before Robolectric initialises its shadow. Robolectric registers an Instrumentation instance into the registry before tests run, so this works in src/test/ when @RunWith(AndroidJUnit4::class) is in effect.
- 4. Pull the application Context via
ApplicationProvider, not via the registry.ApplicationProvider.getApplicationContext()is the single entry point for the Application context (filecore-1.7.0/androidx/test/core/app/ApplicationProvider.javaL29-43). Internally it returnsgetInstrumentation().getTargetContext().getApplicationContext()— same Context, more readable callsite, and the unchecked generic lets callers cast to theirApplicationsubclass.
import androidx.test.core.app.ApplicationProvider
val ctx: Context = ApplicationProvider.getApplicationContext()
val app: MyApp = ApplicationProvider.getApplicationContext() // unchecked cast
- 5. Tag each test with a size annotation when the suite is large enough to need filtering. Size is one of three:
@SmallTest(<200ms, no Android stubs),@MediumTest(<1000ms, Android framework via Robolectric),@LargeTest(>1000ms, instrumented). Annotations live inandroidx.test.filters.*(filerunner-1.7.0/androidx/test/filters/SmallTest.javaL44-46). The runner'sRunnerArgs.SIZE(runner-1.7.0/androidx/test/internal/runner/RunnerArgs.javaL58) wires-e size <value>directly to these annotations.
import androidx.test.filters.SmallTest
import androidx.test.filters.MediumTest
@SmallTest
class FastValidatorTest { /* ... */ }
@MediumTest
class RoomDaoTest { /* ... */ }
The corresponding am instrument invocation is adb shell am instrument -w -r -e size small <pkg>/androidx.test.runner.AndroidJUnitRunner. See ../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md for the on-device counterpart.
- 6. Reach for Truth subjects from
androidx.test.ext:truthfor Android-domain assertions. Truth shipped withandroidx.test.ext:truth:1.7.0adds Android-awareSubjecttypes. Each exposes astatic assertThat(T actual)shortcut and astatic Subject.Factory<T, S> name()factory:
| Subject | FQCN package | Asserts on |
|---|---|---|
IntentSubject |
androidx.test.ext.truth.content |
android.content.Intent (action, data, component, extras, categories, flags) |
BundleSubject / BaseBundleSubject / PersistableBundleSubject |
androidx.test.ext.truth.os |
Bundle / BaseBundle / PersistableBundle keys, types, values |
ParcelableSubject |
androidx.test.ext.truth.os |
Parcelable (round-trips through a Parcel to verify the writer/reader) |
LocationSubject |
androidx.test.ext.truth.location |
android.location.Location |
NotificationSubject / NotificationActionSubject / PendingIntentSubject |
androidx.test.ext.truth.app |
Notification family |
MotionEventSubject / PointerCoordsSubject / PointerPropertiesSubject |
androidx.test.ext.truth.view |
MotionEvent family |
SparseBooleanArraySubject |
androidx.test.ext.truth.util |
SparseBooleanArray |
Idiomatic import-and-call:
import androidx.test.ext.truth.content.IntentSubject.assertThat
import androidx.test.ext.truth.os.BundleSubject
@Test fun intent_routes_to_settings() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("settings://wifi"))
assertThat(intent).hasAction(Intent.ACTION_VIEW)
assertThat(intent).hasData(Uri.parse("settings://wifi"))
}
- 7. Install a
MainDispatcherRulefor any test that touchesDispatchers.Main. Compose,viewModelScope, andLiveDataall default toDispatchers.Main, which is unavailable on the JVM unless swapped. The canonical androidx pattern lives attestutils/testutils-ktx/src/jvmMain/kotlin/androidx/testutils/MainDispatcherRule.jvm.kt— use it verbatim:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val testDispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
Apply on a test class:
@RunWith(AndroidJUnit4::class)
class MyViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test fun loadsUsers() = runTest {
val vm = MyViewModel(repo = FakeUserRepository())
vm.load()
advanceUntilIdle()
assertThat(vm.users.value).isNotEmpty()
}
}
StandardTestDispatcher is preferred — it queues continuations and only advances on runCurrent()/advanceUntilIdle(), matching kotlinx.coroutines.test.runTest semantics. UnconfinedTestDispatcher dispatches eagerly and is appropriate only when ordering is irrelevant.
- 8. Verify the test class actually runs. Execute the suite once:
./gradlew :<module>:testDebugUnitTest --tests com.example.MyTest. The Gradle output shows the runner choice —org.junit.runner.RunWith: AndroidJUnit4confirms the canonical runner is bound;Test process exited with status codenear the top confirms Robolectric is wiring up.
Patterns
Pattern: WRONG vs RIGHT — choosing the AndroidJUnit4 runner
// WRONG
import org.junit.runners.JUnit4
import org.junit.runner.RunWith
@RunWith(JUnit4::class)
class MyTest { /* uses InstrumentationRegistry, ApplicationProvider, ... */ }
// WRONG because: stock JUnit4 does not register the Android `Instrumentation` instance
// into InstrumentationRegistry. ApplicationProvider.getApplicationContext() throws
// IllegalStateException("No instrumentation registered!"). Robolectric is also never
// activated even when on the classpath, so all framework calls hit unmocked stubs.
// RIGHT
import androidx.test.ext.junit.runners.AndroidJUnit4 // ext.junit.runners, NOT runner.AndroidJUnit4
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MyTest { /* ... */ }
The deprecated alias androidx.test.runner.AndroidJUnit4 still compiles but emits an unsuppressed deprecation warning. New code MUST import from androidx.test.ext.junit.runners.
Pattern: WRONG vs RIGHT — InstrumentationRegistry import
// WRONG
import androidx.test.InstrumentationRegistry // @Deprecated since :monitor
val ctx = InstrumentationRegistry.getContext() // also deprecated — not the Application context
// WRONG because: every member of androidx.test.InstrumentationRegistry is @Deprecated
// and @InlineMe'd to its replacement. getContext() returns the instrumentation package
// context, NOT the Application context — common source of "resource not found" failures.
// RIGHT
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.core.app.ApplicationProvider
val ctx: Context = ApplicationProvider.getApplicationContext() // application context
val args: Bundle = InstrumentationRegistry.getArguments() // -e key/value pairs
val instr = InstrumentationRegistry.getInstrumentation()
Pattern: WRONG vs RIGHT — accessing Compose Dispatchers.Main from a unit test
// WRONG
class MyViewModelTest {
@Test fun loadsUsers() = runBlocking {
val vm = MyViewModel(repo = FakeUserRepository())
vm.load()
// Throws IllegalStateException: "Module with the Main dispatcher is missing"
}
}
// WRONG because: kotlinx-coroutines-android (the Main dispatcher impl for Android) is
// NOT on the unit-test classpath. Without Dispatchers.setMain(...), any viewModelScope or
// LaunchedEffect that touches the Main dispatcher fails immediately.
// RIGHT
class MyViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
@Test fun loadsUsers() = runTest {
val vm = MyViewModel(repo = FakeUserRepository())
vm.load()
advanceUntilIdle()
assertThat(vm.users.value).hasSize(2)
}
}
Pattern: filtering by size from the command line
The @SmallTest / @MediumTest / @LargeTest annotations bind directly to RunnerArgs.SIZE (runner-1.7.0/androidx/test/internal/runner/RunnerArgs.java L58). For instrumented tests:
adb shell am instrument -w -r -e size small \
com.example.test/androidx.test.runner.AndroidJUnitRunner
For Robolectric-on-JVM tests, Gradle property filtering is the standard route:
./gradlew :app:testDebugUnitTest --tests "com.example.*" \
-PandroidTestSize=small
Combined with -e annotation com.example.SmokeTest (the RunnerArgs.ANNOTATION arg, L60) you get composable filtering.
Mandatory rules
- MUST import
AndroidJUnit4fromandroidx.test.ext.junit.runners. MUST NOT importandroidx.test.runner.AndroidJUnit4— it is@Deprecated. - MUST import
InstrumentationRegistryfromandroidx.test.platform.app. MUST NOT importandroidx.test.InstrumentationRegistry— it is@Deprecated. - MUST use
ApplicationProvider.getApplicationContext()for the Application context. MUST NOT callInstrumentationRegistry.getInstrumentation().getTargetContext()directly —ApplicationProvideris the canonical entry point and handles the unchecked cast. - MUST install a
MainDispatcherRule(or equivalentDispatchers.setMain/resetMainplumbing) before any test that touchesviewModelScope,LaunchedEffect, or anyDispatchers.Mainconsumer. Without it, the test crashes withIllegalStateException: Module with the Main dispatcher is missing. - MUST prefer
StandardTestDispatcheroverUnconfinedTestDispatcherinMainDispatcherRule.StandardTestDispatchermatchesrunTestsemantics; eager dispatch is a footgun for ordering. - MUST annotate every test class or method with exactly ONE size annotation (
@SmallTest/@MediumTest/@LargeTest) when the suite is filtered by size. Multiple size annotations on the same target produce undefined runner behaviour. - MUST pin Robolectric independently — it does not ship in the androidx-test BOM. Use
org.robolectric:robolectric:4.xmatching thecompileSdkof the module. - MUST NOT use
runBlockingTest { }in new code — it is@Deprecated(level = DeprecationLevel.ERROR)sincekotlinx-coroutines-test1.7. UserunTest { }instead. - MUST NOT use
ActivityTestRule<A>— it is@Deprecated. Useandroidx.test.ext.junit.rules.ActivityScenarioRule<A>instead (junit-1.3.0/androidx/test/ext/junit/rules/ActivityScenarioRule.javaL56). For instrumented variants, see../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md. - PREFERRED: declare
testOptions.unitTests.isIncludeAndroidResources = truein the module'sandroid { … }block. Robolectric needs merged resources to inflate any layout or readR.string.*.
Verification
-
./gradlew :<module>:testDebugUnitTestcompiles and reportsTests run: N, Failures: 0for the new suite. -
grep -r "import androidx.test.runner.AndroidJUnit4" src/test src/androidTestreturns NO matches (onlyandroidx.test.ext.junit.runners.AndroidJUnit4is allowed). -
grep -r "import androidx.test.InstrumentationRegistry" src/test src/androidTestreturns NO matches. - Any test that uses
viewModelScope/Dispatchers.Maindeclares@get:Rule val mainDispatcherRule = MainDispatcherRule()and usesrunTest { … }. - At least one test class is annotated with
@SmallTest,@MediumTest, or@LargeTestif the suite is meant to be filtered. -
./gradlew :<module>:testDebugUnitTest --infooutput showsorg.robolectric.RobolectricTestRunner(host) orAndroidJUnit4ClassRunner(device) in the runner stack — confirming theAndroidJUnit4delegate selection.
References
- AndroidX Test releases: https://developer.android.com/jetpack/androidx/releases/test
- Local tests guide: https://developer.android.com/training/testing/local-tests
- Instrumented tests guide: https://developer.android.com/training/testing/instrumented-tests
- AndroidJUnitRunner reference: https://developer.android.com/training/testing/junit-runner
- Truth subject reference (upstream tree): https://github.com/android/android-test/tree/main/ext/truth/java/androidx/test/ext/truth
runner-1.7.0/androidx/test/runner/AndroidJUnit4.java(L41-44) —@Deprecatedalias.junit-1.3.0/androidx/test/ext/junit/runners/AndroidJUnit4.java(L49-70) — canonical runner with Robolectric/Android delegate selection.monitor-1.8.0/androidx/test/platform/app/InstrumentationRegistry.java(L30-85) — canonical registry;getInstrumentation,getArguments,registerInstance.monitor-1.8.0/androidx/test/InstrumentationRegistry.java(L34) —@Deprecatedlegacy registry.core-1.7.0/androidx/test/core/app/ApplicationProvider.java(L29-43) — single-method facade returning the Application context.runner-1.7.0/androidx/test/filters/SmallTest.java(L44-46),MediumTest.java,LargeTest.java— size annotation definitions.runner-1.7.0/androidx/test/internal/runner/RunnerArgs.java(L54-99) — every-e <key> <value>argument the runner accepts, includingsize,annotation,class,package,numShards/shardIndex.testutils/testutils-ktx/src/jvmMain/kotlin/androidx/testutils/MainDispatcherRule.jvm.kt— canonical androidxMainDispatcherRulesource, identical pattern shipped here.- Cross-set:
../../../kotlin/kotlin-test/writing-tests-with-kotlin-test/SKILL.md— the framework-agnostickotlin.testassertions (assertEquals,assertFailsWith,@BeforeTest) used inside these JUnit4 tests; also the only assertion API available incommonTest.