使用SwiftUI时编写可测试的代码教程

684 阅读6分钟

架构以UI为重点的代码库的一个主要挑战是决定在需要与平台的各种UI框架互动的代码和完全属于我们自己的应用程序的逻辑领域的代码之间的界限。

在使用SwiftUI时,这项任务可能会变得特别棘手,因为很多以UI为中心的逻辑往往会在我们的各种View ,这反过来又使这些代码很难使用单元测试来验证。

因此,在这篇文章中,让我们看看如何处理这个问题,并探索如何使UI相关的逻辑完全可测试 - 即使该逻辑主要用于基于SwiftUI的视图中。

与视图交织在一起的逻辑

"你不应该把业务逻辑放在你的视图中",这是在讨论基于UI的项目(如iOS和Mac应用程序)的单元测试时经常提到的一个建议。然而,在实践中,这个建议有时是很难遵循的,因为与视图相关的逻辑最自然或最直观的地方往往是在视图本身。

举个例子,假设我们正在开发一个包含以下内容的应用程序:SendMessageView 。尽管实际的消息发送逻辑(及其相关的网络)已经使用MessageSender 协议进行了抽象,但所有与发送消息有关的特定UI逻辑目前都直接嵌入在我们的视图中:

struct SendMessageView: View {
    var sender: MessageSender

    @State private var message = ""
    @State private var isSending = false
    @State private var sendingError: Error?

    var body: some View {
        VStack {
            Text("Your message:")

            TextEditor(text: $message)

            Button(isSending ? "Sending..." : "Send") {
                isSending = true
                sendingError = nil
            
                Task {
                    do {
                        try await sender.sendMessage(message)
                        message = ""
                    } catch {
                        sendingError = error
                    }

                    isSending = false
                }
            }
            .disabled(isSending || message.isEmpty)

            if let error = sendingError {
                Text(error.localizedDescription)
                    .foregroundColor(.red)
            }
        }
    }
}

乍一看,上述情况可能看起来并不那么糟糕。我们的视图并不庞大,而且代码也很有条理。然而,目前对该视图的逻辑进行单元测试是非常困难的--因为我们必须在测试中找到一些方法来启动我们的视图,然后找到它的各种UI控件(比如它的 "发送 "按钮),然后找出一种方法来触发和观察这些视图本身。

因为我们必须记住,SwiftUI视图并不是我们在屏幕上绘制的UI的实际、具体的表示,然后可以按照我们的意愿进行控制和检查。相反,它们是我们希望我们的各种视图看起来像什么的短暂描述,然后由系统代表我们进行渲染和管理。

因此,尽管我们很可能找到一种方法来直接对我们的SwiftUI视图进行单元测试--理想情况下,我们可能希望在一个更加可控的、孤立的环境中验证我们的逻辑。

创建这样一个隔离环境的一个方法是将我们想要测试的所有逻辑从我们的视图中提取出来,放到我们完全控制的对象和函数中--例如通过使用一个视图模型。下面是这样一个视图模型,如果我们把所有的信息发送UI逻辑从我们的SendMessageView ,最终会是什么样子:

@MainActor class SendMessageViewModel: ObservableObject {
    @Published var message = ""
    @Published private(set) var errorText: String?
    
    var buttonTitle: String { isSending ? "Sending..." : "Send" }
    var isSendingDisabled: Bool { isSending || message.isEmpty }

    private let sender: MessageSender
    private var isSending = false

    init(sender: MessageSender) {
        self.sender = sender
    }

    func send() {
        guard !message.isEmpty else { return }
        guard !isSending else { return }

        isSending = true
        errorText = nil

        Task {
            do {
                try await sender.sendMessage(message)
                message = ""
            } catch {
                errorText = error.localizedDescription
            }

            isSending = false
        }
    }
}

我们的逻辑几乎保持不变,但上述重构确实给我们带来了两个相当重要的好处。首先,我们现在可以对我们的代码进行单元测试,而根本不用担心SwiftUI。其次,我们甚至能够改进我们的SwiftUI视图本身,因为我们的视图模型现在包含了我们的视图在决定如何呈现时所需要的所有逻辑--在这个过程中,UI代码变得更加简单:

struct SendMessageView: View {
    @ObservedObject var viewModel: SendMessageViewModel

    var body: some View {
        VStack(alignment: .leading) {
            Text("Your message:")

            TextEditor(text: $viewModel.message)

            Button(viewModel.buttonTitle) {
                viewModel.send()
            }
            .disabled(viewModel.isSendingDisabled)

            if let errorText = viewModel.errorText {
                Text(errorText).foregroundColor(.red)
            }
        }
    }
}

太棒了!现在我们要把注意力转移到代码的单元测试上,在我们真正开始编写测试用例之前,我们需要两块基础设施。虽然这两样东西不是严格要求的,但它们将帮助我们使我们的测试代码变得更加简单和容易阅读。

投资于公用事业

首先,让我们创建一个MessageSender 协议的模拟实现,这将使我们能够完全控制消息的发送方式,以及在此过程中如何抛出错误:

