🤖 Unit 14 • Android Mobile Engineering

Testing & Debugging: Quality Assurance & Profiling

Engineering resilient, production-verified Android mobile applications: Mastering the Android Testing Pyramid, fast JVM unit testing with MockK & Coroutine TestScope, instrumented testing on ART runtimes, automated Jetpack Compose UI semantics testing, advanced Android Studio debugger breakpoints, Logcat query grammar, global crash handling, and CPU/Memory leak profiling with LeakCanary.

🧪 Testing: JUnit 5, MockK & Compose Rule
🐞 Debugging: Conditional Breakpoints & Logcat v2
📉 Profiling: Memory Heap Dumps & LeakCanary
💥 Crashes: UncaughtExceptionHandler & ANR Traces
01

The Android Testing Pyramid & Execution Environments

🏛️ The Three Layers of Enterprise Mobile Testing

Reliable mobile engineering relies on the Android Testing Pyramid, balancing execution velocity against hardware fidelity:

  • Unit Tests (~70% of total test suite): Test isolated business logic, domain algorithms, ViewModels, and data repositories in milliseconds. Located in the src/test/java/ directory, these run directly on the host development machine's Local Java Virtual Machine (JVM) without launching an Android emulator or physical device.
  • Integration & Instrumented Tests (~20%): Verify interactions between components, Room database SQLite queries, and Android framework interactions. Located in src/androidTest/java/, these compile into an APK and execute on the actual Android Runtime (ART) on a connected device or AVD.
  • End-to-End (E2E) UI Tests (~10%): Validate complete user flows across multiple screens (e.g. login, browsing a course, initiating a download). Powered by Compose Test Rules or Espresso, simulating real touch events, screen scrolls, and navigation transitions.
Dimension Local Unit Tests (src/test/) Instrumented Tests (src/androidTest/)
Execution Environment Host Developer Machine JVM (HotSpot / OpenJDK). Target Device / Emulator ART (Dalvik Executable bytecode).
Execution Speed Extremely Fast (~milliseconds per test). Slower (~seconds, requires APK packaging and ADB installation).
Android Framework Access Mocked or stubbed (requires MockK, Mockito, or Robolectric). Real Android OS (Real SQLite, Context, PackageManager, Sensors).
Primary Target Code ViewModels, UseCases, Repositories, Domain models, Utility functions. Room DAOs, Compose UI layouts, DataStore persistence, Services.
Gradle Execution Task ./gradlew testDebugUnitTest ./gradlew connectedDebugAndroidTest
Visual Architecture: The Android Testing Pyramid & Runtime Boundaries
E2E UI Tests (~10%) Compose Test Rule / Espresso / Device ART
▲ High Fidelity, Slow Execution
Integration Tests (~20%) Room SQLite / SharedPreferences / WorkManager / ART
▲ Component Boundary Verification
Local Unit Tests (~70%) ViewModels / Repositories / MockK / Host JVM (<10ms)
02

Unit Testing with MockK & Kotlin Coroutines TestScope

⚡ Testing Asynchronous Coroutines with runTest

Testing code that uses Kotlin Coroutines presents a unique challenge: coroutines execute asynchronously, while tests must execute deterministically without flaky Thread.sleep() calls. The kotlinx-coroutines-test library provides:

  • runTest { }: A coroutine builder designed specifically for unit tests. It provides virtual time control, automatically skipping delay() calls instantly while maintaining execution order!
  • StandardTestDispatcher: A test dispatcher with an internal virtual scheduler. Coroutines enqueued on it do not execute until explicitly advanced using advanceUntilIdle() or advanceTimeBy(millis).
  • UnconfinedTestDispatcher: Eagerly executes new coroutines immediately on the current thread, making it ideal for simple StateFlow collection testing.
🎭 Mocking Dependencies with MockK

MockK is the premier mocking library built from first principles for Kotlin. Unlike Java-centric Mockito, MockK natively supports:

  • Kotlin Coroutine suspending functions via coEvery { } and coVerify { }.
  • Kotlin language primitives (default arguments, extension functions, object declarations).
  • Relaxed mocks (mockk<T>(relaxed = true)) that automatically return sensible dummy values for unspecified methods.
