2

My SwiftUI app consists of different views with their own view models which handle data fetching etc. This works fine in the simulator, but I'd really like to be able to use previews and have each view appear in it's different state (loading, success, error etc.)

Here's a simple example of how a page might be built:

import SwiftUI
import Observation

@Observable
class TestViewModel {
    enum State {
        case loading
        case success
    }
    
    private(set) var state = State.loading
    
    func loadData() async {
        // Make async network request here and update state
    }
}

struct TestView: View {
    
    @State private var viewModel = TestViewModel()
    
    var body: some View {
        Group {
            switch viewModel.state {
            case .loading: Text("Loading...")
            case .success: Text("Success!")
            }
        }.task {
            await viewModel.loadData()
        }
    }
}

#Preview {
    TestView()
}

How would I go about mocking the view model/network response for previews?

5
  • You can define a network-handler like model and pass it to the test model. Then you can pass a mock-network-handler when needed. Commented Jan 21 at 8:31
  • You’re mixing up the model and controller. @Observable Is for model data, its only job is to manage mutable data. The async func for loading should go in a protocol and that can be implemented by 2 controller structs. Loading funcs don’t have any mutable state so shouldn’t be in a class. Commented Jan 21 at 10:48
  • @malhal I'm not sure I follow, would you be able to elaborate please? If i have a view model, how would I get it to fetch data if the loading func doesn't live there? And isn't @Observable managing mutable state here (because of the state property)? Apologies if I'm missing something obvious Commented Jan 21 at 11:23
  • With "Dependency Injection" Commented Jan 21 at 11:45
  • @ADB sure, I added example code as an answer below Commented Jan 21 at 12:18

3 Answers 3

1

You can do something similar to what this person did in this question.

Separate the concerns. Extract the "fetching data" part to another object (let's call this DataSource).

@Observable
@MainActor
class TestViewModel {
    enum State {
        case loading
        case success
    }
    
    private(set) var state = State.loading
    
    func loadData(from dataSource: any DataSource) async {
        let data = await dataSource.fetch()
        // do something with "data"...
        state = .success
    }
}

@MainActor
protocol DataSource {
    // this should probably be "throws" as well, but for the sake of simplicity...
    func fetch() async -> Data
}

struct MockDataSource: DataSource {
    func fetch() async -> Data {
        Data("Some Fake Data".utf8)
    }
}

class RealDataSource: DataSource {
    func fetch() async -> Data {
        // ...
    }
}

Then you can put this into the EnvironmentValues, with the default value being the mock.

extension EnvironmentValues {
    @Entry var dataSource: any DataSource = MockDataSource()
}

In your view, you can then do:

@State private var viewModel = TestViewModel()
@Environment(\.dataSource) private var dataSource

var body: some View {
    Group {
        switch viewModel.state {
        case .loading: Text("Loading...")
        case .success: Text("Success!")
        }
    }.task {
        await viewModel.loadData(from: dataSource)
    }
}

Since the default value of the environment value is the mock, you don't need to do anything extra in your preview. Outside of previews, you should write .environment(\.dataSource, RealDataSource()) somewhere.

Sign up to request clarification or add additional context in comments.

Comments

1

Normally you wouldn't mock the view data for Previews you would mock the controller instead. This way the view data is the same for both but the way it is loaded is different. Also the cool thing about .task is you don't need any object to manage the lifetime of the asynchronous work, it has its own internal object for that. So all you need is .task, @State and an async func to call. Putting all this together would be something like:

import SwiftUI
import Observation

protocol Controller {
    func loadData() async -> [Item]
}

struct AppController: Controller {
   func loadData() async -> [Item] {
       return real data
   }
}

struct PreviewController: Controller {
   func loadData() async -> [Item] {
       return sample data
   }
}

extension EnvironmentValues {
    @Entry var controller: Controller = AppController()
}

struct TestViewModel: View {
    
    enum LoadingState {
        case loading
        case success(items: [Item])

        var text: String {
            switch self {
                case .loading: "Loading..."
                case .success: "Success!"
            }
        }
    }
    
    @State var state = LoadingState.loading
    
    @Environment(\.controller) var controller // defaults to AppController

    var body: some View {
        Text(state.text)
        .task {
            let items = await controller.loadData()
            state = .success(items)
        }
    }
}

// Switches AppController for PreviewController
#Preview {
    TestViewModel()
        .environment(\.controller, PreviewController())
}

And by the way in your code you used @State with a class, that's a heap memory leak, it's only designed for value types.

Comments

0

You can add an init and then inject another view model for the preview that is either a sub class of your current view model or you can create a protocol that your view model and any mock object conforms to.

Below is an example using the protocol approach

protocol DataLoading: Observable {
    var state: TestViewModel.State { get }
    func loadData() async -> Void
}

@Observable
class TestViewModel: DataLoading {
    //...
}

struct TestView: View {
    @State private var viewModel: DataLoading
    
    init(viewModel: DataLoading = TestViewModel()) {
        self.viewModel = viewModel
    }

    //...
}

And then you can create a mock like this

@Observable
class MockViewModel: DataLoading {
    private(set) var state = TestViewModel.State.loading
    func loadData() async {
        try? await Task.sleep(for: .milliseconds(300))
        state = .success
    }
}

If you do this then I would strongly suggest you move the enum declaration outside of the view model declaration

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.