class MessageSenderMock: MessageSender {
    @Published private(set) var pendingMessageCount = 0
    private var pendingMessageContinuations = [CheckedContinuation<Void, Error>]()

    func sendMessage(_ message: String) async throws {
        return try await withCheckedThrowingContinuation { continuation in
            pendingMessageContinuations.append(continuation)
            pendingMessageCount += 1
        }
    }

    func sendPendingMessages() {
        let continuations = pendingMessageContinuations
        pendingMessageContinuations = []
        pendingMessageCount = 0
        continuations.forEach { $0.resume() }
    }

    func triggerError(_ error: Error) {
        let continuations = pendingMessageContinuations
        pendingMessageContinuations = []
        pendingMessageCount = 0
        continuations.forEach { $0.resume(throwing: error) }
    }
}

接下来,由于我们要验证的代码是异步的,我们需要一种方法来等待一个给定的状态进入,然后再进行验证。由于我们不想把任何观察逻辑放在我们的测试本身中,让我们用一个方法来扩展XCTestCase ,让我们等待,直到一个给定的@Published-marked属性被赋予一个特定的值:

extension XCTestCase {
    func waitUntil<T: Equatable>(
        _ propertyPublisher: Published<T>.Publisher,
        equals expectedValue: T,
        timeout: TimeInterval = 10,
        file: StaticString = #file,
        line: UInt = #line
    ) {
        let expectation = expectation(
            description: "Awaiting value \(expectedValue)"
        )
        
        var cancellable: AnyCancellable?

        cancellable = propertyPublisher
            .dropFirst()
            .first(where: { $0 == expectedValue })
            .sink { value in
                XCTAssertEqual(value, expectedValue, file: file, line: line)
                cancellable?.cancel()
                expectation.fulfill()
            }

        waitForExpectations(timeout: timeout, handler: nil)
    }
}

上面我们使用苹果的Combine框架来观察注入的Published 属性的发布者(哇,这真是个绕口令,不是吗?)。要了解更多关于Combine的信息,特别是发布的属性,请查看这个Discover页面

有了这两个部分,我们现在终于可以开始为我们的用户界面相关的消息发送逻辑编写单元测试了起初,创建所有的基础设施只是为了能够验证一些简单的逻辑片断,这似乎是相当不必要的--但是我们现在创建的实用程序将真正使我们的测试代码更容易(和更愉快)地编写。

现在是测试时间!

让我们先验证一下我们的 "发送 "按钮是否能根据用户是否输入了信息而正确地启用和禁用。要做到这一点,我们将首先为我们的测试设置一个XCTestCase 子类,然后我们将能够轻松地模拟一个正在输入的消息,只需将一个字符串分配给我们的视图模型的message 属性即可。

@MainActor class SendMessageViewModelTests: XCTestCase {
    private var sender: MessageSenderMock!
    private var viewModel: SendMessageViewModel!

    @MainActor override func setUp() {
        super.setUp()
        sender = MessageSenderMock()
        viewModel = SendMessageViewModel(sender: sender)
    }

    func testSendingDisabledWhileMessageIsEmpty() {
        XCTAssertTrue(viewModel.isSendingDisabled)
        viewModel.message = "Message"
        XCTAssertFalse(viewModel.isSendingDisabled)
        viewModel.message = ""
        XCTAssertTrue(viewModel.isSendingDisabled)
    }
}

注意,我们需要将MainActor 属性添加到我们的测试用例本身,以及我们从XCTestCase 基类重载的setUp 方法。否则,我们将不能很容易地与我们的视图模型的API进行交互,因为这些也被绑定到主角色上。要了解更多,请看这篇文章

好了,我们的第一个测试已经完成,但我们才刚刚开始。接下来,让我们验证一下在发送消息时是否进入了正确的状态--这就是我们之前建立的两个工具(我们的MessageSenderMock 类和我们的waitUntil 方法)将非常有用的地方了。

@MainActor class SendMessageViewModelTests: XCTestCase {
    ...

    func testSuccessfullySendingMessage() {
        // First, start sending a message, and verify the current state:
        viewModel.message = "Message"
        viewModel.send()
        waitUntil(sender.$pendingMessageCount, equals: 1)

        XCTAssertEqual(viewModel.buttonTitle, "Sending...")
        XCTAssertTrue(viewModel.isSendingDisabled)

        // Then, finish sending the message, and verify the end state:
        sender.sendPendingMessages()
        waitUntil(viewModel.$message, equals: "")

        XCTAssertEqual(viewModel.buttonTitle, "Send")
    }
}

这就是投资于测试基础设施的力量,比如mock和各种实用功能--它们让我们的测试方法保持完全的线性,并且不受可取消性、期望和其他复杂源的影响。

让我们再写一个测试。这一次,我们将验证我们的代码在遇到错误时的行为是否正确:

@MainActor class SendMessageViewModelTests: XCTestCase {
    ...

