| name | android-screenshot-testing |
| description | Use when writing Android integration tests that render real UI pages and capture screenshots on JVM without emulators. Also covers asserting on rendered content (text, visibility, i18n sweeps) alongside the captured PNG. Use when needing visual verification of Android screens, testing full UI rendering pipelines, or setting up Robolectric-based screenshot infrastructure for any Android project. Triggers on "screenshot test", "render page test", "Robolectric screenshot", "visual test without emulator", "integration test with screenshot", "Android JVM screenshot", "assert text in screenshot test", "verify rendered text". |
Android Screenshot Testing on JVM
Overview
Render real Android pages and capture pixel-accurate screenshots entirely on JVM โ no emulator, no device. Uses Robolectric (native graphics) + MockWebServer to exercise the full code stack (Fragment/Activity โ ViewModel โ Repository โ Retrofit โ OkHttp) while only faking HTTP responses at the network boundary.
Core principle: Mock at the lowest possible layer. All your Kotlin/Java code runs for real. Only the bytes coming back from the wire are fake.
Why This Approach
The problem with traditional Android UI testing
| Approach | Drawback |
|---|
| Instrumented tests (androidTest) | Requires emulator/device, slow CI, flaky |
| Unit tests with mocked ViewModels | Doesn't test real data flow, misses integration bugs |
| Manual QA screenshots | Not repeatable, not in CI |
| Compose Preview screenshots | Only works for Compose, not Views |
What this technique gives you
- Runs in
./gradlew test โ no emulator, 10-15 seconds, works in any CI
- Full vertical integration โ Fragment observes real LiveData/StateFlow, ViewModel calls real Repo, Repo serializes real data, OkHttp sends real HTTP to MockWebServer
- Visual regression baseline โ PNG output can be diffed across commits
- Catches real bugs โ adapter binding logic, data transformation chains, visibility conditions all execute for real
Architecture
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Your Test โ
โ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Test Data โ โ MockWebServer โ โ
โ โ (JSON / โ โ (returns test HTTP โ โ
โ โ protobuf) โ โ responses by path) โ โ
โ โโโโโโโฌโโโโโโโ โโโโโโโโโโโโฒโโโโโโโโโโโโโโ โ
โ โ โ โ
โ ALL REAL CODE BELOW THIS LINE โ
โ โโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Fragment โ ViewModel โ Repository โ โ
โ โ โ Retrofit โ OkHttp โโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ View.draw(Canvas) โ PNG file โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Quick Reference
Dependencies (app/build.gradle)
testImplementation 'org.robolectric:robolectric:4.12.2'
testImplementation 'androidx.test:core:1.6.1'
testImplementation 'androidx.test.ext:junit:1.2.1'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
android {
testOptions {
unitTests { includeAndroidResources = true }
}
}
Test class annotations
@RunWith(RobolectricTestRunner::class)
@Config(
sdk = [34],
application = TestApp::class, // skip third-party SDK init
qualifiers = "w360dp-h780dp-xxhdpi" // real phone density
)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
Minimal test structure
class MyPageScreenshotTest : BaseScreenshotTest() {
override fun onDispatch(path: String, request: RecordedRequest) = when {
path.contains("/api/my-endpoint") -> jsonResponse(buildTestData())
else -> null
}
@Test
fun renderMyPage() = launchAndCapture("my_page") { scenario ->
navigateTo(scenario, R.id.target_destination)
}
}
Five Problems You Must Solve
Every Android screenshot test on JVM hits these five walls. Solve them once in a base class.
1. Third-party SDKs crash on JVM
Firebase, analytics SDKs, crash reporters, and similar libraries use native code or system services unavailable on JVM.
Solution: Create a TestApp extending your Application class that catches init errors:
open class MyApp : Application() { ... }
class TestApp : MyApp() {
override fun onCreate() {
try { super.onCreate() }
catch (_: Throwable) { }
}
}
2. AndroidKeyStore unavailable on JVM
EncryptedSharedPreferences โ MasterKey โ AndroidKeyStore โ this is a hardware-backed system service with no JVM equivalent.
Solution: Add a test bypass flag so encrypted storage falls back to plain SharedPreferences:
object SecureStorage {
@VisibleForTesting var disableEncryption = false
fun getPrefs(name: String, encrypted: Boolean = false): SharedPreferences {
if (encrypted && !disableEncryption)
return getEncryptedSharedPreferences(name)
return context.getSharedPreferences(name, MODE_PRIVATE)
}
}
3. Endpoint URLs point to real servers
Repositories use Retrofit services initialized with production URLs. Must redirect to MockWebServer before first access.
Solution: Add a baseUrlOverride to your endpoint configuration:
object ApiConfig {
@VisibleForTesting var baseUrlOverride: String? = null
val baseUrl: String
get() = baseUrlOverride ?: BuildConfig.API_BASE_URL
}
Then in test setup: ApiConfig.baseUrlOverride = mockWebServer.url("/").toString()
4. Singleton lifecycle ordering
Activity lifecycle callbacks often reset global state. If you set state before launching the Activity, it gets wiped.
Solution: Always set app state AFTER Activity creation + first idle:
val scenario = ActivityScenario.launch(MainActivity::class.java)
idleSafe()
AppState.currentUser = testUser
AppState.selectedConfig = testConfig
Standalone Activity pattern: For Activities launched directly (not via fragment navigation in MainActivity), onCreate may immediately kick off coroutines that read singleton state. If onCreate resets state (e.g., refreshes stock locations from a native library unavailable on JVM), those coroutines see stale/empty data. Fix by re-triggering the ViewModel after correcting state:
val scenario = ActivityScenario.launch(CheckoutActivity::class.java)
idleSafe()
Cart.items.forEach { it.stockLocation = DELIVERY_STORE }
scenario.onActivity { activity ->
val vm = ViewModelProvider(activity)[CheckoutViewModel::class.java]
vm.buildDisplayItems(true)
}
repeat(20) { idleSafe(); Thread.sleep(200) }
5. Views depend on user authentication state
Many views check authentication status and render differently (or skip rendering) for unauthenticated users.
Solution: Set up a fake authenticated user before capture:
AuthManager.setTestUser(TestUser(
token = "test-token",
expiresAt = "2099-12-31T23:59:59Z",
userId = "TEST001"
))
Base Class Design
Extract all five solutions plus screenshot capture into a reusable base class:
BaseScreenshotTest
โโโ @Before: MockWebServer + endpoint redirect
โโโ @After: cleanup singletons + shutdown server
โโโ initAppState(): auth, config, any global providers
โโโ launchAndCapture(name) { lambda }: full lifecycle orchestration
โโโ Subclass hooks:
โ โโโ onDispatch(path, request): MockResponse? โ page-specific APIs
โ โโโ buildTestData(): Any โ test fixtures
โ โโโ configureAppState() โ optional overrides
โโโ Response helpers:
โ โโโ jsonResponse(obj): MockResponse
โ โโโ protobufResponse(msg): MockResponse
โ โโโ imageResponse(bytes): MockResponse
โ โโโ generateTestImage(label, color): ByteArray
โโโ Navigation helpers:
โ โโโ navigateTo(scenario, destinationId)
โ โโโ idleSafe()
โโโ Reflection utilities:
โ โโโ setPrivateField, setAtomicBoolean, etc.
โโโ Screenshot capture:
โโโ captureScreenshot(scenario, name) โ single viewport
โโโ captureScrollingScreenshots(scenario, rvId, name) โ per-viewport scroll
โโโ forceSoftwareRendering(view)
โโโ saveScreenshot(bitmap, name)
Subclasses typically need only 50-90 lines of page-specific code.
Screenshot Capture Details
fun forceSoftwareRendering(view: View) {
view.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
view.setWillNotDraw(false)
if (view is ViewGroup) {
for (i in 0 until view.childCount)
forceSoftwareRendering(view.getChildAt(i))
}
}
val rootView = activity.window.decorView.rootView
val bitmap = Bitmap.createBitmap(rootView.width, rootView.height, Bitmap.Config.ARGB_8888)
rootView.draw(Canvas(bitmap))
bitmap.compress(Bitmap.CompressFormat.PNG, 100, FileOutputStream(file))
Capture strategies
Single viewport โ use captureScreenshot(scenario, name) for pages that fit in one screen.
Scrolling pages โ use captureScrollingScreenshots(scenario, rvId, name) to scroll a RecyclerView one viewport at a time, saving each as {name}_1.png, {name}_2.png, etc.
fun <T : Activity> captureScrollingScreenshots(
scenario: ActivityScenario<T>,
scrollableViewId: Int,
screenshotName: String
) {
scenario.onActivity { activity ->
val rootView = activity.window.decorView.rootView
forceSoftwareRendering(rootView)
activity.findViewById<RecyclerView?>(scrollableViewId)?.scrollToPosition(0)
}
idleSafe()
var pageIndex = 1
var canScroll = true
while (canScroll) {
scenario.onActivity { activity ->
val rootView = activity.window.decorView.rootView
val rv = activity.findViewById<RecyclerView>(scrollableViewId)
val bitmap = Bitmap.createBitmap(rootView.width, rootView.height, Bitmap.Config.ARGB_8888)
rootView.draw(Canvas(bitmap))
saveScreenshot(bitmap, "${screenshotName}_$pageIndex")
bitmap.recycle()
if (rv.canScrollVertically(1)) rv.scrollBy(0, rv.height) else canScroll = false
}
idleSafe()
pageIndex++
}
}
Why per-viewport, not scroll-stitch: Stitching strips into one tall image produces a compressed, hard-to-read PNG. Per-viewport captures are actual phone-sized screenshots โ readable at a glance and easy to review in conversation or CI.
Multi-state capture
Capture the same page in different UI states within one test. Change the state, rebind, capture again with a different name:
captureScrollingScreenshots(scenario, R.id.recyclerView, "checkout_collapsed")
scenario.onActivity { activity ->
val vm = ViewModelProvider(activity)[MyViewModel::class.java]
vm.items.value?.forEach { it.uiState.sectionExpanded = true }
activity.findViewById<RecyclerView>(R.id.recyclerView).adapter?.notifyDataSetChanged()
}
repeat(5) { idleSafe(); Thread.sleep(100) }
captureScrollingScreenshots(scenario, R.id.recyclerView, "checkout_expanded")
Why xxhdpi qualifier matters
Without it, Robolectric defaults to mdpi (1x). A 1080px-wide canvas at mdpi = 1080dp โ a giant tablet. At xxhdpi (3x), 1080px = 360dp โ a real phone. Text, spacing, and layouts all render at correct proportions.
Asserting on Rendered Content
A PNG alone is a weak signal. A wrong string, an unsubstituted @string/foo placeholder, or text drawn in the wrong color can all slip through casual review. Text-level assertions complement the screenshot by failing the build the moment the rendered tree contains the wrong content โ and they double as inline documentation of what the screen should say.
Three approaches, in order of preference:
1. Direct binding access (preferred for targeted checks)
You already hold the inflated view binding (or can findViewById on the activity). Read text and visibility directly:
@Test
fun renderTakeBeforeSlotRowWithHintIcon() {
val controller = Robolectric.buildActivity(HostActivity::class.java)
.create().start().resume().visible()
val activity = controller.get()
val binding = activity.binding
assertEquals(View.VISIBLE, binding.takeBeforeSlotQuestionMark.visibility)
assertEquals(
"ๆๅฎๆ้ใใๅใฎๅใๅใ OK",
binding.takeBeforeSlotCheckText.text.toString()
)
assertFalse(
binding.takeBeforeSlotCheckText.text.contains("@string/"),
"untranslated string placeholder rendered"
)
saveScreenshot(activity, "checkout_slot_dialog_with_hint")
controller.pause().stop().destroy()
}
Use this when you know which view/ID to check โ fast, exact, no extra dependencies.
2. View-tree walk (preferred for sweeps across the page)
When you don't want to hard-code every TextView โ e.g., "no untranslated strings anywhere on this page", "no debug stubs", "every required label is present" โ walk the tree and collect:
fun collectTextViews(view: View, out: MutableList<TextView> = mutableListOf()): List<TextView> {
if (view is TextView) out += view
if (view is ViewGroup) {
for (i in 0 until view.childCount) collectTextViews(view.getChildAt(i), out)
}
return out
}
@Test
fun noUntranslatedStringsOnCheckout() = launchAndCapture("checkout") { scenario ->
scenario.onActivity { activity ->
val texts = collectTextViews(activity.window.decorView.rootView)
.map { it.text.toString() }
.filter { it.isNotBlank() }
texts.forEach { text ->
assertFalse(text.startsWith("@string/")) { "untranslated: $text" }
assertFalse(text.contains("TODO:")) { "stub left in: $text" }
assertFalse(text.contains("Lorem ipsum")) { "placeholder copy: $text" }
}
assertTrue(texts.any { it.contains("้
้ใชใใทใงใณ") }) {
"expected ้
้ใชใใทใงใณ section header"
}
}
}
The same walk works for collecting all ImageViews (verify icons load), all Buttons (verify enabled state), or any view subclass.
3. Espresso onView(withText(...)) (only if already in the project)
Espresso runs in Robolectric and reads naturally:
testImplementation 'androidx.test.espresso:espresso-core:3.7.0'
onView(withText("ๆๅฎๆ้ใใๅใฎๅใๅใ OK")).check(matches(isDisplayed()))
onView(withId(R.id.takeBeforeSlotQuestionMark)).check(matches(isDisplayed()))
It adds a dependency and is more ceremony than a tree walk for what's really the same operation. Prefer it only if Espresso is already pulled in for instrumented tests.
When to assert vs. screenshot only
| Signal | Screenshot | Assertion |
|---|
| Layout, spacing, alignment | โ | โ (hard to express) |
| Specific copy / labels | weak | โ |
| i18n / translation completeness | weak | โ (tree-walk + @string/ check) |
| Visibility of icons / badges | โ | โ (assert View.VISIBLE) |
| Color / theming | โ | โ (brittle in code) |
| State after interaction | โ | โ |
Pair them: assertions fail loudly with a clear message in CI; the screenshot gives reviewers something to inspect when something else regresses. A test that only saves a PNG and never asserts is a test that depends on a human noticing the diff.
Asserting alongside scrolling captures
When you also use captureScrollingScreenshots, run the assertions on the activity before scrolling โ the tree mutates as views recycle, and a TextView you saw at scroll position 0 may be detached by the time you reach the bottom:
scenario.onActivity { activity ->
val texts = collectTextViews(activity.window.decorView.rootView).map { it.text.toString() }
assertTrue(texts.any { it == "็ขบๅฎ" })
}
captureScrollingScreenshots(scenario, R.id.recyclerView, "checkout")
For content that only appears after scrolling, capture first, then drive the RecyclerView back to the relevant position and assert.
Common Mistakes
| Mistake | Fix |
|---|
| Set app state before Activity launch | Lifecycle callbacks reset it. Set AFTER idleSafe() |
| Forget to set authenticated user | Views skip rendering for anonymous users |
Use manual measure/layout for screenshot | Breaks RecyclerView. Use Activity's natural dimensions |
Only call submitList without forcing sync | DiffUtil is async, may not complete in Robolectric. Call notifyDataSetChanged or idle the looper |
| Expect real image decoding | Robolectric shows "Failed to create image decoder". Use generateTestImage() for colored placeholders |
Wait with Thread.sleep only | Must also call Shadows.shadowOf(Looper.getMainLooper()).idle() to process messages |
Missing @GraphicsMode(NATIVE) | Without native graphics mode, views render as empty bitmaps |
| Standalone Activity has empty adapter | onCreate may reset state + trigger coroutines before you fix it. Re-trigger ViewModel after correcting state |
| Stitch screenshots into one tall image | Produces compressed, unreadable PNGs. Use per-viewport capture instead |
| Test only saves a PNG, no assertions | Wrong text and untranslated @string/ placeholders pass silently. Pair captures with text/visibility assertions |
| Assert against text on a recycled view | RecyclerView detaches off-screen TextViews. Capture first, then assert against the live tree before scrolling |
Running
./gradlew :app:testDebugUnitTest --tests "com.example.MyPageScreenshotTest"
./gradlew :app:testDebugUnitTest --tests "com.example.*ScreenshotTest"
build/test-results/screenshots/my_page.png
open build/test-results/screenshots/my_page.png
Tips for Scaling
- Naming convention: Suffix all screenshot tests with
ScreenshotTest so they can be run as a group
- Output directory: Save PNGs to a consistent directory (e.g.,
build/test-results/screenshots/) for easy CI artifact collection
- Visual diffing: Compare PNGs across commits using tools like
pixelmatch, reg-suit, or simple diff on file hashes
- Multiple states: Capture the same page in different states (empty, loading, error, populated) by varying MockWebServer responses
- UI state toggles: Capture collapsed/expanded, selected/unselected states in a single test by mutating ViewModel data +
notifyDataSetChanged() between captures
- Screen sizes: Run the same test with different
@Config(qualifiers = ...) to test tablet/phone layouts
- Pair captures with assertions: Every screenshot test should also assert at least one piece of expected text or visibility. Screenshots catch layout regressions; assertions catch wrong text, missing translations, and silent content drift