Qué es el TestStore
El `TestStore` no es un envoltorio cómodo sobre el `Store` de producción sino un instrumento de medida con una tesis moral incorporada: nada de lo que ocurre dentro de una feature puede quedar sin declarar. Esta lección monta el aparato pieza a pieza —cómo se construye, qué guarda por dentro, qué compara y en qué instante decide fallar—, enumera las cuatro deudas que te cobra al terminar cada prueba —estado declarado, acciones recibidas, efectos terminados y dependencias sustituidas— y explica por qué esta exigencia, imposible en casi cualquier otra arquitectura de interfaz, se sostiene entera sobre el reducer puro, el efecto como valor y la dependencia inyectada.
Un test corriente pregunta; el TestStore exige. La diferencia parece de matiz y es de género. Cuando escribes una prueba normal eliges un puñado de propiedades observables, las miras después de ejecutar algo y decides si te convencen: el test cubre aquello que su autor recordó mirar y guarda un silencio cómplice sobre todo lo demás. El TestStore invierte la carga de la prueba. No te deja observar, te obliga a declarar de antemano el estado exacto que esperas; luego ejecuta el reducer de verdad y compara, campo a campo, lo que dijiste contra lo que ocurrió. Si algo se movió sin que lo anunciaras, falla. Si un efecto devolvió una acción que no reclamaste, falla. Si al acabar queda una tarea viva, falla. Esta lección monta el aparato desde cero y defiende una tesis incómoda: esa severidad no es un rasgo de carácter del framework, sino la única forma conocida de que un test que pasa signifique algo más que la ausencia de sospecha.
- Construir un
TestStorey entender qué guarda por dentro: el reducer real, una copia del estado y una cola de acciones pendientes. - Enumerar las cuatro deudas que el
TestStorecobra al final de cada prueba y reconocer el mensaje de fallo de cada una. - Distinguir el
TestStoredelStorede producción y saber qué exige a cambio de lo que ofrece. - Ver por qué esta exigencia sólo es posible sobre un reducer puro, efectos como valores y dependencias inyectadas.
Un store construido para desconfiar
Un TestStore se construye con las mismas dos piezas que el store de producción —un estado inicial y el reducer que lo gobierna— más una tercera opcional que en la práctica nunca lo es: el bloque donde sustituyes las dependencias por versiones controladas.
@Test
func minimo() async {
let store = TestStore(initialState: Contador.State()) {
Contador()
} withDependencies: {
$0.uuid = .incrementing
}
await store.send(.incrementar) {
$0.cuenta = 1
}
}
Por dentro no hay magia, hay contabilidad. El TestStore mantiene dos representaciones del mundo: el estado real, que sólo el reducer muta, y el estado esperado, que sólo tú mutas dentro de la closure de aserción. Cada send ejecuta el reducer auténtico —no un doble, no un simulacro— sobre el estado real, aplica tu closure sobre una copia del estado anterior y compara los dos resultados con Equatable. Si difieren, el fallo no dice «algo salió mal»: imprime un diff estructural con la línea exacta que sobra y la que falta. A esa contabilidad de estado se le suma una segunda, la de las acciones que los efectos devuelven, que se encolan en vez de aplicarse a escondidas y quedan esperando a que las reclames.
De ahí se sigue la diferencia esencial con el Store de producción, que ejecuta y sigue adelante. El store real es un motor; el TestStore es un motor con un notario al lado que levanta acta de cada movimiento y compara el acta con la declaración que firmaste antes de arrancar. Esa asimetría explica por qué el TestStore vive en el módulo de tests y por qué su API es más estrecha y más ceremoniosa: no está hecho para hacer funcionar una app, sino para no dejar pasar ni un gesto sin registrar.
El fallo que produce esa comparación merece mirarse de cerca, porque no es un booleano rojo sino un documento:
// Lo que imprime el TestStore cuando la prediccion no encaja
//
// A state change does not match expectation:
//
// Contador.State(
// cuenta: 1,
// − ultimoToque: nil
// + ultimoToque: Date(2026-07-30T10:00:00Z)
// )
//
// (Expected: −, Actual: +)
El diff es estructural y mínimo: no vuelca los dos estados enteros sino la diferencia entre ellos, línea a línea, con el signo menos marcando lo que declaraste y el más lo que de verdad ocurrió. En un estado con cuarenta campos anidados eso es la distancia entre un mensaje inútil y un diagnóstico: el TestStore no te dice que algo no cuadra, te señala el campo exacto, dentro de qué estructura hija y con qué valor concreto. Esa legibilidad no es cosmética, es lo que hace sostenible la exigencia; una comparación exhaustiva que fallara con un volcado ilegible sería insoportable a la tercera prueba.
Las cuatro deudas que cobra al final
Estado declarado
Toda mutación que el reducer provoque debe aparecer en la closure de aserción. Un campo que cambió y no declaraste rompe el test con un diff.
Acciones recibidas
Cada acción que un efecto devuelve queda encolada. Terminar con la cola no vacía es fallo: nada de lo que vuelve puede pasar inadvertido.
Efectos terminados
Un efecto de larga vida que sigue corriendo al acabar la prueba también falla. Todo lo que empieza tiene que acabar o cancelarse.
Dependencias sustituidas
En test, @Dependency resuelve al testValue. Una dependencia sin implementación de prueba falla al ser invocada en vez de tocar el mundo real.
| Deuda | Cuándo se detecta | Qué significa el fallo |
|---|---|---|
| Mutación no declarada | en el send o el receive |
el comportamiento cambió y el test no lo sabía |
| Aserción de más | en el send o el receive |
afirmas una mutación que el reducer no hace |
| Acción sin reclamar | al destruirse el store | un efecto habla y nadie escucha su respuesta |
| Efecto en vuelo | al destruirse el store | la feature deja trabajo colgando tras el flujo |
| Dependencia no sustituida | al invocarla el reducer | el test iba a tocar red, disco o reloj reales |
Las dos primeras filas son simétricas y esa simetría no es adorno: la regla exacta es que hay closure de aserción si y sólo si hubo mutación, porque una aserción redundante miente sobre el comportamiento tanto como una omitida. Las dos siguientes se comprueban tarde, cuando el store se destruye al final del ámbito de la prueba, y por eso su mensaje aparece a veces desconectado de la línea que lo causó: es el precio de vigilar el ciclo de vida completo y no sólo cada paso aislado. La última es la única que puede fallar en mitad de la ejecución sin que nada haya salido mal del todo; es, de hecho, una alarma de diseño disfrazada de fallo de test.
La condición que hace posible tanta exigencia
Conviene preguntarse por qué esta clase de instrumento no existe en las arquitecturas de interfaz habituales, y la respuesta es que allí no habría nada que declarar. Sólo puedes afirmar el estado exacto siguiente si ese estado es una función determinista del estado anterior, la acción y las dependencias. Sólo puedes esperar un efecto si el efecto es un valor devuelto y no una llamada disparada a ciegas dentro del método. Sólo puedes congelar el tiempo si el reloj entró por una dependencia en lugar de estar cableado en una llamada estática. El TestStore no inventa esas propiedades: las cobra.
// La ceremonia de los niveles previos es la precondición del test
@Reducer
struct Feature {
@Dependency(\.clienteAPI) var clienteAPI // sustituible
@Dependency(\.continuousClock) var reloj // congelable
var body: some ReducerOf<Self> {
Reduce { state, action in // puro: estado y accion adentro
switch action {
case .aparecer:
state.cargando = true
return .run { send in // el efecto es un valor
await send(.datos(try await clienteAPI.cargar()))
}
case let .datos(items):
state.cargando = false
state.items = items
return .none
}
}
}
}
Merece la pena hacer el ejercicio inverso y romper una a una esas precondiciones para ver qué se pierde. Si el reducer llamara a Date() directamente en vez de recibir la fecha por dependencia, la aserción sobre ese campo sería imposible de escribir: el valor esperado cambiaría en cada ejecución y sólo cabría afirmar algo vago sobre él. Si el efecto fuese una llamada disparada dentro del case en lugar de un valor devuelto, no habría cola que reclamar ni forma de saber si se disparó una vez o dos. Y si el estado fuese una clase con referencias compartidas, la copia previa que la closure recibe no sería una copia sino un alias del estado real, y la comparación daría siempre verde porque estarías comparando un valor consigo mismo. Cada exigencia del TestStore es la contrapartida de una libertad que renunciaste a tomarte niveles atrás.
flowchart TD S[send de una accion] --> R[Reducer real muta el estado real] S --> D[Closure declara el estado esperado] R --> C[Comparacion campo a campo] D --> C R --> Q[Acciones de efectos a la cola] Q --> RC[receive las reclama una a una] RC --> C C --> F[Fin de la prueba: cola vacia y sin efectos vivos] style C fill:#f9e2af,color:#11111b style F fill:#a6e3a1,color:#11111b
El TestStore está aislado al MainActor, igual que el store de producción, así que tus pruebas se marcan con @MainActor o heredan ese aislamiento de la suite. No es un detalle burocrático: garantiza que la comparación de estados y el drenaje de la cola de acciones ocurren en un orden serializado y reproducible, sin que dos hilos observen versiones distintas del mismo mundo.
Hay una asimetría lógica entre observar y declarar que decide el valor entero de una suite de pruebas. Cuando observas, tu test es una conjunción de afirmaciones sueltas —esto vale tres, aquello está vacío— y su alcance termina justo donde termina tu memoria: todo lo que no miraste queda fuera del enunciado, y lo que queda fuera del enunciado no puede romperse nunca, porque no se afirmó nada sobre ello. Un test así no es falso, es débil en un sentido técnico: su contenido informativo es minúsculo comparado con el espacio de comportamientos que deja pasar. Cuando declaras, en cambio, el enunciado deja de ser una lista de comprobaciones y se convierte en una descripción cerrada: esto es todo lo que ocurre, y nada más ocurre. La segunda proposición —la cláusula de clausura, la que dice «y nada más»— es la que ningún test convencional puede pronunciar y la que el TestStore pronuncia por defecto en cada línea. De ahí que su relación con el código no sea la de un vigilante externo sino la de una especificación ejecutable: describe la feature de forma completa y se rompe en el instante mismo en que la feature deja de ser lo que describía. Y repara en el precio ontológico que esto tiene. Para poder decir «nada más ocurre» hace falta que el universo del que hablas sea cerrado y enumerable: un estado que es un valor y no una maraña de referencias, unos eventos que son casos de un enum y no llamadas arbitrarias, unos efectos que son datos devueltos y no acciones clandestinas sobre el mundo. La disciplina que llevas pagando desde el Nivel 2 no era, entonces, un gusto estético por la pureza. Era la construcción paciente del único tipo de sistema sobre el que una frase así puede ser verdadera.
- Toma la feature más pequeña que tengas y escribe un
TestStoremínimo: estado inicial, reducer y unsendcon su closure de aserción correcta. Compruébalo en verde antes de romper nada. - Añade en el reducer una mutación colateral —un contador de toques, una marca de tiempo— y no toques el test. Ejecuta y lee el diff: acabas de ver la primera deuda.
- Pon una aserción de más en la closure de una acción que no cambia el estado y observa que el
TestStoreprotesta igual. Confirma con eso la regla del si y sólo si. - Haz que la acción devuelva un efecto que emita otra acción y termina el test sin reclamarla. Anota el mensaje exacto y en qué momento aparece.
- Borra el bloque
withDependenciesde una feature que use red o reloj y comprueba que el fallo por dependencia sin implementación de prueba llega antes de que nada real se ejecute.