    func testHandlingMessageSendingError() {
        // First, start sending a message:
        viewModel.message = "Message"
        viewModel.send()
        waitUntil(sender.$pendingMessageCount, equals: 1)

        // Then, make the sender throw an error and verify it:
        let error = URLError(.badServerResponse)
        sender.triggerError(error)
        waitUntil(viewModel.$errorText, equals: error.localizedDescription)

        XCTAssertEqual(viewModel.message, "Message")
        XCTAssertEqual(viewModel.buttonTitle, "Send")
        XCTAssertFalse(viewModel.isSendingDisabled)
    }
}

就这样,我们现在已经用单元测试完全覆盖了我们的UI相关的消息发送逻辑--而不需要实际尝试对我们的SwiftUI视图本身进行单元测试。作为额外的奖励,我们在这个过程中也使我们的视图代码变得更简单,现在应该更容易完全独立地迭代我们的视图的逻辑和风格。

将上述方法与一些UI测试以及手动测试结合起来,我们应该能够自信地发布我们应用程序的新版本。

MVVM对于可测试性是必需的吗?

现在,上述一系列例子的重点是所有基于SwiftUI的应用程序都应该完全采用MVVM*(模型-视图-视图模型*)架构吗?不,绝对不是。相反,重点是对任何类型的UI相关代码进行单元测试的最简单方法(无论代码最初是针对什么UI框架编写的),通常是将代码从它被消费的视图中移出。这样一来,我们的逻辑就不再被任何特定的UI框架所束缚,我们可以自由地测试和管理它。

为了进一步证明这篇文章并不是在倡导 "MVVM所有的东西!",让我们看一下另一个例子,在这个例子中,使用视图模型可能是非常不必要的。

在这里,我们写了一个EventSelectionView ,它也有一个重要的逻辑嵌入其中--这次是为了决定当用户点击一个按钮时是否应该自动选择一个给定的Event

struct EventSelectionView: View {
    var events: [Event]
    @Binding var selection: Event?

    var body: some View {
        List(events) { event in
            ...
        }
        .toolbar {
            Button("Select next available") {
                selection = events.first(where: { event in
                    guard event.isBookable else {
                        return false
                    }

                    guard event.participants.count < event.capacity else {
                        return false
                    }

                    return event.startDate > .now
                })
            }
        }
    }
}

就像我们之前重构我们的SendMessageView ,使上述逻辑可测试的一种方法是创建另一个视图模型,并将我们的逻辑移到那里。但是,这次让我们采取一种不同的(更轻量级的)方法,而把逻辑移到我们的Event 类型本身:

extension Event {
    var isSelectable: Bool {
        guard isBookable else {
            return false
        }

        guard participants.count < capacity else {
            return false
        }

        return startDate > .now
    }
}

毕竟,上述逻辑与UI完全不相关(它不改变任何形式的视图状态,而且它只是检查Event 本身拥有的属性),所以它并不真正需要创建一个专门的视图模型。

而且,即使没有视图模型,我们仍然可以完全测试上述代码,只需创建和改变一个Event 的值:

class EventTests: XCTestCase {
    private var event: Event!

    override func setUp() {
        super.setUp()

        event = Event(
            id: UUID(),
            capacity: 1,
            isBookable: true,
            startDate: .distantFuture
        )
    }

    func testEventIsSelectableByDefault() {
        XCTAssertTrue(event.isSelectable)
    }

    func testUnBookableEventIsNotSelectable() {
        event.isBookable = false
        XCTAssertFalse(event.isSelectable)
    }

    func testFullyBookedEventIsNotSelectable() {
        event.participants = [.stub()]
        XCTAssertFalse(event.isSelectable)
    }

    func testPastEventIsNotSelectable() {
        event.startDate = .distantPast
        XCTAssertFalse(event.isSelectable)
    }
}

就像以前一样,执行上述逻辑提取的一大好处是,这样做也往往会使我们基于SwiftUI的代码变得更加简单。由于我们新的Event 扩展,EventSelectionView 现在可以简单地使用Swift的关键路径语法来选择第一个可选择的事件--像这样:

struct EventSelectionView: View {
    var events: [Event]
    @Binding var selection: Event?

    var body: some View {
        List(events) { event in
            ...
        }
        .toolbar {
            Button("Select next available") {
                selection = events.first(where: \.isSelectable)
            }
        }
    }
}

所以,无论我们选择的是视图模型、简单的模型扩展,还是其他类型的隐喻--如果我们能把我们想要测试的UI逻辑从视图本身移出来,那么这些测试往往更容易编写和维护。

总结

那么,我如何对我的SwiftUI视图进行单元测试?答案很简单:我不做。我也几乎从不测试我的UIView 实现。相反,我专注于把我想测试的所有逻辑从我的视图中提取出来,放到我完全控制的对象中。这样一来,我就可以花更少的时间与苹果的UI框架作斗争,以使它们有利于单元测试,而把更多的时间用于编写坚实可靠的测试。

谢谢你的阅读!