protocol MovieLoader {
func load() async throws -> [Movie]
}
struct MovieList: View {
var movies = [Movie]()
let loader: MovieLoader
var body: some View {
List(movies, rowContent: MovieRow.init)
.task { await load() }
}
func load() async {
movies = (try? await loader.load()) ?? []
}
}
func test_load_loadsMovies() async {
let expected = [anyMovie()]
let stubbed = StubMovieLoader(stubs: expected)
let sut = MovieList(loader: stubbed)
await sut.load()
XCTAssertEqual(sut.movies, expected)
}
Si intentas ejecutar este test tal cual, fallará.
La razón es que el @State de una vista en SwiftUI solo se conecta con un almacenamiento en memoria si la vista ha sido montada a una jerarquía real. La forma más común de testear el estado es trasladar la lógica a un @Observable
class MovieListViewModel {
var movies = [Movie]()
let loader: MovieLoader
init(loader: MovieLoader = RemoteMovieLoader()) { ... }
func load() async {
movies = (try? await loader.load()) ?? []
}
}
struct MovieList: View {
var vm = MovieListViewModel()
var body: some View {
List(vm.movies, content: MovieRow.init)
.task { await vm.load() }
}
}
func test_load_loadsMovies() async {
let expected = [anyMovie()]
let stubbed = StubMovieLoader(stubs: expected)
let sut = MovieListViewModel(loader: stubbed)
await sut.load()
XCTAssertEqual(sut.movies, expected)
}
Este patrón, muy común y considerado estándar de facto, tiene algunos inconvenientes:
Dejamos de usar value types sólo porque tenemos el propósito de testear
Introducimos reference types con todo su bagaje (gestión de ciclos de vida y leaks potenciales) en un marco pensado para ser lo más value type posible.
Seguimos mezclando lógica de estado de UI (cómo y cuándo se actualiza) con el propio almacenamiento, pese a introducir un objeto adicional para abstraerla (a pesar de que incluso en UIKit existen implementaciones stateless de MVVM que demuestran que el estado y la lógica pueden separarse)
La solución, curiosamente, ya está en SwiftUI desde el principio.
En este artículo me gustaría presentar una alternativa apoyandome en el trabajo de Lazar Otasevic.
Binding
@State no es testeable fuera de una jerarquía de vistas, pero @Binding ofrece una forma de exponer el estado para testing, algo que parece haber pasado desapercibido por la comunidad.
Conceptualmente, un @Binding puede entenderse como un simple par de closures (get y set):
struct Binding<Value> {
let get: () -> Value
let set: (Value) -> Void
}
Lo que hace que una vista con @Bindings sea completamente testeable:
struct MovieList: View {
var movies: [Movie]
let loader: MovieLoader
var body: some View {
List(movies, rowContent: MovieRow.init)
.task { await load() }
}
func load() async {
movies = (try? await loader.load()) ?? []
}
}
func test_load_loadsMovies() async {
var storage = [Movie]()
let binding = Binding(get: { storage }, set: { storage = $0 })
let expected = [anyMovie()]
let stubLoader = StubMovieLoader(stubs: expected)
let sut = MovieList(movies: binding, loader: stubLoader)
await sut.load()
XCTAssertEqual(storage, expected)
}
La vista deja de tener estado propio, transformándose en un componente puramente funcional que delega la persistencia a su ancestro.
SwiftUI ya es un motor de estado;
@Bindinges simplemente el cable que nos permite conectar ese motor con nuestras pruebas unitarias.
Podemos crear algunas funciones auxiliares: De forma análoga a la función estática Binding.constant() de Apple, podemos tener un Binding.variable().
extension Binding {
static func variable(_ initialValue: Value) -> Self {
var copy = initialValue
return Binding(get: { copy }, set: { copy = $0 })
}
}
func test_load_loadsMovies() async {
let expected = [anyMovie()]
let stubLoader = StubMovieLoader(stubs: expected)
let (sut, movies) = makeSUT(loader: stubLoader)
await sut.load()
XCTAssertEqual(movies(), expected)
}
func makeSUT(movies: [Movie] = [], loader: MovieLoader) -> (MovieList, () -> [Movie]) {
let movies = Binding.variable(movies)
let sut = MovieList(movies: movies, loader: loader)
return (sut, { movies.wrappedValue })
}
`Binding.variable `no crea un contenedor especial como @State; solo captura una variable local mediante closures. Cuando la vista escribe:
movies = newValue
...en realidad está ejecutando el closure set, que muta copy.
Y cuando en el test lees:
movies.wrappedValue
...estás ejecutando el closure get, que devuelve ese mismo copy: La vista y el test comparten el mismo almacenamiento, ya que ambos usan la variable capturada.
La lógica de estado de la vista es totalmente testeable, lo único que tendría que hacer quién quiera que la construya es proveerle de su estado:
import Movies
import MoviesUI
// Composition Root
struct MovieListComposer: View {
var movies = [Movie]()
let loader: MovieLoader
var body: some View {
MovieList(movies: $movies, loader: loader)
}
}
Conclusiones y Consideraciones:
Este patrón permite un nivel alto de testabilidad a la vez que permite conservar la simplicidad del sistema declarativo de SwiftUI sin capas intermedias.
No necesitas un reference type ViewModel para testear lógica de estado en SwiftUI si no necesitas identidad (en la mayoría de los casos).
@Statetiene almacenamiento inaccesible fuera del runtime de SwiftUI.@Bindingno tiene almacenamiento propio y solo representa acceso a uno externo mediante closures.Al usar
@Binding, mantenemos la vista como un struct ligero y testeable, sin forzar la creación de una clase solo para satisfacer al test runner.Puedes seguir utilizando
@Observablepara el estado ya que@Bindinges la interfaz de comunicación, no de almacenamiento. Esto desacopla la vista de cómo se almacena el dato (ya sea en un@State, un@Observableo un propery wrapper de CoreData/SwiftData/etc...).Encapsular la lógica en un struct dedicado para reutilizarla en otras vistas y hacer la arquitectura más desacoplada es perfectamente posible. Para más detalles, recomiendo el artículo de Lazar Otasevic sobre logica puramente stateless. También, puedes encontrar un proyecto de referecia publicado un proyecto de ejemplo en el que uso extensivamnete este patrón y que te puede servir de referencia si decides probarlo: OnlyGoodMovies