wandres.dev
@PRESENTS · sheets, alerts, popovers

Alertas y diálogos: `AlertState` como dato testeable

Una alerta es la presentación más pequeña que existe y, precisamente por eso, la que mejor revela la tesis del nivel: en TCA no se muestra una alerta, se asigna un valor. `AlertState` y `ConfirmationDialogState` describen título, mensaje y botones como datos puros, y cada botón lleva un caso de un `enum` en lugar de una closure, de modo que pulsar Borrar es enviar una acción como cualquier otra. Esta lección construye el patrón completo —hueco anotado, acción anidada, `ifLet`, modificador en la vista—, explica cuándo el parámetro de tipo es `Never`, y muestra por qué una alerta declarada así se afirma en un test con una sola línea y sin abrir el simulador.

⏱ 18 min

De todas las cosas que una app puede poner encima de una pantalla, la alerta es la más humilde: dos líneas de texto y un par de botones que desaparecen enseguida. También es, con diferencia, la más maltratada. En el estilo imperativo se construye en el punto exacto del código donde hace falta, con closures que capturan lo que tengan a mano, y el resultado es que la lógica más delicada de la aplicación —la confirmación antes de borrar, el aviso antes de perder cambios— acaba viviendo dentro de un cierre anónimo colgado de la capa de vistas, donde ningún test la alcanza. TCA le aplica el mismo tratamiento que a todo lo demás, sin ninguna concesión: la alerta es un valor que vive en el estado, sus botones son casos de un enum, y mostrarla consiste en asignarla al mismo tipo de hueco con el que se presenta una pantalla entera. Lo que se gana con ese giro no es elegancia, es que la confirmación de borrado pase a ser código probado.

🎯 Al terminar esta lección sabrás
  • Construir un valor de AlertState con su título, su mensaje y sus botones descritos como datos.
  • Asociar a cada ButtonState un caso de un enum anidado en lugar de una closure de acción.
  • Enlazar el hueco con la vista mediante alert y confirmationDialog sobre $store.scope.
  • Afirmar en un TestStore que la alerta apareció y que su botón destructivo hizo lo que debía.

La alerta como valor

El patrón es el de una presentación cualquiera, con una diferencia: el estado presentado no es la State de una feature sino un AlertState parametrizado por el enum de sus botones.

@Reducer
struct Cuenta {
  @ObservableState
  struct State: Equatable {
    var perfil: Perfil
    @Presents var alerta: AlertState<Action.Alerta>?
  }

  enum Action {
    case borrarPulsado
    case alerta(PresentationAction<Alerta>)

    @CasePathable
    enum Alerta: Equatable {
      case confirmarBorrado
    }
  }

  @Dependency(\.cuentaClient) var cuentaClient

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .borrarPulsado:
        state.alerta = .confirmarBorrado
        return .none

      case .alerta(.presented(.confirmarBorrado)):
        return .run { _ in try await cuentaClient.borrar() }

      case .alerta:
        return .none
      }
    }
    .ifLet(\.$alerta, action: \.alerta)
  }
}

El valor de la alerta se declara aparte, como una constante estática sobre el tipo, porque es un dato y los datos con nombre se reutilizan y se comparan:

extension AlertState where Action == Cuenta.Action.Alerta {
  static let confirmarBorrado = Self {
    TextState("Borrar la cuenta")
  } actions: {
    ButtonState(role: .destructive, action: .confirmarBorrado) {
      TextState("Borrar")
    }
    ButtonState(role: .cancel) {
      TextState("Cancelar")
    }
  } message: {
    TextState("Se perderan todos los datos y la operacion no se puede deshacer.")
  }
}

Lo decisivo está en ButtonState. Su parámetro action no es una closure sino un caso del enum anidado, es decir, un dato inerte que se guarda dentro del estado y viaja después por el canal de presentación como cualquier otra acción. El botón de cancelar no lleva ninguno porque cancelar no es un hecho del dominio: es simplemente no hacer nada, y TCA lo traduce a un dismiss que ifLet resuelve vaciando el hueco. Fíjate también en que el reducer no comprueba en ningún momento si la alerta sigue en pantalla antes de borrar la cuenta; no le hace falta, porque la única forma de que llegue .confirmarBorrado es que el usuario haya pulsado ese botón concreto de esa alerta concreta.

En la vista, con el store declarado como @Bindable, un solo modificador cierra el circuito, y no recibe closures de ningún tipo:

Form { /* ... */ }
  .alert($store.scope(state: \.alerta, action: \.alerta))

Cuando la alerta es meramente informativa —un aviso con un único botón de aceptar— no hay ninguna acción que emitir, y el tipo lo dice: AlertState<Never>. Ese parámetro imposible de habitar es la forma en que el sistema de tipos afirma que de esa alerta no puede salir nada.

El diálogo de confirmación es la misma idea

ConfirmationDialogState comparte estructura, tipos y filosofía con AlertState; solo cambia la presentación en pantalla y el número razonable de opciones.

state.destino = .dialogo(
  ConfirmationDialogState {
    TextState("Que quieres hacer con el articulo")
  } actions: {
    ButtonState(action: .duplicar) { TextState("Duplicar") }
    ButtonState(action: .archivar) { TextState("Archivar") }
    ButtonState(role: .destructive, action: .borrar) { TextState("Borrar") }
    ButtonState(role: .cancel) { TextState("Cancelar") }
  }
)
Rasgo AlertState ConfirmationDialogState
Modificador de vista alert confirmationDialog
Número de acciones una o dos varias | lista corta de opciones
Mensaje frecuente y explicativo opcional y breve
Uso típico confirmar algo irreversible | informar de un fallo elegir entre acciones alternativas

Ambos encajan sin ceremonia dentro de un @Reducer enum de destino junto a las demás pantallas, porque la macro admite casos cuyo estado es uno de estos tipos. Así, una feature con un editor, un detalle y una alerta sigue teniendo un único hueco y sigue siendo incapaz de mostrar dos cosas a la vez.

💡
Cuidado con guardar datos dentro del caso del botón

Es tentador escribir ButtonState(action: .confirmarBorrado(articulo)) para llevarse el dato al reducer. Funciona, pero hace que el estado de la alerta contenga una copia del artículo, con dos efectos molestos: la comparación por igualdad del TestStore te obliga a reconstruir ese valor exacto en la aserción, y si el artículo cambia mientras la alerta está abierta trabajarás con una copia obsoleta. Casi siempre es mejor que el caso del botón sea escueto y que el reducer lea el dato vigente del estado en el momento de actuar. La excepción legítima es cuando el dato identifica cuál de varios elementos motivó la alerta y no lo tienes guardado en ningún otro sitio; en ese caso lleva el identificador, nunca el valor entero.

Afirmar una alerta en un test

Como la alerta es un valor, el test no la busca en la pantalla: la compara. Y como los botones son casos, pulsarlos es enviar acciones.

@Test
func borradoConfirmado() async {
  let store = TestStore(initialState: Cuenta.State(perfil: .prueba)) {
    Cuenta()
  } withDependencies: {
    $0.cuentaClient.borrar = { }
  }

  await store.send(.borrarPulsado) {
    $0.alerta = .confirmarBorrado
  }

  await store.send(\.alerta.confirmarBorrado)
  await store.send(\.alerta.dismiss) {
    $0.alerta = nil
  }
}

Observa lo que este test comprueba sin tocar la interfaz: que pulsar borrar no borra nada todavía, que aparece una alerta con ese título y esos dos botones exactos, que el botón destructivo desencadena la llamada al cliente y que descartar vacía el hueco. En una arquitectura donde la alerta se construye en la vista, cada una de esas cuatro afirmaciones exige un test de interfaz, y el segundo —que el título y los botones son los correctos— no suele escribirse jamás. Aquí es una comparación de valores que el TestStore exige de todos modos por su exhaustividad.

flowchart LR
A[Accion borrarPulsado] --> S[state alerta igual a AlertState]
S --> V[Modificador alert sobre store scope]
V --> U[El usuario pulsa un boton]
U -->|caso del enum Alerta| R[Reducer decide y devuelve efecto]
U -->|boton cancel| D[PresentationAction dismiss]
D --> N[ifLet vacia el hueco]
style S fill:#89b4fa,color:#11111b
style R fill:#a6e3a1,color:#11111b
Sustituir closures por casos de un enum es cambiar comportamiento por descripción, y esa es la operación que hace testeable todo lo demás

Un botón con una closure y un botón con un caso de enum parecen dos notaciones de la misma cosa, y no lo son en absoluto: son dos categorías ontológicas distintas. Una closure es comportamiento encapsulado —opaco por definición, imposible de comparar por igualdad, imposible de serializar, imposible de imprimir de forma legible— y arrastra consigo todo lo que capturó, incluidas referencias que mantienen vivo lo que quizá debería morir. Un caso de enum es una descripción: un dato finito, inspeccionable, comparable, que dice qué quiso el usuario sin decir qué debe ocurrir a continuación. Al elegir la segunda, TCA introduce una separación que atraviesa el framework entero, entre lo que se ha querido y lo que se hará, y coloca la frontera exactamente donde puede ponerse una prueba. Las consecuencias se acumulan más allá del testing. Una alerta que es un dato se puede construir en el reducer con toda la información del dominio a mano, se puede localizar porque TextState conoce las claves de traducción, se puede guardar en el estado y restaurar tras un reinicio, se puede transportar por la red en una sesión de depuración remota y se puede reproducir replicando una lista de acciones. Nada de eso es alcanzable cuando el botón lleva dentro una función. Y hay algo más profundo todavía, que solo se ve al mirar la alerta junto al resto del nivel: la alerta es la presentación más pequeña posible y por tanto el mejor lugar para comprobar si una arquitectura respeta de verdad su propio principio o hace excepciones cuando la conveniencia aprieta. Casi todas las hacen aquí, porque una alerta parece demasiado trivial para merecer estado, acción y reducer. TCA no la hace, y esa terquedad es la razón de que en una app escrita así no exista ningún rincón donde ocurra algo importante fuera del alcance del ciclo. Cuando incluso la pregunta de si de verdad quieres borrar esto es un valor en el estado, ya no queda ninguna lógica escondida en la interfaz.

⚔️ Saca una confirmación de la vista y llévala al estado
  1. Busca en tu app una alerta construida en la capa de vistas con closures en sus botones. Anota qué captura cada closure y desde dónde.
  2. Declara un enum anidado con un caso por cada botón que haga algo de verdad, y reescribe la alerta como un AlertState asignado a un hueco @Presents.
  3. Mueve la lógica de cada closure a la rama correspondiente del reducer, y comprueba que ninguna necesita capturar nada porque el estado ya lo tiene todo.
  4. Escribe un test que afirme el valor completo de la alerta tras la acción de apertura. Si te cuesta reconstruirlo, es señal de que el botón lleva demasiado dato encima.
  5. Añade un ConfirmationDialogState con tres opciones al mismo hueco convirtiéndolo en un @Reducer enum de destino, y verifica que abrir el diálogo cierra la alerta sin escribir código de cierre.