CourseViewModelTest.kt Kotlin / JUnit 5
class CourseViewModelTest {

    // Rule/Extension to redirect Dispatchers.Main to TestDispatcher during JVM tests
    private val testDispatcher = StandardTestDispatcher()
    private val repository: CourseRepository = mockk()
    private lateinit var viewModel: CourseViewModel

    @BeforeEach
    fun setUp() {
        Dispatchers.setMain(testDispatcher)
        viewModel = CourseViewModel(repository)
    }

    @AfterEach
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `loadCourses emits Success state when repository returns data`() = runTest(testDispatcher) {
        // GIVEN: Mock repository returns dummy course list
        val mockCourses = listOf(
            Course(id = "CS101", title = "Android Kotlin", enrolled = 250)
        )
        coEvery { repository.fetchCourses() } returns mockCourses

        // WHEN: Triggering data load
        viewModel.loadCourses()

        // Advance virtual time until all asynchronous coroutine jobs complete
        advanceUntilIdle()

        // THEN: Assert that UI State transitioned to Success
        val currentState = viewModel.uiState.value
        assertTrue(currentState is UiState.Success)
        assertEquals(1, (currentState as UiState.Success).courses.size)
        coVerify(exactly = 1) { repository.fetchCourses() }
    }
}
03

Automated UI Testing in Jetpack Compose

🖥️ The Compose Semantics Tree Architecture

In traditional XML layouts, UI testing relied on View IDs (e.g. R.id.submit_button) and Espresso matchers. In declarative Jetpack Compose, widgets do not have traditional View IDs. Instead, Compose generates a parallel Semantics Tree alongside the UI rendering tree:

  • Semantics Nodes: Capture the semantic meaning of UI elements (e.g. text content, button click actions, toggle state, accessibility labels).
  • Finders: Locate specific nodes in the tree using semantic properties: onNodeWithText("Enroll Now"), onNodeWithTag("course_card_CS101"), onNodeWithContentDescription("Profile").
  • Actions: Simulate user physical gestures: performClick(), performTextInput("Android Dev"), performScrollTo().
  • Assertions: Verify visual conditions and state: assertIsDisplayed(), assertIsEnabled(), assertTextEquals("Success").

Automatic Synchronization: Compose Test Rules automatically synchronize with Compose's internal clock and recomposition engine. You never need manual sleep delays—the test runner automatically waits until all animations and snapshot recompositions finish before executing the next assertion!

Visual Architecture: Compose UI Semantics Tree Automation Pipeline
Compose Screen Rendered Semantics Tree Populated
➔
Node Finder Query onNodeWithTag("btn_enroll")
⬇ Node Found in Semantics Tree
performClick() Action Dispatches touch gesture
➔
Automatic Sync Wait Awaits recomposition to settle
⬇ UI Recomposed with New State
assertIsDisplayed() Validates dialog / confirmation UI
04

Android Studio Debugging: Breakpoints & Logcat v2

🔍 Advanced Breakpoint Types in Android Studio

Professional debugging goes far beyond placing basic pause points on lines of code. Android Studio provides four specialized breakpoint mechanisms:

  • Line Breakpoint: Pauses execution when the instruction pointer hits a designated line.
  • Conditional Breakpoint: Pauses execution only when a declared boolean expression evaluates to true (e.g. userId == "404" || items.size > 1000). Avoids tedious manual stepping through loops.
  • Logging Breakpoint (Non-Suspending): Evaluates an expression and prints the output directly to the Debug console without halting thread execution. Perfect for tracing state mutations in high-frequency animations where pausing would break timing.
  • Exception Breakpoint: Pauses execution the exact instant a specific exception (e.g. NullPointerException or IndexOutOfBoundsException) is thrown, allowing inspection of the exact call stack before stack unwinding occurs.
📜 Logcat v2 Query Syntax & Severity Levels

Android Studio's Logcat v2 features an expressive key-value search grammar that filters noise across the millions of system logs generated by the Android OS:

Logcat_v2_Query_Reference.txt Logcat Filter Grammar
# Filter by current running app process
package:mine

# Filter by logging severity level (Error, Warning, Info, Debug, Verbose)
level:ERROR
level:WARN

