.st0{fill:#FFFFFF;}

SwiftUI Testing Challenge: Can You Test This Counter App? 

 January 21, 2025

by Jon Reid

8 COMMENTS

How do we automate tests for SwiftUI? Should we even bother? And does test-driven development (TDD) apply to SwiftUI?

SwiftUI Testing: The Challenge

In my TDD for iOS Workshop, participants start TDD by coding something without UI, networking, or persistence. I then introduce testing through the UI. The key idea is to use fast unit tests for UI-driven code instead of slow UI tests. People are often blown away at how easy and effective it is.

Code snippet sample

Improve your test writing “Flow.”

Sign up to get my test-oriented code snippets.

I’ve taught this many times using UIKit. What would it look like for SwiftUI? Well, what if I take the same exercise I’ve used and port it to SwiftUI? So here we go!

The challenge is to build a Counter app. “What’s that?” you may ask. Why, it’s a useful app for counting things.

Counter Demo

Here’s a demo of what it can look like. (This is the prettiest version I’ve ever made, thanks to suggestions from the Claude LLM.)

As you can see, the idea is simple. There’s a count and two buttons. One button increments. The other button lets you clear the count, but uses an alert to confirm this. After all, it would be a bummer to reset the count by mistake.

Counter Source Code

Here’s one way to code the main guts of the Counter:

struct CounterView: View {
    @State private var count = 0
    @State private var showingClearAlert = false
  
    var body: some View {
        VStack(spacing: 24) {
            Text("\(count)")
                .font(.system(size: 96, weight: .bold, design: .rounded))
                .foregroundStyle(.primary)
                .contentTransition(.numericText(countsDown: count == 0))
                .animation(.spring(), value: count)
            HStack(spacing: 16) {
                Button(action: { count += 1 }) {
                    Label("1", systemImage: "plus.circle.fill")
                        .font(.title2.weight(.semibold))
                        .frame(maxWidth: .infinity)
                        .padding()
                }
                .buttonStyle(.borderedProminent)
                .tint(.green)
                Button(action: { showingClearAlert = count > 0 }) {
                    Label("Clear", systemImage: "trash")
                        .font(.title2.weight(.semibold))
                        .frame(maxWidth: .infinity)
                        .padding()
                }
                .buttonStyle(.bordered)
                .tint(.red)
                .opacity(count > 0 ? 1 : 0.5)
            }
        }
        .padding()
        .alert("Clear Counter?", isPresented: $showingClearAlert) {
            Button("Cancel", role: .cancel) { }
            Button("Clear", role: .destructive) {
                count = 0
            }
        } message: {
            Text("This will reset the counter to zero.")
        }
    }
}

Take a look through this code. Then ask yourself…

How Would You Test This?

Before I show you my approach, I’d love to hear your thoughts:

  • Would you unit test this? Why or why not?
  • If you were to unit test this, how would you do it?
  • What parts are worth testing, and what parts aren’t?
  • If you were to build this using TDD, how would you approach it? What would your test list look like?

Share your thoughts in the comments below!

This post kicks off our “Let’s TDD a SwiftUI App” series. In the next post, we’ll compare unit testing with UI testing, and explore why unit testing SwiftUI is worth the effort.

Articles in this series

Let’s TDD a SwiftUI Counter App

Don’t miss a single post in this exciting series.

Subscribe to my newsletter, and I’ll notify you whenever I wrote a new blog post. As a bonus, you’ll also receive the test-oriented code snippets I like to use in Xcode.

Legacy Code Rescue: Taming a Thousand-Line View Controller

Jon Reid

Programming was fun when I was a kid. But working in Silicon Valley, I saw poor code led to fear, with real human costs. Looking for ways to make my life better, I learned about Extreme Programming, including unit testing, test-driven development (TDD), and refactoring. Programming became fun again! I've now been doing TDD in Apple environments for 20 years. I'm committed to software crafting as a discipline, hoping we can all reach greater effectiveness and joy. Now a coach with Industrial Logic!

  • I’ll take a shot!

    > Would you unit test this?
    > What parts are worth testing, and what parts aren’t?
    I’ll answer these two at the same time since they go together in my mind. When I look to unit test something I ask myself, “Is there behavior to define?” In this case, there is behavior depending on the value of `count` and `showingClearAlert`. For example, I might test “when I increment the count, it always only goes up by one” and “clearing the count means setting it to 0.” But I might avoid testing “The label text is the count.”

    > If you were to unit test this, how would you do it?
    The first challenge I see is that since `CounterView` is a View, we’re not able to unit test it directly (out of the box, at least). I would try to use safe refactorings to create a view model or `Counter` type that could be tested in isolation of the UI. I would also extract the SwiftUI button actions to methods on the view model so that this behavior could be tested.

    > If you were to build this using TDD, how would you approach it? What would your test list look like?
    – View model starts with a count of 0 and without the clear alert visible.
    – The counter increments by 1 (cases: 0 -> 1, 1 -> 2, 100 -> 101)
    – Clearing turns the count to 0 (cases: 0 -> 0, 1 -> 0, 100 -> 0)
    – Requesting clear sets the `showingClearAlert` flag if `count` is greater than 0.
    (After writing this test case I notice that there is an action to actually “clear” and also something like “request clear prompt if necessary”. Some clever naming might be in order here!)

    Looking forward to seeing your solution, Jon!

    • I agree with Ricky.

      Adding to his thoughts, I think another test that verifies we‘re actually calling the right methods from the view (and perhaps displays the right values) could improve our test suite. I know there are UI tests and the ViewInspector library for those.

  • Let me add a few ideas, so you can show how painful it is to test the UI directly ;)

    (*) The “+1” button should always be enabled
    (*) Pressing the “+1” button should increment the count
    (*) The “clear” button should be disabled when the counter == 0
    (*) The “clear” button should be enabled when the counter > 0
    (*) Pressing the “clear button” should lead to the alert with “cancel” and “clear” options.
    (*) Pressing the “clear” option on the alert should reset the counter to 0 (and disable the “clear” button)
    (*) Pressing the “cancel” button on the alert should keep the counter unchanged and both buttons enabled.

    I assumed we would not be testing colo(u)rs, font sizes, system images, and the displayed text. Is that an incorrect assumption?

  • Hey! I hope I’m not too late to the party.

    I just wanted to share some thoughts based on my own experience.

    > Would you unit test this? Why or why not?
    Yes! Definitely! There are two approaches here: one for those who want to use ViewInspector and another for those who don’t. Initially, I was full of hope when I saw that ViewInspector had an API that could locate a button declaration in a View definition and trigger its closure. However, recently, this has caused many issues like tests flakiness or tests that fail to compile, especially when switching from Xcode 15 to Xcode 16. This experience changed my approach to unit testing in SwiftUI.

    > If you were to unit test this, how would you do it?
    In current approach I start with snapshot test and preview definition (both can share the same code). Then I implement the pure view leaving all actions empty and a single struct that holds the whole dynamic content of a screen. When the UI is ready I focus on behaviours.

    With ViewInspector (my old way)
    I write a test in a way that first I perform an action e.g I “tap” on a button with a given name and then I expect my state struct to change accordingly.

    Without ViewInspector (new way)
    I start with writing tests for behaviours that can happen on the screen like “plus button is tapped”. I test my reducer by scheduling each action and expecting a new correct state.

    Shortly
    * snapshot tests covers UI part
    * unit tests covers business logic (in my case stored in reducer)
    * the untested part is the connection between UI element action and component that schedules actions (state store in my case)
    * there might be a problem with snapshotting part of modifiers (e.g. alert view modifier)

    > What parts are worth testing, and what parts aren’t?
    Everything is worth testing. I’ve often heard arguments that simple things aren’t worth testing, but I disagree. I’ve made simple mistakes more times than I can count, and having tests in place has saved me many times.

    Unfortunately, without ViewInspector, it’s not possible to test everything using my approach. The part that remains untested is the connection between UI element actions and the component that schedules actions (in my case, state stores). There’s a space for mistake here, but I have to accept that risk, because of ViewInspector imperfections.

    > If you were to build this using TDD, how would you approach it? What would your test list look like?
    1. snapshot test where counter = 0
    2. snapshot test where counter > 0
    3. unit test for screen state after the plus button is tapped
    4. unit test for screen state after the clear button is tapped
    5. unit test for screen state after the clear pop-up button is tapped

    • Thanks for sharing, Maciej! You point out an inherent danger of 3rd-party testing tools. I’ll be sure to mention that when I write about ViewInspector, thank you. I also experienced trouble going to Xcode 16, but find that the maintainer Alexey is quick to provide help. This was due to a change in how Apple creates views without our knowledge.

      You do a nice job of describing the non-ViewInspector approach.

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >