Author Avatar

No necesitas MVVM para testear SwiftUI

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:

  1. Dejamos de usar value types sólo porque tenemos el propósito de testear

  2. 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.

  3. 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; @Binding es 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.