# Filter by custom tag and message text
tag:DevAscentSync "Database migration"

# Exclude noisy system tags using negation (-)
package:mine -tag:ViewRootImpl -tag:SurfaceView

# Filter by specific thread name or message age
thread:main age:2m
05

Crash Handling, ANR Traces & Performance Profiling

💥 Global Crash Interception & ANR Traces

Uncaught exceptions crash the ART process with a fatal SIGABRT. Enterprise applications intercept these crashes to save unsaved local user drafts and transmit diagnostic payloads to telemetry servers (e.g. Firebase Crashlytics) before process death:

GlobalCrashHandler.kt Kotlin
object GlobalCrashHandler {
    fun init() {
        val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()

        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
            // 1. Write crash breadcrumb to private crash log file
            CrashLogger.recordFatalException(thread.name, throwable)

            // 2. Delegate to Android default handler to preserve standard OS crash behavior
            defaultHandler?.uncaughtException(thread, throwable)
        }
    }
}

Diagnosing ANR Traces: When the Android OS generates an Application Not Responding dialog, it dumps the exact stack trace of every active thread to a system file located at /data/anr/traces.txt. Pull this file via ADB (adb bugreport or adb pull /data/anr/traces.txt) to discover which lock or I/O operation blocked the main thread's Looper!

📉 Android Studio Profiler & LeakCanary Memory Auditing

The Android Studio Profiler provides real-time diagnostic instruments:

  • CPU Profiler (Traceview & System Trace): Records method-level execution timing. Visualizes flame charts and call trees to detect slow algorithms that exceed the 16.6ms frame rendering threshold.
  • Memory Profiler & Heap Dump: Captures an instant snapshot of all Java/Kotlin objects residing in the ART heap. Differentiates Shallow Size (memory consumed by the object itself) from Retained Size (memory freed if the object and its references are garbage collected).
  • LeakCanary: An open-source memory leak detection library for Android. LeakCanary automatically hooks into the Activity and Fragment lifecycles. When an Activity finishes, it waits 5 seconds and verifies whether the instance was garbage collected. If still retained in RAM, it dumps the heap and renders the exact reference chain (leak trace) leading from a GC Root (e.g. static variable, Singleton, or uncancelled Coroutine) to the destroyed Activity!
06

Production Capstone: Complete Compose UI & ViewModel Test Suite

The complete automated UI test below demonstrates testing a Jetpack Compose screen using createComposeRule(): verifying initial loading indicators, interacting with buttons via semantics finders, and asserting that the resulting list displays correct content.

CourseScreenComposeTest.kt Kotlin / Compose Testing
package me.devascent.academy.ui

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test

class CourseScreenComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun loadingState_showsCircularProgressIndicator() {
        // GIVEN: Screen rendered in Loading state
        composeTestRule.setContent {
            CourseCatalogContent(
                uiState = CourseUiState.Loading,
                onRefreshClick = {},
                onEnrollClick = {}
            )
        }

        // THEN: Progress bar is visible, course list does not exist
        composeTestRule
            .onNodeWithTag("loading_indicator")
            .assertIsDisplayed()

        composeTestRule
            .onNodeWithTag("course_list")
            .assertDoesNotExist()
    }

    @Test
    fun successState_rendersCourses_andHandlesEnrollClick() {
        var clickedCourseId = ""

        val testCourses = listOf(
            Course(id = "CS301", title = "Mobile Architecture", enrolled = 140),
            Course(id = "CS302", title = "Jetpack Compose UI", enrolled = 220)
        )

        // GIVEN: Screen rendered with success state
        composeTestRule.setContent {
            CourseCatalogContent(
                uiState = CourseUiState.Success(testCourses),
                onRefreshClick = {},
                onEnrollClick = { id -> clickedCourseId = id }
            )
        }

        // 1. Assert both course titles are rendered on screen
        composeTestRule
            .onNodeWithText("Mobile Architecture")
            .assertIsDisplayed()

        composeTestRule
            .onNodeWithText("Jetpack Compose UI")
            .assertIsDisplayed()

        // 2. Click the Enroll button for course CS301
        composeTestRule
            .onNodeWithTag("enroll_btn_CS301")
            .performClick()

        // 3. Verify the click handler received the exact course ID
        assert(clickedCourseId == "CS301")
    }
}
Simulated Gradle Terminal Test Execution Output
> Task :app:testDebugUnitTest me.devascent.academy.CourseViewModelTest > loadCourses emits Success state PASSED [0.042s] me.devascent.academy.CourseRepositoryTest > networkError emits Failure state PASSED [0.015s] > Task :app:connectedDebugAndroidTest Starting 2 tests on Pixel 8 Pro - Android 14 (API 34) me.devascent.academy.CourseScreenComposeTest > loadingState_showsCircularProgressIndicator PASSED [0.312s] me.devascent.academy.CourseScreenComposeTest > successState_rendersCourses_andHandlesEnrollClick PASSED [0.450s] BUILD SUCCESSFUL in 4s 4 actionable tasks: 4 executed
07

