SwiftTCATestingSwiftUIiOS

Pokédex en TCA (parte 6): testing y contraste técnico con MVVM

Llevamos cinco partes construyendo. Tenemos la lista con paginación, el detalle con navegación como estado, los favoritos con @Shared y la búsqueda con filtros.

Cerramos con tres cosas: los tests que faltaban, los aprendizajes de haber construido la app en un proyecto real, y una comparación técnica con MVVM enfocada en los patrones de código, no en métricas.

Tests: lo mínimo que quiero cubrir

TestStore es exhaustivo por defecto: cada cambio de estado y cada efecto debe estar declarado, o el test falla. Ya vimos el patrón en detalle en la parte 4 de TCA; aquí lo aplico a la app real.

Tres tests que capturan la conducta importante:

  • Carga inicial: se entra a la pantalla, se llama al cliente, se guardan los Pokémon.
  • Marcar favorito: se toca la estrella, el set compartido se actualiza.
  • Flujo completo: cargar lista, abrir detalle, marcar favorito, volver.

Test 1: carga inicial

import ComposableArchitecture
import Testing

@Test
func cargaInicialExitosa() async {
    let pokemons = [
        Pokemon(id: 1, name: "bulbasaur", spriteURL: nil, types: ["grass"]),
        Pokemon(id: 4, name: "charmander", spriteURL: nil, types: ["fire"]),
    ]

    let store = await TestStore(initialState: PokemonListFeature.State()) {
        PokemonListFeature()
    } withDependencies: {
        $0.continuousClock = ImmediateClock()
        $0.pokemonClient.fetchList = { _, _, _ in pokemons }
    }

    await store.send(.onAppear) {
        $0.loadState = .loading
    }

    await store.receive(\.listReceived) {
        $0.loadState = .loaded(pokemons)
        $0.canLoadMore = false   // devolvimos menos que el pageSize
    }
}

Lo que pasa aquí, línea por línea:

  • Sustituyo pokemonClient.fetchList por una closure que devuelve mis dos Pokémon. Nada de red.
  • ImmediateClock() es opcional pero no cuesta nada, y me protege por si algún día agrego un debounce inicial: los tests seguirían pasando al instante.
  • store.send(.onAppear) describe el cambio inmediato del reducer: pasar a .loading. Si no lo declaro, el test falla. Si declaro un cambio que no ocurre, también.
  • store.receive(\.listReceived) espera a que el efecto entregue la acción y verifica el estado final.

Test 2: marcar favorito

Este es donde @Shared cambia la forma del test:

@Test
func marcarFavoritoActualizaElConjuntoCompartido() async {
    let pokemon = Pokemon(id: 25, name: "pikachu", spriteURL: nil, types: ["electric"])

    let store = await TestStore(
        initialState: PokemonDetailFeature.State(pokemon: pokemon)
    ) {
        PokemonDetailFeature()
    }

    await store.send(.favoriteTapped) {
        $0.$favorites.withLock { $0 = [25] }
    }

    await store.send(.favoriteTapped) {
        $0.$favorites.withLock { $0 = [] }
    }
}

Dos cosas.

Uno, no configuré ninguna dependencia. @Shared(.fileStorage) en los tests usa un almacén simulado en memoria por defecto, así que este test arranca con favorites vacío y no toca el disco real.

Dos, el $0.$favorites.withLock { $0 = [25] } en el closure de aserción. Es la misma sintaxis que en el reducer: el candado es parte del contrato de @Shared, y los tests lo respetan.

Test 3: flujo completo con exhaustividad reducida

Los tests exhaustivos son valiosos para features individuales, pero cuando quieres verificar un flujo de tres pantallas se vuelven un dolor. Para eso está el modo no exhaustivo:

@Test
func flujoCargarListaAbrirDetalleMarcarFavorito() async {
    let pokemon = Pokemon(id: 1, name: "bulbasaur", spriteURL: nil, types: ["grass"])
    let detail = PokemonDetail(id: 1, height: 7, weight: 69, abilities: ["overgrow"])

    let store = await TestStore(initialState: PokemonListFeature.State()) {
        PokemonListFeature()
    } withDependencies: {
        $0.continuousClock = ImmediateClock()
        $0.pokemonClient.fetchList = { _, _, _ in [pokemon] }
        $0.pokemonClient.fetchDetail = { _ in detail }
    }
    store.exhaustivity = .off(showSkippedAssertions: true)

    await store.send(.onAppear)
    await store.receive(\.listReceived)

    await store.send(.rowTapped(pokemon))
    await store.send(.destination(.presented(.detail(.onAppear))))
    await store.receive(\.destination.presented.detail.detailReceived)

    await store.send(.destination(.presented(.detail(.favoriteTapped))))

    // Verificación final: lo único que me importa del flujo entero.
    #expect(store.state.destination?.detail?.favorites.contains(1) == true)
}

Con .off(showSkippedAssertions: true) los cambios que no declaro pasan en silencio, pero la consola los reporta en gris. Es una radiografía de qué está haciendo la app sin obligarme a escribir cada paso. Y el #expect final captura lo que de verdad me importa: al final del flujo, el Pokémon está en favoritos.

Aprendizajes de haber construido la app

Estos son detalles concretos que no aparecen leyendo los posts pero que salen a la primera al implementar. Los he sincronizado en los posts anteriores para que quede el código correcto.

El Button con .buttonStyle(.plain) dentro de List no captura el tap

Parece la opción idiomática, envolver la fila en un botón sin estilo, pero el List intercepta el tap y la acción nunca llega al reducer. El patrón que funciona es dejar la fila sin envolver y añadir un .contentShape(Rectangle()) con .onTapGesture directamente:

PokemonRow(...)
    .contentShape(Rectangle())
    .onTapGesture { store.send(.rowTapped(pokemon)) }

Lo descubrí probando la app en el simulador: hasta cambiar a .onTapGesture, tocar una fila no navegaba. Con Xcode compilando sin quejarse.

Swift 6 estricto exige disciplina de Sendable

El struct del cliente, las closures que expone y los tipos que devuelve tienen que ser Sendable. Sin ese detalle, el static let liveValue no compila. Los modelos que pasan por TaskGroup (aquí, Pokemon cuando parseamos en paralelo) también lo necesitan.

El @Reducer enum Destination no propaga Equatable

Si el estado del padre lo requiere, hay que añadir la conformidad en una extensión aparte:

extension Destination.State: Equatable {}

La sintaxis vieja @Reducer(state: .equatable) enum está deprecada.

La cancelación también lanza CancellationError

Al hacer .cancellable(id:, cancelInFlight: true) sobre la búsqueda, cada tecla nueva cancela la anterior, y esa cancelación se propaga como CancellationError al catch: del .run. Si no lo filtras, verás flashazos de “cancelled” en pantalla mientras el usuario escribe. Filtrar es una línea:

} catch: { error, send in
    if error is CancellationError { return }
    await send(.searchFailed(error.localizedDescription))
}

Contraste técnico con MVVM

Sin métricas y sin tabla comparativa. Estos son los puntos donde el mismo problema se ve distinto en el código al compararlo con MVVM.

Modelado del estado remoto

El patrón MVVM habitual es un booleano isLoading + array + un opcional errorMessage. Esa combinación permite estados que no deberían existir (isLoading == true con datos ya cargados). TCA modela con un enum LoadState<Value> de cuatro casos mutuamente excluyentes: si tienes .loaded(pokemons), por construcción no puedes estar cargando ni en error.

Mutación del estado

En MVVM el ViewModel expone métodos y muta sus propiedades @Published directamente desde donde sea. En TCA la vista solo envía acciones; el reducer decide qué mutar en un switch exhaustivo sobre Action. Agregar una acción sin manejarla es un error de compilación.

Cancelación de efectos

En MVVM se guarda el Task? de la petición como propiedad del ViewModel, se cancela a mano en onDisappear, y cada operación asíncrona se gestiona por separado. En TCA lo declaras con .cancellable(id: .search, cancelInFlight: true) y no hay más. Al presentar y descartar pantallas con .ifLet, los efectos del hijo se cancelan automáticamente al cerrarla.

Estado compartido

En MVVM con SwiftData, cada ViewModel que toque favoritos recibe el ModelContext, consulta con @Query o FetchDescriptor, y hay que coordinar refresco entre vistas a mano. En TCA @Shared(.fileStorage) es una anotación en el estado. Se declara en cada feature que la necesita, y cuando una cambia el valor, todas las demás lo ven de inmediato.

En MVVM el NavigationLink(destination:) decide navegar en el momento del toque. Es rápido de escribir y difícil de manejar desde código. En TCA el destino es un valor opcional en el estado del padre; presentar es asignar, cerrar es poner a nil. Todo lo que se sigue de eso (deep links, restauración, tests de flujo) se hereda gratis.

Testing

En MVVM se sustituyen dependencias con protocolos y mocks, y las esperas asíncronas piden XCTestExpectation o Task.sleep. En TCA TestStore verifica cada cambio de estado y cada efecto que produce el reducer, @Dependency deja sustituir una closure sin implementar toda la interfaz, y los tests corren en microsegundos sin simulador.

Organización del proyecto

MVVM tiende a agrupar por capa: Views/, ViewModels/, Services/. TCA tiende a agrupar por feature: Features/PokemonList/, Features/PokemonDetail/. Cada carpeta contiene el reducer y la vista de esa feature, viviendo y muriendo juntos.

Cuándo elegir cada una

Después de haber escrito esta Pokédex en TCA, mi criterio para un proyecto nuevo:

Elegiría MVVM cuando la app tiene pocas pantallas y sé que no va a crecer, cuando el equipo no tiene tiempo de aprender TCA y no se van a escribir tests de lógica sistemáticos, o cuando cada segundo de compilación en clean build cuenta.

Elegiría TCA cuando la app crecerá con navegación compleja, deep links o restauración de estado, cuando hay concurrencia real (peticiones que se cancelan, se solapan, se reintentan), cuando el equipo escribe tests de lógica y los mantiene, y cuando el proyecto va a durar años y pasar por varias manos.

Para una Pokédex como la de esta serie (5 pantallas, red simple, favoritos), MVVM basta y es más rápido de hacer. Elegí escribir esta serie en TCA para tener material serio con el que enseñar los patrones sobre código real, no para argumentar que TCA es “mejor”. Cada arquitectura resuelve bien un conjunto distinto de problemas.

Cierre de la serie

Con esto cierra la Pokédex en TCA. El código completo vive público en pokedex-tca, compila con Xcode 26, iOS 17+ y Swift 6, y funciona en simulador con la PokeAPI real. Puedes clonarlo, ejecutarlo y usarlo como base para tu propio proyecto TCA.

Para la contraparte MVVM, la serie PokeTracker recorre parte por parte una app más ambiciosa (Clean Architecture con Data/Domain/Presentation, SwiftData, dos capas de cache, Atomic Design). El alcance no es simétrico al de la Pokédex TCA, pero sirve como referencia avanzada del enfoque MVVM en la misma familia de problemas.

Fuentes: Testing en TCA (docs) · swift-composable-architecture · repo de la app