Writing testable code when using SwiftUI
Discover page available: SwiftUIA major part of the challenge of architecting UI-focused code bases tends to come down to deciding where to draw the line between the code that needs to interact with the platform’s various UI frameworks, versus code that’s completely within our own app’s domain of logic.
That task might become especially tricky when working with SwiftUI, as so much of our UI-centric logic tends to wind up within our various View
declarations, which in turn often makes such code really difficult to verify using unit tests.
So, in this article, let’s take a look at how we could deal with that problem, and explore how to make UI-related logic fully testable — even when that logic is primarily used within SwiftUI-based views.
Logic intertwined with views
“You shouldn’t put business logic within your views”, is a piece of advice that’s often mentioned when discussing unit testing within the context of UI-based projects, such as iOS and Mac apps. However, in practice, that advice can sometimes be tricky to follow, as the most natural or intuitive place to put view-related logic is often within the views themselves.
As an example, let’s say that we’re working on an app that contains the following SendMessageView
. Although the actual message sending logic (and its associated networking) has already been abstracted using a MessageSender
protocol, all of the UI-specific logic related to sending messages is currently embedded right within our view:
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)
}
}
}
}
At first glance, the above might not look so bad. Our view isn’t massive by any stretch of the imagination, and the code is quite well-organized. However, unit testing that view’s logic would currently be incredibly difficult — as we’d have to find some way to spin up our view within our tests, then find its various UI controls (such as its “Send” button), and then figure out a way to trigger and observe those views ourselves.
Because we have to remember that SwiftUI views aren’t actual, concrete representations of the UI that we’re drawing on-screen, which can then be controlled and inspected as we wish. Instead, they’re ephemeral descriptions of what we want our various views to look like, which the system then renders and manages on our behalf.
So, although we could most likely find a way to unit test our SwiftUI views directly — ideally, we’ll probably want to verify our logic in a much more controlled, isolated environment.
One way to create such an isolated environment would be to extract all of the logic that we’re looking to test out from our views, and into objects and functions that are under our complete control — for example by using a view model. Here’s what such a view model could end up looking like if we were to move all of our message sending UI logic out from our 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
}
}
}
To learn more about constructing and using observable objects, check out this guide to SwiftUI’s state management system.
Our logic remains almost identical, but the above refactor does give us two quite significant benefits. First, we’ll now be able to unit test our code without having to worry about SwiftUI at all. And second, we’ll even be able to improve our SwiftUI view itself, as our view model now contains all of the logic that our view needs to decide how it should be rendered — making that UI code much simpler in the process:
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)
}
}
}
}
Fantastic! To now shift our focus to unit testing our code, we’re going to need two pieces of infrastructure before we can actually start writing our test cases. While those two pieces are not strictly required, they’re going to help us make our testing code so much simpler and easier to read.
Investing in utilities
First, let’s create a mocked implementation of our MessageSender
protocol, which will enable us to gain complete control over how messages are sent, as well as how errors are thrown during that process:
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) }
}
}
To learn more about Swift’s continuation system, check out “Connecting async/await to other Swift code”.
Next, since the code that we’re looking to verify is asynchronous, we’re going to need a way to wait for a given state to be entered before proceeding with our verifications. Since we don’t want to put any observation logic within our tests themselves, let’s extend XCTestCase
with a method that’ll let us wait until a given @Published
-marked property has been assigned a specific value:
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)
}
}
Above we’re using Apple’s Combine framework to observe the injected Published
property’s publisher (wow, that’s quite a tongue twister, isn’t it?). To learn more about Combine, and published properties in particular, check out this Discover page.
With those two pieces in place, we can now finally start writing the unit tests for our UI-related message sending logic! It might, at first, seem rather unnecessary to create all of that infrastructure just to be able to verify some simple pieces of logic — but the utilities that we’ve now created will really make our testing code much easier (and more enjoyable) to write.
It’s testing time!
Let’s start by verifying that our “Send” button will be correctly enabled and disabled based on whether the user has entered a message. To do that, we’ll start by setting up an XCTestCase
subclass for our tests, and we’ll then be able to easily simulate a message being entered simply by assigning a string to our view model’s message
property:
@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)
}
}
Note that we need to add the MainActor
attribute to both our test case itself, as well as to the setUp
method that we’re overriding from the XCTestCase
base class. Otherwise we wouldn’t be able to easily interact with our view model’s APIs, since those are also bound to the main actor. To learn more, check out this article.
Alright, our first test is done, but we’re just getting started. Next, let’s verify that the correct states are entered while sending a message — and this is where the two utilities that we built earlier (our MessageSenderMock
class and our waitUntil
method) will come very much in handy:
@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")
}
}
That’s really the power of investing in testing infrastructure like mocks and various utility functions — they let our testing methods remain completely linear, and free from cancellables, expectations, and other sources of complexity.
Let’s write one more test. This time, we’ll verify that our code behaves correctly when an error was encountered:
@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)
}
}
Just like that, we’ve now fully covered our UI-related message sending logic with unit tests — without having to actually attempt to unit test our SwiftUI view itself. As an added bonus, we also made our view code simpler in the process, and it should now be much easier to iterate on our view’s logic and styling completely separately.
Combine the above approach with a few UI tests, as well as manual testing, and we should be able to release new versions of our app with confidence.
Is MVVM required for testability?
Now, is the point of the above series of examples that all SwiftUI-based apps should completely adopt the MVVM (Model-View-ViewModel) architecture? No, absolutely not. Instead, the point is that the easiest way to unit test any kind of UI-related code (regardless of what UI framework that the code was originally written against) is most often to move that code out from whatever view that it’s being consumed in. That way, our logic is no longer tied to any specific UI framework, and we’re free to test and manage it however we’d like.
To further prove that this article isn’t about advocating for “MVVM all the things!”, let’s take a look at another example, in which using a view model would probably be quite unnecessary.
Here we’ve written an EventSelectionView
, which also has a significant piece of logic embedded within it — this time for deciding whether a given Event
should be auto-selected when the user taps a button:
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
})
}
}
}
}
Just like when we refactored our SendMessageView
earlier, one way to make the above logic testable would be to create another view model, and move our logic there. But, let’s take a different (more lightweight) approach this time, and instead move that logic into our Event
type itself:
extension Event {
var isSelectable: Bool {
guard isBookable else {
return false
}
guard participants.count < capacity else {
return false
}
return startDate > .now
}
}
After all, the above logic isn’t very UI-related at all (it doesn’t mutate any form of view state, and it just inspects properties that are owned by Event
itself), so it doesn’t really warrant the creation of a dedicated view model.
And, even without a view model, we can still fully test the above code, simply by creating and mutating an Event
value:
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)
}
}
Just like before, a big benefit of performing the above kind of logic extraction is that doing so also tends to make our SwiftUI-based code much simpler. Thanks to our new Event
extension, EventSelectionView
can now simply use Swift’s key path syntax to pick the first selectable event — like this:
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)
}
}
}
}
So, regardless of whether we choose to go for a view model, a simple model extension, or another kind of metaphor — if we can move the UI logic that we’re looking to test out from our views themselves, then those tests tend to be much easier to write and maintain.
Conclusion
So, how do I unit test my SwiftUI views? The answer is quite simply: I don’t. I also almost never test my UIView
implementations either. Instead, I focus on extracting all of the logic that I wish to test out from my views and into objects that are under my complete control. That way, I can spend less time fighting with Apple’s UI frameworks in order to make them unit testing-friendly, and more time writing solid, reliable tests.
I hope that this article has given you a few ideas on how you could make your SwiftUI-based code easier to test as well. If you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.
Thanks for reading!