Giter Club home page Giter Club logo

testable-view-swiftui's Introduction

Testable SwiftUI views using async/await

  • This example demonstrates how to test any SwiftUI.View without hacks or third-party libraries, accomplished in just a few minutes of coding.
  • We compare SwiftUI and MVVM and show that the Observable view-model approach may not be the best fit for SwiftUI, as it doesn't align well with its paradigm.

Short overview of the code:

SwiftUI.View is just a protocol that only value types can conform to, its centerpiece being the body property, which produces another SwiftUI.View.

It lacks typical view properties like frame or color. This implies that SwiftUI.View isn't a traditional view.

SwiftUI.View looks and acts more like a view-model. Understanding this is key to grasping the essence of SwiftUI.View.

SwiftUI model vs MVVM view-model

Anyone claiming how Apple coupled view and business logic is wrong. Apple just used View conformance on top of the model. That is not coupling. That is POP.

In SwiftUI it is up to you what goes to model and what goes to View conformance extension. Don't blame Apple if your code is entangled.

MVVM uses HARD decoupling which is more suitable for Java and other old-school languages.

We start with two similar implementations of our business logic:

struct ContentModel {
    @State var sheetShown = false
    @State var counter = 0
    func increase() { counter += 1 }
    func showSheet() { sheetShown.toggle() }
}

@Observable final class ContentViewModel {
    var sheetShown = false
    var counter = 0
    func increase() { counter += 1 }
    func showSheet() { sheetShown.toggle() }
}

Model conformance vs VM composition

We can make two view variants, one is SwiftUI and the other is MVVM. One uses conformance and the other uses composition.

extension ContentModel: View {
    var body: some View {
        let _ = assert(bodyAssertion) // this is the only line in the view for testing support
        VStack {
            Text("The counter value is \(counter)")
            Button("Increase", action: increase)
            Button("Show sheet", action: showSheet)
        }
        .sheet(isPresented: $sheetShown) {
            Sheet()
        }
    }
}

struct ContentView: View {
    @State var vm = ContentViewModel()
    var body: some View {
        let _ = assert(bodyAssertion) // this is the only line in the view for testing support
        VStack {
            Text("The counter value is \(vm.counter)")
            Button("Increase", action: vm.increase)
            Button("Show sheet", action: vm.showSheet)
        }
        .sheet(isPresented: $vm.sheetShown) {
            Sheet()
        }
    }
}

Native async/await testing

App hosting

The key for view testing is to host the View in some App.

We need to share the state, that is the hosted view, between the main-target and the test-target:

struct TestApp: App {
    static var shared: Self!
    @State var view: any View = EmptyView()
    var body: some Scene {
        let _ = Self.shared = self
        WindowGroup {
            AnyView(view)
        }
    }
}

Body evaluation notifications

We need to notify the test function that body evaluation happened. To achieve this is we add let _ = assert(bodyAssertion) as the first line of the body.

NOTE: Assertion does not evaluate in release! We dont need #if DEBUG ...

Tests

We can test both SwiftUI and MVVM versions in the same way, no hacking, no third party libs.

We receive body-evaluation index and the view itself as an async sequence from our 30-lines of code "framework" so we can test if the evaluations behave like we intended:

func testContenModel() async throws {
    TestApp.shared.view = ContentModel()
    for await (index, view) in ContentModel.bodyEvaluations().prefix(2) {
        switch index {
        case 0:
            XCTAssertEqual(view.counter, 0)
            view.increase()
        case 1:
            XCTAssertEqual(view.counter, 1)
            view.showSheet()
        default: break
        }
    }
}

func testContentView() async throws {
    TestApp.shared.view = ContentView()
    for await (index, view) in ContentView.bodyEvaluations().prefix(2) {
        switch index {
        case 0:
            XCTAssertEqual(view.vm.counter, 0)
            view.vm.increase()
        case 1:
            XCTAssertEqual(view.vm.counter, 1)
            view.vm.showSheet()
        default: break
        }
    }
}

Testing UI interactions using ViewInspector

Testing the body function using tools like ViewInspector, in conjunction with our native testing approach, allows us to interact with SwiftUI elements and to verify their values with each interaction.

Tests are identical for both SwiftUI and MVVM:

switch index {
case 0:
    _ = try view.inspect().find(text: "The counter value is 0")
    try view.inspect().find(button: "Increase").tap()
case 1:
    _ = try view.inspect().find(text: "The counter value is 1")
    try view.inspect().find(button: "Show sheet").tap()
default: break
}

Body evaluations during the ViewInspector test

Test findings spotlight a disparity in number of body evaluations.

MVVM approach necessitates more evaluations of the view’s body, underscoring a potential inefficiency in how MVVM patterns integrate with SwiftUI’s rendering cycle.

2 body evaluations using SwiftUI

ContentModel: @self, @identity, _sheetShown, _counter changed.
ContentModel: _counter changed.

3 body evaluations using MVVM

ContentView: @self, @identity, _vm changed.
ContentView: @dependencies changed.
ContentView: @dependencies changed.

Design flaws of MVVM in SwiftUI

  • My biggest issue with MVVM is inability to use native property wrappers like @Environment, @AppStorage, @Query and others.
  • View-models are not composable, while SwiftUI models(views) are very easy to split and reuse. MVVM just leads us to massive views and massive view-models. Its harder to split to smaller components. You need double amount of work to split them. You need to split VM and the View each on its own.
  • Another problem with MVVM is usage of reference types. Using [weak self] everywhere is so annoying and misuse can lead to reference cycles.

Now that we know how to test "views" there is really no need to use MVVM.

testable-view-swiftui's People

Contributors

sisoje avatar

Stargazers

Alok Jha avatar Mitch Chapman avatar Slava avatar Hai Feng Kao avatar  avatar Michael avatar Konstantin Zabelin avatar ali zaenal abidin avatar Fat Shady avatar Mustafa Hastürk avatar Jeffrey avatar daqian avatar Agripino Gabriel avatar Hal Mueller avatar davidtam avatar Jérôme Figueiredo avatar David Peak avatar Ihar Khadorchanka avatar Anton Begehr avatar Eric Sean Conner avatar

Watchers

Mitch Chapman avatar  avatar Bevan christian avatar

testable-view-swiftui's Issues

How would you test a .task ?

How would I test an async task in a View which just shows some simple data, loaded from an api?

.task {
   let result = await api.loadData()
   self.data = result
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.