SwiftUI unit test without MVVM

What if there’s no view

Jim Lai
12 min readMay 7

--

What about testing?

Every MVVM dev asks this question as if they were TDD titans.

Yet the dumbest, most brute-force tests I’ve seen are from these MVVM TDD titans.

At some point they forgot that a good design must come before unit test. If you can’t write highly refactored code, you can’t write highly refactored test, and due to your over-emphasized focus on testing, you will retroactively damage your design to fit your dumb shit test.

I’m going to look at two articles here. One from a recent MVVM unit test tutorial, the other from google top result of SwiftUI unit testing.

Is there a REFACTOR in your MVVM + clean architecture + TDD + DI + coordinator?

Saw this “Testable Code and Unit Testing in SwiftUI” on recommended.

What’s the design problem in this snippet?

final class HomeViewModel: BaseViewModel<HomeViewStates> {
let service: NewsServiceable
var showingAlert: Bool

@Published private(set) var allNews: [Article]

init(service: NewsServiceable) {
self.service = service
self.allNews = []
self.showingAlert = false
}

func serviceInitialize() {
fetchNews()
}

func changeStateToEmpty() {
changeState(.empty)
}

private func fetchNews() {
changeState(.loading)
Task { [weak self] in
guard let self = self else { return }
let result = await self.service.fetchAllNews(country: .us)
self.changeState(.finished)
switch result {
case .success(let success):
guard let articles = success.articles else { return }
DispatchQueue.main.async {
self.allNews = articles
}
case .failure(let failure):
self.changeState(.error(error: failure.customMessage))
self.showingAlert.toggle()
}
}
}
}

His fetch call is not refactored. Did you seriously repeat all these for EVERY endpoint?

Rename HomeViewModelto NewsAPIService which inherits NetworkService , then it becomes a boilerplate brute force fetcher.

So what is it that you thnk MVVM is bringing to the table?

Oh the unit test of course!

TDD Titan

Let’s see what google top result for “SwiftUI unit test” says about this.

Writing testable code when using SwiftUI.

I want to start with this legendary (as in retarded) interpretation from MVVM devs:

… 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:

No. It is NOT.

There are two components that are supposed to make UI code much simpler.

  1. Dumping every property to a sink object called view model.
  2. Use computed property from view model, e.g.; viewModel.isSendingDisabled

Note that you are NOT reducing the number of properties used nor simplifying computed properties.

You are just moving things so you don’t see it.

That doesn’t mean it’s not there.

By this logic, why not conforming to Viewin another file? E.g.;

struct SendMessage {
@State var message = ""
@State var errorText: String?
@State var isSending = false

// nothing stopping you to use computed property here
var buttonTitle: String { isSending ? "Sending..." : "Send" }
var isSendingDisabled: Bool { isSending || message.isEmpty }

private let sender: MessageSender
}

// another file
extension SendMessage: View {
// ... var body
// without redundant viewModel prefix
// ... Button(buttonTitle)
}

Can I say this makes UI code much simpler too?

This way I don’t need a sink object and extra overheads, and better yet I don’t need to sacrifice the usage of @State, @EnvironmentObject just so it looks simpler.

Oh shit, what about testing? Damn. These TDD titans.

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.

To his second point, all of the logic is not always a good thing; there are other factors to consider, e.g.; reuse, refactor, composition… etc. There’s nothing in the argument that explains why you can only have ONE view model. Say I want to refactor out networking. That is one observable. Then I want to refactor out view processing logic, which can be another independent observable; I can also throw in shared environment object, which is another observable. Each of which can be unit tested even according to his test logic.

In fact, since anything can conform to ObservableObject , what MVVM asked you to do is to use exactly ONE observable. It’s not like you have to create binding manually and build around it. SwiftUI took care of that.

To his first point, let’s ask what it is that he is worrying about.

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.

  1. Spin up our view within our tests

You mean var model = SendMessage() ? Yes there are problems with @State not working in unit test. But this is how you “spin up a view” in a test regardless. It’s not like you need to load from a nib and setup @IBObservable like good old days. Are you applying MVVM for obj-c to SwiftUI?

2. Find its various UI controls

This is very reference type thinking. Get used to the idea that you can no longer holds a reference to UI object. In this case he wants to find “Send” button, which is described via binding. This means you can instead find buttonTitle because it binds to “Send” button. They are now two sides of the same coin.

3. Figure out a way to trigger and observe those views ourselves

