Mobile Development 13 min read

Architecture of Airbnb's Automated Testing Framework (Part 5)

Part 5 of Airbnb’s Android testing series details the custom IntegrationTestActivity architecture that drives fragment‑based screenshots and interactions, explains how idle state is detected by polling Looper queues, and describes a global exception handler that adds contextual test information to aid rapid debugging.

Airbnb Technology Team
Airbnb Technology Team
Airbnb Technology Team
Architecture of Airbnb's Automated Testing Framework (Part 5)

This article is the fifth part of a series describing Airbnb's Android automated testing framework. It explains the overall architecture of the integration testing system, including how idle state detection and error handling are implemented.

The integration tests run on Espresso, but a custom set of tools is built on top to simplify fragment setup, view manipulation, and teardown. Instead of using standard JUnit or Espresso assertions, the framework performs screenshot capture, automatic view clicks, and uploads test reports.

Base Activity

A custom IntegrationTestActivity (named IntegrationTestActivity ) is defined in a shared library module that is only a test dependency, so it is not packaged in the production APK. Each test launches this activity, passing the fragment name as a string. The activity uses reflection to access the mock state of the fragment, iterates over all mock states, applies each state, performs actions (e.g., screenshot), clears the state, and finally marks its IdlingResource as idle.

Example of a screenshot‑testing activity:

class HappoTestActivity : IntegrationTestActivity() {
    override fun testCurrentScreen(
        mockProvider: MockedFragmentProvider,
        fragment: MvRxFragment,
        resetView: (onViewReset: (MvRxFragment) -> Unit) -> Unit,
        finishedTestingFragment: () -> Unit
    ) {
        happoViewSnapshotBuilder.snap(
            activity = this,
            component = mockProvider.fragmentName,
            variant = mockProvider.mockData.name
        )
        finishedTestingFragment()
    }
}

Example of an interaction‑testing activity:

class InteractionTestActivity : IntegrationTestActivity() {
    override fun testCurrentScreen(
        mockProvider: MockedFragmentProvider,
        fragment: MvRxFragment,
        resetView: (onViewReset: (MvRxFragment) -> Unit) -> Unit,
        finishedTestingFragment: () -> Unit
    ) {
        ActivityInteractionTester(
            activity = this,
            resetViewCallback = resetView,
            entryPointProvider = { this.window.decorView },
            interactionManager = interactionManager,
            onEnd = { reportJson ->
                happoJsonSnapshotBuilder.add(reportJson, mockProvider.fragmentName, mockProvider.mockData.name)
                finishedTestingFragment()
            }
        ).start()
    }
}

Running the test activities from JUnit looks like this:

@Test
fun screenshotBookingFragment() = runScreenshots("com.airbnb.booking.BookingFragment")

@Test
fun screenshotSearchFragment() = runScreenshots("com.airbnb.search.SearchFragment")

fun runScreenshots(fragmentName: String) {
    val intent = IntegrationTestActivity.intent
(
        context = InstrumentationRegistry.getInstrumentation().targetContext,
        fragmentName = fragmentName
    )
    activityTestRule.launchActivity(intent)
    Espresso.onIdle()
}

Idle State Detection

Espresso normally waits for UI idle, but the custom activities do not use Espresso assertions and cannot rely on its internal idle callbacks. Therefore the framework implements its own idle detection by polling Handler().looper.queue.isIdle . A runnable is posted to the handler to refresh the queue before checking its state.

Utility code for idle detection:

fun waitForLoopers(loopers: List
) {
    val idleDetectors = loopers.map { HandlerIdleDetector(Handler(it)) }
    while (idleDetectors.any { !it.isIdle }) {
    }
}

class HandlerIdleDetector(val handler: Handler) {
    var isIdle = false

    init {
        postIdleCheck()
    }

    private fun postIdleCheck() {
        handler.post {
            do {
                isIdle = handler.looper.queue.isIdle
            } while (isIdle)
            postIdleCheck()
        }
    }
}

The implementation notes that the detector should stop its loop after use, combine polling with callbacks or coroutines, add timeout handling, avoid posting on the looper being checked, and consider a short idle period (≈16 ms) to account for animations.

Error Handling

If a test throws an exception, the framework registers a default exception handler for all threads (including RxJava and coroutines) via a TestRule, forwarding the exception to the main thread so Espresso can display a clear error message. To provide context, a stack of strings representing the test context is maintained (e.g., which mock state is loaded, which view was clicked). When an exception occurs, the framework wraps it with this context:

val testContextMessage = "Error while testing 'Default State' mock for BookingFragment -> Clicking 'book_button' view on thread 'AsyncTask #1'"
throw IllegalStateException(testContextMessage, originalException)

This approach helps developers quickly understand the cause of failures without digging through logs.

The article concludes by previewing the next part, which will discuss maintaining consistency of mock states across tests.

Androidautomated testingerror handlingintegration-testingEspressoIdle Detection
Airbnb Technology Team
Written by

Airbnb Technology Team

Official account of the Airbnb Technology Team, sharing Airbnb's tech innovations and real-world implementations, building a world where home is everywhere through technology.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.