University Exam & Technical Interview Viva Guide (8 Master Q&As)

Q1. Why are local unit tests executed on the host JVM rather than on an Android device?
Local unit tests (located in src/test/) run on the host development machine's JVM because it bypasses the massive overhead of packaging APKs, transferring them via ADB, and launching an Android runtime environment. JVM tests execute in milliseconds, allowing developers to practice rapid Test-Driven Development (TDD) and run thousands of regression tests in continuous integration (CI) pipelines.
Q2. What is the role of runTest and TestDispatcher in coroutine testing?
runTest provides virtual time control during unit testing. When coroutines call delay(), runTest advances virtual time instantaneously rather than causing real wall-clock delays, making asynchronous tests fast and deterministic. TestDispatcher (such as StandardTestDispatcher) queues coroutines onto a virtual scheduler, allowing tests to precisely step through execution using advanceUntilIdle().
Q3. How does Jetpack Compose find and assert UI elements without View IDs?
Jetpack Compose generates a Semantics Tree alongside the visual layout tree. Semantics nodes expose properties such as text content, test tags (Modifier.testTag()), content descriptions, and accessibility actions. Test Finders (e.g. onNodeWithTag(), onNodeWithText()) query this semantics tree to locate nodes, trigger actions (performClick()), and assert visibility without needing traditional Android View IDs.
Q4. What is a Conditional Breakpoint and when should it be utilized?
A Conditional Breakpoint is a debugging breakpoint that halts execution only when a specified boolean condition evaluates to true (e.g. itemCount > 500 or response.code == 401). It is used when debugging loops or high-frequency callbacks where manually hitting "Resume" hundreds of times would be impractical.
Q5. What is the difference between Shallow Size and Retained Size in Memory Profiling?
Shallow Size is the quantity of physical memory allocated to hold the object itself (its primitive fields and reference pointers). Retained Size is the total amount of memory that would be freed if the object were garbage collected—meaning the object's shallow size plus the shallow sizes of all objects reachable exclusively through that object.
Q6. How does LeakCanary detect memory leaks automatically?
LeakCanary installs an ActivityLifecycleCallbacks listener. When an Activity triggers onDestroy(), LeakCanary wraps the instance in a WeakReference keyed with a 5-second watch timer. If the weak reference is not cleared after 5 seconds (indicating the GC could not collect it), LeakCanary dumps the ART heap (hprof), parses the object graph, and traces the reference chain from a GC Root (static reference or background thread) holding onto the destroyed Activity.
Q7. What causes an ANR and where can you locate the system ANR trace file?
An Application Not Responding (ANR) occurs when the Main Thread is blocked past OS timeout limits (5s for user input, 10s/20s for BroadcastReceivers, 20s/200s for Services). The OS dumps thread stack traces to /data/anr/traces.txt, which can be extracted via adb pull /data/anr/traces.txt or inspected inside Android Studio's Profiler to pinpoint the exact line of code causing the lock.
Q8. How does MockK differ from Mockito in modern Kotlin Android projects?
Mockito was designed for Java and often struggles with Kotlin language idioms (such as final classes by default, non-null types, extension functions, and coroutines). MockK was designed natively for Kotlin, providing dedicated support for suspending functions via coEvery { } / coVerify { }, object mocks, sealed class hierarchies, and default parameter verification without requiring bytecode transformation hacks.