From dd2684b3249292bf737d8711bcbe04ce9ea539ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 18 Sep 2026 19:37:47 +0200 Subject: [PATCH] test: add end-to-end smoke tests covering every demo activity Reviewing a pull request currently means cloning it and clicking through the sample app by hand to confirm nothing regressed. These two tests do that walk automatically. DemoAppSmokeTest is parameterized over allActivityGroups, the same registry that builds the on-screen demo list, so a demo added to the app is covered with no change to the test. Each demo is launched and checked for three things: it reaches RESUMED, a MapView or StreetViewPanoramaView is attached and laid out, and nothing crashes on a background thread. It then recreates the activity and checks again, which is where camera and marker state holders tend to regress. DemoRegistryTest guards the registry itself. A demo is described both in allActivityGroups and in AndroidManifest.xml; adding it to one and not the other builds cleanly and only fails when someone taps the entry. Map content is deliberately not asserted. Verifying that a particular marker or overlay is drawn belongs in the focused tests alongside these; the value here is breadth. --- .../maps/android/compose/DemoAppSmokeTest.kt | 189 ++++++++++++++++++ .../maps/android/compose/DemoRegistryTest.kt | 86 ++++++++ 2 files changed, 275 insertions(+) create mode 100644 maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt create mode 100644 maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt new file mode 100644 index 00000000..af196156 --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright 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. + */ + +package com.google.maps.android.compose + +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.util.concurrent.CopyOnWriteArrayList + +/** + * End-to-end smoke test covering every demo in the sample app. + * + * The parameter list is derived from [allActivityGroups], the same registry that builds the + * demo list on screen, so a demo added to the app is covered here automatically with no + * change to this file. That is the point of the test: it is a standing guarantee that every + * screen a reviewer would otherwise open by hand still launches, renders a map and survives a + * configuration change. + * + * Each demo is checked for three things: + * - it reaches [Lifecycle.State.RESUMED] without throwing, + * - a map surface ([MapView] or [StreetViewPanoramaView]) is attached and laid out, + * - nothing crashes on a background thread while it is open. + * + * This deliberately does not assert on map *content*. Verifying that a particular marker or + * overlay is drawn belongs in the focused tests next to this one; the value here is breadth. + */ +@RunWith(Parameterized::class) +class DemoAppSmokeTest( + private val demoName: String, + private val demoActivity: Class, +) { + + companion object { + /** + * How long to wait for a demo's map surface to be attached and laid out. Generous + * because the first demo to run on a cold emulator pays for Maps SDK initialisation. + */ + private const val MAP_SURFACE_TIMEOUT_MS = 20_000L + + private const val POLL_INTERVAL_MS = 250L + + /** + * Demos that legitimately show no map surface of their own. Keep this empty unless a + * demo really is map-free; an entry here is a hole in the coverage, not a fix. + */ + private val DEMOS_WITHOUT_MAP_SURFACE = emptySet() + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun demos(): List> = + allActivityGroups + .flatMap { group -> group.activities } + .map { activity -> + @Suppress("UNCHECKED_CAST") + arrayOf( + activity.kClass.simpleName ?: activity.kClass.java.name, + activity.kClass.java as Class, + ) + } + } + + private val uncaughtExceptions = CopyOnWriteArrayList() + private var defaultHandler: Thread.UncaughtExceptionHandler? = null + + @Before + fun setUp() { + // Without a key the Maps SDK renders an empty grid, so every map assertion below would + // be meaningless. Skip rather than fail: forks run CI without access to the secret. + assumeTrue("Maps API key not specified", hasValidApiKey) + + defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + uncaughtExceptions.add(throwable) + } + } + + @After + fun tearDown() { + Thread.setDefaultUncaughtExceptionHandler(defaultHandler) + } + + @Test + fun demoLaunchesAndShowsMap() { + ActivityScenario.launch(demoActivity).use { scenario -> + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP_SURFACE) { + scenario.awaitMapSurface() + } + assertNoUncaughtExceptions() + } + } + + /** + * Rotation and process-level configuration changes are where camera and marker state + * holders tend to regress, and they are easy to miss when clicking through the app by hand. + */ + @Test + fun demoSurvivesConfigurationChange() { + ActivityScenario.launch(demoActivity).use { scenario -> + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP_SURFACE) { + scenario.awaitMapSurface() + } + + scenario.recreate() + + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP_SURFACE) { + scenario.awaitMapSurface() + } + assertNoUncaughtExceptions() + } + } + + private fun ActivityScenario.assertResumed() { + assertThat(state).isEqualTo(Lifecycle.State.RESUMED) + } + + /** + * Polls until the demo has a map surface that is attached, visible and non-zero sized. + * Polling rather than a single check because [MapView] is created from an `AndroidView` + * factory and laid out a frame or more after the activity resumes. + */ + private fun ActivityScenario.awaitMapSurface() { + val deadline = System.currentTimeMillis() + MAP_SURFACE_TIMEOUT_MS + var surfaces = emptyList() + + while (System.currentTimeMillis() < deadline) { + onActivity { activity -> + surfaces = activity.window.decorView.mapSurfaces() + } + if (surfaces.any { it.isShown && it.width > 0 && it.height > 0 }) return + Thread.sleep(POLL_INTERVAL_MS) + } + + val detail = if (surfaces.isEmpty()) { + "no MapView or StreetViewPanoramaView was found in the view hierarchy" + } else { + surfaces.joinToString(prefix = "found but not laid out: ") { surface -> + "${surface.javaClass.simpleName}" + + "(shown=${surface.isShown}, ${surface.width}x${surface.height})" + } + } + throw AssertionError( + "$demoName did not show a map within ${MAP_SURFACE_TIMEOUT_MS}ms: $detail", + ) + } + + private fun assertNoUncaughtExceptions() { + val failure = uncaughtExceptions.firstOrNull() ?: return + throw AssertionError("$demoName crashed on a background thread", failure) + } + + /** Depth-first walk collecting every Maps SDK surface below this view. */ + private fun View.mapSurfaces(): List = buildList { + fun walk(view: View) { + if (view is MapView || view is StreetViewPanoramaView) add(view) + if (view is ViewGroup) { + for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + } + walk(this@mapSurfaces) + } +} diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt new file mode 100644 index 00000000..ad943f91 --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright 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. + */ + +package com.google.maps.android.compose + +import android.content.ComponentName +import android.content.pm.PackageManager +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Guards the demo registry itself. + * + * A demo is described in two places that have to agree: [allActivityGroups], which builds the + * on-screen list, and `AndroidManifest.xml`, which declares the activity. Adding a demo to one + * and not the other builds cleanly and only fails when someone taps the entry, which is exactly + * the kind of breakage a reviewer has to catch by hand today. + */ +class DemoRegistryTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val demos = allActivityGroups.flatMap { group -> group.activities } + + @Test + fun everyDemoIsDeclaredInTheManifest() { + val undeclared = demos.filterNot { demo -> + val component = ComponentName(context, demo.kClass.java) + runCatching { + context.packageManager.getActivityInfo(component, 0) + }.isSuccess + } + + assertThat(undeclared.map { it.kClass.simpleName }).isEmpty() + } + + /** + * The demo list launches each entry with a bare [android.content.Intent], which only works + * for activities the system will start from the sample's own task. + */ + @Test + fun everyDemoIsLaunchable() { + val notLaunchable = demos.filterNot { demo -> + val component = ComponentName(context, demo.kClass.java) + val info = runCatching { + context.packageManager.getActivityInfo(component, PackageManager.MATCH_ALL) + }.getOrNull() + info != null && info.isEnabled + } + + assertThat(notLaunchable.map { it.kClass.simpleName }).isEmpty() + } + + @Test + fun everyDemoHasATitleAndDescription() { + val missingStrings = demos.filter { demo -> + context.getString(demo.title).isBlank() || context.getString(demo.description).isBlank() + } + + assertThat(missingStrings.map { it.kClass.simpleName }).isEmpty() + } + + @Test + fun noDemoIsRegisteredTwice() { + val duplicates = demos + .groupBy { it.kClass } + .filterValues { it.size > 1 } + .keys + .map { it.simpleName } + + assertThat(duplicates).isEmpty() + } +}