Author Avatar

You Don't Need MVVM to Test 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)
}

If you try to run this test as-is, it will fail.

The reason is that @State in a SwiftUI view only connects to an in-memory store if the view has been mounted into a real view hierarchy. The most common workaround is to move the logic into an @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)
}

This pattern, widely used and considered a de facto standard, comes with some drawbacks:

  1. We abandon value types purely for the sake of testability.

  2. We introduce reference types with all their baggage (lifecycle management and potential leaks) into a framework designed to be as value type-oriented as possible.

  3. We keep mixing UI state logic (how and when it updates) with the storage itself, despite introducing an extra object to abstract it — even though stateless MVVM implementations exist even in UIKit.

The solution, curiously, has been in SwiftUI from the start. In this article I'd like to present an alternative, building on the work of Lazar Otasevic.

Binding

@State is not testable outside a view hierarchy, but @Binding offers a way to expose state for testing — something that seems to have gone largely unnoticed by the community. Conceptually, a @Binding can be understood as a simple pair of closures (get and set):

struct Binding<Value> {
    let get: () -> Value
    let set: (Value) -> Void
}

Which makes a view using @Binding fully testable:

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)
}

The view no longer owns its state, becoming a purely functional component that delegates persistence to its ancestor.

SwiftUI is already a state engine; @Binding is simply the wire that lets us connect that engine to our unit tests.

We can build some helper utilities. Analogous to Apple's static Binding.constant(), we can have a 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 doesn't create a special container like @State ; it simply captures a local variable via closures. When the view writes:

movies = newValue

...it's actually executing the set closure, which mutates copy.

And when you read in the test:

movies.wrappedValue

...you're executing the get closure, which returns that same copy. The view and the test share the same storage, since both use the captured variable.

The view's state logic is fully testable. Whoever builds it needs to provide it with its state:

import Movies   
import MoviesUI

// Composition Root
struct MovieListComposer: View {
     var movies = [Movie]()
    let loader: MovieLoader
    var body: some View {
    	MovieList(movies: $movies, loader: loader)
    }
}

We can also create a generic wrapper for views using this pattern:

struct Host<Initial, Content: View>: View {
    typealias Binding = SwiftUI.Binding<Initial>
    
     var state: Initial
    let content: (Binding) -> Content
    
    init(
        _ state: Initial, 
         content:  (Binding) -> Content
    ) {
        self.state = state
        self.content = content
    }
    var body: some View {
        content($state)
    }
}

struct SomeApp: App {
    var body: some Scene {
        WindowGroup {
            Host([Movie]()) {
                MovieList(movies: $0)
            }
        }
    }
}

Conclusions and Considerations

This pattern enables a high level of testability while preserving the simplicity of SwiftUI's declarative system, without intermediate layers.


You can read this article in spanish