AI-driven visual validation, multimodal UI verification, and hybrid golden fallback for the Google Maps SDK on Android.
Screenshot and visual testing of map applications has traditionally presented unique challenges:
- Dynamic Cartography: Map rendering includes real-time elements, label placement shifts, vector antialiasing differences across GPU drivers, and platform-dependent text rendering.
- Brittle Exact Pixel Diffing: Even a 1-pixel shift in a road label or slight shading variation can fail a rigid pixel-by-pixel golden comparison, causing test flakiness.
- Intent-Based Visual Assertions: Traditional testing frameworks struggle to assert natural language requirements like "Verify that the map is centered on Paris and the custom marker info window is visible without being obscured by the bottom sheet."
:visual-testing addresses these challenges by combining Google Gemini Multimodal AI with Android UiAutomator and :golden-testing.
Map Verification Strategy
|
+-------------------+-------------------+
| |
Deterministic Pixel Diff Semantic AI Evaluation
[:golden-testing] [:visual-testing]
| |
Strict baseline check Natural language assertions
Zero network, zero quota Tolerates GPU / tile variations
\ /
\ /
v v
+---------------------------------------+
| verifyScreenshotWithGoldenFallback |
| 1. Try fast pixel diff (< tolerance) |
| 2. If pixel diff fails, ask Gemini |
| 3. If Gemini approves, update golden |
+---------------------------------------+
- Dynamic Flash Model Resolution: Automatically queries the Gemini API for the latest, stable Flash model (e.g.
gemini-3.8-flash,gemini-2.5-flash) and caches the resolved model for the entire testing session, avoiding hardcoded or stale model names. - Hybrid Golden Fallback (
verifyScreenshotWithGoldenFallback):- Runs a fast local pixel comparison first using
:golden-testing'sGoldenImageDiff. - If pixel diff matches within tolerance, the test passes with zero network latency and zero API quota.
- If pixel diff fails, it seamlessly falls back to Gemini Multimodal AI to verify semantic correctness and visual integrity.
- Optionally auto-updates baseline golden images when Gemini confirms the change is valid.
- Runs a fast local pixel comparison first using
- Multimodal Visual Inspection: Send captured UI or map screenshots directly to Gemini with natural-language verification prompts (
analyzeImage/analyzeImageBlocking). - AI-Driven UI Automation (
performActionFromPrompt): Generates and executes UiAutomator actions (click, scroll, type, wait) derived directly from the current view hierarchy and screenshot. - Smart API Key Discovery: Hierarchical resolution checks:
- Instrumentation test runner arguments (
gemini_api_key) - Java system properties (
gemini.api.key/GEMINI_API_KEY) - Environment variables (
GEMINI_API_KEY) - Application
BuildConfigfields via reflection
- Instrumentation test runner arguments (
- Modern Kotlin & Resilience: Built on
kotlinx.serializationand Ktor HTTP client with built-in retry and exponential backoff for rate limits (HTTP 429/503). Target bytecode is strictly Java 11.
Add :visual-testing to your gradle/libs.versions.toml:
[versions]
mapsTesting = "1.0.0-rc01"
[libraries]
maps-testing-visual = { module = "com.google.maps.android:maps-testing-visual", version.ref = "mapsTesting" }Then add it to your module's build.gradle.kts:
dependencies {
androidTestImplementation(libs.maps.testing.visual)
}Without Version Catalog (Direct Coordinates)
dependencies {
androidTestImplementation("com.google.maps.android:maps-testing-visual:1.0.0-rc01")
}Provide your Gemini API key via Gradle test options or environment variables:
# In CI or local terminal:
export GEMINI_API_KEY="your-gemini-api-key"
# Or via connected Android test runner argument:
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.GEMINI_API_KEY="your-gemini-api-key"// [START maps_visual_test_sample]
/**
* Sample instrumented visual test demonstrating AI-powered visual verification
* and hybrid golden testing with Gemini.
*/
@RunWith(AndroidJUnit4::class)
class VisualMapSampleTest : BaseVisualTest() {
@Test
fun verifyStoreLocatorMapRendering() = runBlocking {
// 1. Launch MainActivity
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
context.startActivity(intent)
// 2. Wait for application window to settle
uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
waitForMapRendering(timeoutSeconds = 5)
// 3. Capture screenshot of current map state
val screenshotBitmap = captureScreenshot("store_locator_screenshot.png")
// 4. Natural language prompt specifying expected visual elements
val prompt = """
Please act as a UI tester and analyze this screenshot of an Android application.
Verify the following criteria:
1. Confirm that the application displays a Google Map surface with the "Store Locator" header.
2. Confirm that store location pins or map tiles are visible on the screen.
3. Confirm that the application interface has rendered cleanly without error banners, blank crash dialogs, or ANRs.
If all criteria are met, reply with "PASSED".
If any criteria is not met, please detail the discrepancy.
""".trimIndent()
// 5. Hybrid verification: check local golden baseline first, fallback to Gemini Vision
verifyScreenshotWithGoldenFallback(
testName = "store_locator_initial_state",
screenshotBitmap = screenshotBitmap,
prompt = prompt,
passCondition = { it.contains("PASSED", ignoreCase = true) },
)
}
}
// [END maps_visual_test_sample]verifyScreenshotWithGoldenFallback in BaseVisualTest provides fast, zero-quota CI execution for unchanged baselines, with AI-powered resilience when visual changes occur:
- First Run (Local Recording): On your first run, no golden PNG exists yet. Gemini Vision evaluates the screenshot against your natural-language
prompt, and if it passes, saves<testName>.pngon the device and prints the exactadb pullcommand in Logcat. - Commit Baseline to Git: Pull the verified golden PNG into your test assets directory:
adb pull /sdcard/Android/data/<your.package>/files/goldens/<testName>.png app/src/androidTest/assets/goldens/<testName>.png
- Zero-Quota CI Runs: On every subsequent emulator or CI run,
BaseVisualTestautomatically loadsassets/goldens/<testName>.pngfrom the test APK and compares pixels locally viaGoldenImageDiff. As long as the screen matches withintolerancePercent, the test passes in milliseconds with zero network calls and zero Gemini API quota!
// [START maps_visual_golden_fallback]
suspend fun verifyScreenshotWithGoldenFallback(
testName: String,
screenshotBitmap: Bitmap,
prompt: String,
passCondition: (String) -> Boolean = { it.contains("PASSED", ignoreCase = true) },
goldensDirectory: File = File(context.getExternalFilesDir(null) ?: context.filesDir, "goldens"),
tolerancePercent: Double = 0.5,
updateGoldenOnPass: Boolean = true,
) {
val goldenFile = File(goldensDirectory, "$testName.png")
val goldenBitmap = loadGoldenBaseline(testName, goldenFile)
if (goldenBitmap != null) {
val diffResult = GoldenImageDiff.compare(
expected = goldenBitmap,
actual = screenshotBitmap,
maxDiffPercentage = tolerancePercent / 100.0,
)
if (diffResult.matches) {
val pct = diffResult.diffPercentage * 100.0
Log.i("BaseVisualTest", "Screenshot matched golden baseline perfectly for '$testName' (diff: $pct%). Fast pass!")
return
}
val pct = diffResult.diffPercentage * 100.0
Log.w("BaseVisualTest", "Screenshot diverged from golden for '$testName' (diff: $pct%). Falling back to Gemini Vision.")
} else {
Log.i("BaseVisualTest", "No existing golden baseline found for '$testName'. Consulting Gemini Vision.")
}
// Fallback to Gemini
val apiKey = geminiApiKey
if (apiKey.isNullOrBlank()) {
assumeTrue(
"Skipping Gemini visual verification for '$testName': GEMINI_API_KEY was not found. " +
"Provide GEMINI_API_KEY via secrets.properties, instrumentation arguments, or environment variable.",
false,
)
return
}
val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, apiKey)
Log.i("BaseVisualTest", "Gemini response for '$testName':\n$geminiResponse")
assertTrue("Gemini returned a null response for '$testName'", geminiResponse != null)
val passed = passCondition(geminiResponse!!)
assertTrue(
"Gemini visual verification failed for '$testName'.\nPrompt:\n$prompt\n\nGemini Response:\n$geminiResponse",
passed,
)
if (updateGoldenOnPass) {
try {
goldensDirectory.mkdirs()
FileOutputStream(goldenFile).use { out ->
screenshotBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
Log.w("BaseVisualTest", "Saved new golden baseline for '$testName' at: ${goldenFile.absolutePath}")
Log.i(
"BaseVisualTest",
"To commit this golden image to your repository for fast zero-quota CI runs, run:\n" +
"adb pull ${goldenFile.absolutePath} app/src/androidTest/assets/goldens/$testName.png",
)
} catch (e: Exception) {
Log.e("BaseVisualTest", "Failed to save updated golden image for '$testName'", e)
}
}
}
// [END maps_visual_golden_fallback]By default, :visual-testing queries https://generativelanguage.googleapis.com/v1beta/models on first run to find the latest stable flash model and caches it across all test cases.
You can explicitly force a specific model if desired:
// In test code:
visualTestHelper.preferredModel = "gemini-3.8-flash"
// Or via system property / Gradle flag:
// -Dgemini.model=gemini-2.5-proCopyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.