This is his MVVM unit test utility function

extension XCTestCase {
func waitUntil<T: Equatable>(
_ propertyPublisher: Published<T>.Publisher,
equals expectedValue: T,
timeout: TimeInterval = 10,
file: StaticString = #file,
line: UInt = #line
) { ... }

Which is to setup a trigger and observe @Published view properties.

In fact, why bother with publisher when you have async-await? E.g.;

try await viewModel.send() // if you can have some kind of timeout
assert(viewModel.message == "")

I’m guessing here because I haven’t been up-to-date with SwiftUI. But regardless, what efforts does he really save? He has to setup trigger and observe one way or another.

Obvisouly I need to look more closely. So here is one of his more detailed arguments:

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.

You know what else is an ephemeral description?

A test. Wouldn’t it make more sense that if you can make “view” an ephemeral description such that they can be created and destroyed on demand, e.g.; like a value type, it would help simplify the test process? Since you don’t need to worry about setting up @IBObservable from storyboard?

But somehow this is a negative to him? He needs a much more controlled, isolated environment. Why is this not qualified as one?

struct SendMessage: View {
@State var message = ""
@State var errorText: String?
@State var isSending = false

// nothing stopping you to use computed property here
var buttonTitle: String { isSending ? "Sending..." : "Send" }
var isSendingDisabled: Bool { isSending || message.isEmpty }

private let sender: MessageSender
// var body...
}

OK. @State is not working in unit test. But your app contains more than just unit tests. Does this qualify as a controlled, isolated environment in production code?

Everything in this is created by you. i.e.; custom model type. @State properties do not hinder you from using them as a value type.

Can you test buttonTitle ? Ideally we could, if @State were a regular property wrapper.

var m = SendMessage()
assert(m.buttonTitle == "Send")
m.message = "test"
assert(m.message == "test")

But it is not. @State is so special that it cannot be mutated in unit test.

Thank God, because otherwise it shows all MVVM devs are idiots.

But because of this caveat, we have no choice but to at least consider some merits of MVVM, right?

This is exactly what this guy did. He even emphasized it:

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.

This is funny. When someone said there are other good architectures besides MVVM, it means he didn’t know shit about other architectures and is about to pump MVVM.

When someone wrote a view model unit test tutorial on his own website, you better believe his intention is NOT to PUMP MVVM.

But no. MVVM is just lazy. Of course it will be simpler to dump everything in one reference type object, which defeats the whole purpose of immutability; and do I really need to remind these MVVM TDD titans who need to inject a new class for each mock data that you shouldn’t CRIPPLE your design, i.e.; ONE observable only for what? So you can write unit tests easier? If that were true, first thing to dump is the DI dumb shit.

Let’s now compare it to POP.

POP and Value type

Recall that Swift is protocol-oriented. E.g.;

protocol Sender {
var message: String {get set}
var errorText: String? {get set}
var isSending: Bool {get set}
let sender: MessageSender {get set}
}
extension Sender {
var buttonTitle: String { isSending ? "Sending..." : "Send" }
var isSendingDisabled: Bool { isSending || message.isEmpty }
}

The common misconception is that you use it as a requirement.

No. You make it a mix-in, i.e.; a piece of self-contained working code.

So you will provide the behavior here, in protocol extension.

extension Sender {
func send(...) {
...
}
}

Anything you may need, you need to include it in protocol requirement to keep it self-contained. You need to find a balance to keep the protocol manageable, so as to NOT CONTAIN EVERYTHING.

See the difference in thinking?

We embrace immutability, state changes must be declared mutating ;The goal is to keep things small; and you are limited to the protocol extension sandbox; anything you require must be declared as requirement and can be easily spotted. It’s insanely hard to provide default initializer, so there’s no DI dumb shit. Since you are working with value type, there’s no point doing inheritance. Since you want to keep things small, it encourages composition.

Let’s say you’ve finished implementing it. What’s next? Conform to it.

struct SendMessage: Sender {...}

What’s next? Provide binding.

extension SendMessage: View {...}

So now it can be used to render view.

What’s next? Unit test.

class SenderMock: Sender {
// protocol requirements are what you mock
// default implementations are what you test
}

Wait for auto-complete, then enter mock data. Then write your tests.

Obviously we have some overheads of creating variables, even with auto-complete. But what are we comparing to? Dumb shit DI that doesn’t know how to use variable? That has to create a new class for each mock data? And inject it via type-erased protocol?

Or MVVM that creates a SHIT TON of MUTABLE REPOSITORY? Creating dedicated mock class and variables is where you draw the line?

These mock classes are not even necessary. Simple computed properties can be verified using @State even when they are immutable, e.g.;

var m = SendMessage(isSending: false, message: "")
assert(m.isSendingDisabled == true)

Or you can test multiple protocols in one mock class. We can do composition, remember?

If I were SwiftUI SDK lead designer, I’ll be confused. Because I told you to think in protocol first, design SDK around value type, give you automatic binding by conforming to @ObservableObject which can be conformed by anything. And these TDD titans decided to ignore all of these and crippled themselves by using ONE REFERENCE TYPE SINK OBJECT ONLY.

Do you know how VALUE type affects MVVM? I want my model be immutable. No more REPOSITORY shit. No more view model shit. Is it a MODEL or not? If it is not value type then why not? What are you hidding?

With this POP approach, Control / State changes are described in some protocol extension somewhere. Model is immutable except explicit state changes. Binding is described in some protocol extension somewhere. There are no “views” because there are no view properties, e.g.; backgroundColor .

It’s not even model-view binding if you think about it. Say you map buttonTitle to Button(buttonTitle) , is Button a “view”? It may as well be a model as long as SDK knows how to render it; and there are no “view” properties that you can acess.

It can be “viewed” as a model-model binding. Of all people, who are the idiots that keep insisting there’s a view and we need to remove everything from view?

Let’s look at his example of showing “you don’t always need a view model”.

He wants to be able to testevent.isBookable , event.startData > .now … etc.

Ironically, this is where you use dependency injection. Because usually you want to avoid hard-coded condition checks. Not the massive initializer dumb shit. A events.first(where: {event in event.isSelectable}) will do.

This is just basic refactor, not “it’s in the view so it’s hard to test”.

Let’s change it a bit. Say selection = special() . And you want to test special() .

Let’s “view” this as a model.

struct EventSelection: View {
var events: [Event]
func special() -> Event {...}
// ...
// Button("Select next available") { selection = special() }
}

How do we test it?

var m = EventSelection()
m.events = mockEvents
// assert(m.special() == ...)

Because var events is not a @State. It’s possible if it’s not view-related data.

But maybe this is a typo, so what if it is a @State?

var m = EventSelection(events: mockEvents)
// assert(m.special() == ...)

You need to recreate the variable for every mock data. But it’s doable for simple checks.

You can extend special()from somewhere else if you really really, really need the extra space for some reason. Again, just because you don’t see it doesn’t mean it’s not there. Your code is not simplified.

I don’t know why this “makes UI look simpler” is such an important metric for MVVM devs. You will never hear them say “yeah, my UI may look simpler, but my sink object is fuk”.

So what’s the problem you think MVVM is “solving”?

Irrational fear of “view”?

Let’s wrap up.

There’s no view

Young Monk: “Do not try and bend the spoon — that’s impossible. Instead, only try to realize the truth.”

Neo: “What truth?”

Young Monk: “There is no spoon.”

There’s no view in SwiftUI. You have model. You have model-model binding. SwiftUI renders view from model. This is the simplest interpretation.

You can see it as a “view” in UIView sense, as long as it gives you benefits in design.

But what benefits does it give you? As compared to where there’s no view?

You lost @EnvironmentObject for what? Unit test? Did they cover don’t cripple your design in MVVM master class?

It’s not like you can’t otherwise, as I’ve just shown. With arguably fewer drawbacks and acceptable overheads.

His conclusion:

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.

  1. It says nothing about why you can only have ONE observable. You can divide / refactor all of the logic.
  2. His method of gaining complete control is to fight Apple’s UI frameworks, e.g.; self-inflicted damage like avoiding using “View” that he himself named. (Apple only requires you to conform to View, not naming it)
  3. Solid, reliable tests are nice to have if you can write them. But your job is not writing unit tests! Note that there’s never any discussion as to how you are going to refactor that all of the logic. Like once you put all logic in one place, you are done. You haven’t even begun to refactor, man. Learn how to refactor before you claim to be a TDD titan!

Oh, I was going to criticize the shit out of the other article. But it all came from shit refactor; and it’s hard to argue if a test is good or not. I have a different advice to give:

Spend less time trying to write unit tests, spend more time refactoring your shit.

--

--