0

I have a SwiftUI view with a view model associated to it.

struct BookmarksView: View {
    @StateObject private var viewModel = BookmarksViewModel()
    
    var body: some View {
        switch viewModel.viewState {
        case .empty:
            BookmarksEmptyView()
        case .content(let newsLetters):
            ListView(newsLetters: newsLetters)
        }
    }
}

With the PreviewProvider represented below I'm able to test just the empty case. Not the content one.

struct BookmarksView_Previews: PreviewProvider {
    static var previews: some View {
        BookmarksView()
    }
}

Are you able to suggest a way to test BookmarksView for both cases (i.e. empty and content)?

Thanks, Lorenzo

Lorenzo B
  • 33,216
  • 24
  • 116
  • 190

2 Answers2

2

You could create an init so you can inject the view model but use a default value so you don't need to use it normally.

init(viewModel: BookmarksViewModel = BookmarksViewModel() {
   _viewModel = StateObject(wrappedValue: viewModel)
}

Now you can create and inject an instance in your preview code

For previews I would add two static properties for creating different versions with different configurations of the view model to be used in the previews. I did this inside a #if DEBUG/#endif so they can't be used by mistake in a release build.

#if DEBUG
extension BookmarksViewModel {
    static let emptyState: BookmarksViewModel = {
        BookmarksViewModel(viewState: .empty)
    }()

    static let contentState: BookmarksViewModel = {
        let newsLetters = Newsletter.previews
        return BookmarksViewModel(viewState: .content(newsLetters))
    }()
}
#endif

Note that since I don't know how the view model is declared I made my own version

This can then be used directly in the previews

struct BookmarksViewEmpty_Previews: PreviewProvider {
    static var previews: some View {
        BookmarksView(viewModel: .emptyState)
    }
}

struct BookmarksViewContent_Previews: PreviewProvider {
    static var previews: some View {
        BookmarksView(viewModel: .contentState)
    }
}

The solution posted by OP is another good way to solve this since the sub-view is now decoupled from the view model which makes it much easier to create previews but there is no need to use @State properties in a preview since the properties will not change, instead we can create a binding using constant()

struct BookmarksViewEmpty_Previews: PreviewProvider {
    static var previews: some View {
        BookmarksView(viewState: .constant(.empty))
    }
}

struct BookmarksViewContent_Previews: PreviewProvider {
    static var previews: some View {
        BookmarksView(viewState: .constant(.content(Newsletter.previews)))
    }
}
Joakim Danielson
  • 43,251
  • 5
  • 22
  • 52
  • Your reply just answer to first part of my question. My overall goal is to have a previews where I can test empty and content cases. Should I go with a protocol or similar? Is there best practice to achieve that? – Lorenzo B Mar 11 '23 at 09:01
  • What do you mean by test in this context, just to view the UI in different states? And how would a protocol be used here? Perhaps you could clarify this in your question. – Joakim Danielson Mar 11 '23 at 10:24
  • Hello Joakim, I modified the question in order to clarify it. Hope it helps. – Lorenzo B Mar 11 '23 at 15:56
  • Hi Joakim, thank you very much for your detailed answer and thanks for pointing out to use `.constant` instead of `@State`. The only problem with the solution I provided is the following one (this is not related to previews): suppose an error state is available and the view associated to that state allows to perform a retry. Now the view view is not able anymore to "speak" with the view model. Hence a way to communicate from the view to the view model (e.g. `viewModel.refresh()`). – Lorenzo B Mar 13 '23 at 10:54
  • Introduce another State property that you pass to the sub-view and use an onChange modifier in the main view to react when this property changes – Joakim Danielson Mar 13 '23 at 11:23
1

The best way is to pass an instance of the view model into the view. You will be able to configure the view model as you want. To do that you need to add a custom init. But be careful with @StateObject (check my answer about @StateObject here for more details).

struct BookmarksView: View {
    @StateObject private var viewModel: BookmarksViewModel
    
    init(viewModel: @autoclosure @escaping () -> BookmarksViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel())
    }

    var body: some View {
        switch viewModel.viewState {
        case .empty:
            BookmarksEmptyView()
        case .content(let newsLetters):
            ListView(newsLetters: newsLetters)
        }
    }
}

And then:

struct BookmarksView_Previews: PreviewProvider {
    static var previews: some View {
        BookmarksView(
            viewModel: BookmarksViewModel(initialState: . content([...]))
        )
    }
}
Andrew Bogaevskyi
  • 2,251
  • 21
  • 25