¿Cuándo tiene sentido Espresso?

Espresso tests corren en un dispositivo o emulador real. Son lentos (segundos por test), frágiles (un cambio de ID rompe el test) y costosos de mantener. Úsalos selectivamente:

  • Flujos críticos de usuario: login, checkout, onboarding. Si estos flujos se rompen, la app no sirve.
  • Regresiones complejas: bugs que involucran la interacción entre múltiples componentes que son difíciles de reproducir con unit tests.
  • Accesibilidad: verificar que los content descriptions están bien configurados.

No escribas Espresso tests para cosas que podés verificar con unit tests. Si el ViewModel ya tiene tests, no necesitás un Espresso test que verifique el mismo comportamiento desde la UI.

ActivityScenario — abrir una Activity en tests

// Abrir la Activity:
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    // O manualmente con más control:
    @Test
    fun `login exitoso navega a la pantalla principal`() {
        val scenario = ActivityScenario.launch(LoginActivity::class.java)

        scenario.onActivity { activity ->
            // Acceso directo a la Activity si lo necesitás
        }

        // Las interacciones con Espresso no necesitan el scenario
        onView(withId(R.id.etEmail))
            .perform(typeText("[email protected]"), closeSoftKeyboard())
        onView(withId(R.id.etPassword))
            .perform(typeText("contraseña"), closeSoftKeyboard())
        onView(withId(R.id.btnLogin))
            .perform(click())

        // Verificar que llegamos a MainActivity
        onView(withId(R.id.mainContainer)).check(matches(isDisplayed()))

        scenario.close()
    }
}

Interacciones — perform()

// Encontrar una View:
onView(withId(R.id.miBoton))
onView(withText("Confirmar"))
onView(withContentDescription("Buscar"))
onView(allOf(withId(R.id.tvNombre), withText("Producto A")))

// Acciones — perform():
onView(withId(R.id.etNombre)).perform(typeText("Hola"))
onView(withId(R.id.etNombre)).perform(clearText())
onView(withId(R.id.etNombre)).perform(replaceText("Nuevo texto"))
onView(withId(R.id.btnEnviar)).perform(click())
onView(withId(R.id.btnEnviar)).perform(longClick())
onView(withId(R.id.scrollView)).perform(swipeUp())
onView(withId(R.id.scrollView)).perform(swipeDown())

// Cerrar el teclado después de escribir:
onView(withId(R.id.etBuscar)).perform(typeText("query"), closeSoftKeyboard())

Assertions — check()

// Visibilidad:
onView(withId(R.id.tvError)).check(matches(isDisplayed()))
onView(withId(R.id.progressBar)).check(matches(not(isDisplayed())))
onView(withId(R.id.tvResultado)).check(matches(withEffectiveVisibility(GONE)))

// Texto:
onView(withId(R.id.tvNombre)).check(matches(withText("Carlos")))
onView(withId(R.id.tvNombre)).check(matches(withText(containsString("Car"))))

// Estado:
onView(withId(R.id.btnEnviar)).check(matches(isEnabled()))
onView(withId(R.id.btnEnviar)).check(matches(not(isEnabled())))
onView(withId(R.id.checkBox)).check(matches(isChecked()))

// Que una View NO existe en la jerarquía:
onView(withId(R.id.tvError)).check(doesNotExist())

RecyclerView — interacciones con listas

// Verificar items en un RecyclerView:
onView(withId(R.id.recyclerView))
    .check(matches(hasChildCount(3)))

// Hacer click en el item en la posición 0:
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))

// Hacer click en un item con texto específico:
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
        hasDescendant(withText("Producto A")),
        click()
    ))

// Scrollear hasta un item:
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(10))

// Verificar que un item específico es visible:
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(
        hasDescendant(withText("Producto Z"))
    ))
onView(withText("Producto Z")).check(matches(isDisplayed()))

Idling Resources — esperar operaciones asíncronas

El problema más común con Espresso: el test hace click en "Buscar", pero los resultados tardan en llegar (llamada de red, base de datos). Espresso por default no espera — verifica inmediatamente y el test falla.

// Solución 1: CountingIdlingResource
// Incrementar cuando empieza una operación async, decrementar cuando termina
val idlingResource = CountingIdlingResource("network_calls")

// En el repositorio:
fun buscar(query: String): Flow<List<Producto>> = flow {
    idlingResource.increment()
    try {
        emit(api.buscar(query))
    } finally {
        idlingResource.decrement()
    }
}

// En el test, registrar el idling resource:
@Before
fun setUp() {
    IdlingRegistry.getInstance().register(idlingResource)
}

@After
fun tearDown() {
    IdlingRegistry.getInstance().unregister(idlingResource)
}

// Ahora Espresso espera automáticamente hasta que el contador llegue a 0

// Solución 2 (más simple para proyectos nuevos): usar Hilt para inyectar
// un FakeRepository en los tests instrumentados — así no hay operaciones
// asíncronas reales y no necesitás idling resources

FragmentScenario — testear Fragments sin Activity

// Lanzar un Fragment directamente (más rápido que lanzar la Activity completa)
@RunWith(AndroidJUnit4::class)
class ProductosFragmentTest {

    @Test
    fun `lista de productos se muestra correctamente`() {
        // Lanzar el Fragment con un bundle de argumentos
        val args = Bundle().apply { putInt("categoriaId", 1) }
        val scenario = launchFragmentInContainer<ProductosFragment>(
            fragmentArgs = args,
            themeResId = R.style.Theme_MiApp
        )

        scenario.onFragment { fragment ->
            // Acceso directo al Fragment si es necesario
        }

        // Interacciones normales con Espresso
        onView(withId(R.id.recyclerView)).check(matches(isDisplayed()))
    }
}

// Con Navigation Component — FragmentScenario con NavController:
@Test
fun `click en producto navega al detalle`() {
    val mockNavController = mockk<NavController>(relaxed = true)

    val scenario = launchFragmentInContainer<ProductosFragment>()
    scenario.onFragment { fragment ->
        Navigation.setViewNavController(fragment.requireView(), mockNavController)
    }

    onView(withId(R.id.recyclerView))
        .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))

    verify {
        mockNavController.navigate(
            ProductosFragmentDirections.actionToDetalle(any())
        )
    }
}