Chapters

Hide chapters

SwiftUI by Tutorials

Fifth Edition · iOS 16, macOS 13 · Swift 5.8 · Xcode 14.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

4. Testing & Debugging
Written by Bill Morefield

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Adding tests to your app provides a built-in and automated way to ensure that your app does what you expect of it. And not only do tests check that your code works as expected, but it’s also assurance that future changes won’t break existing functionality.

In this chapter, you’ll learn how to implement UI tests in your SwiftUI app, and what to watch out for when testing your UI under this new paradigm.

Different Types of Tests

There are three types of tests that you’ll use in your apps. In order of increasing complexity, they are: unit tests, integration tests and user interface tests.

The base of all testing, and the foundation of all other tests, is the unit test. Each unit test ensures that you get the expected output when a function processes a given input. Multiple unit tests may test the same piece of code, but each unit test itself should only focus on a single unit of code. A unit test should take milliseconds to execute. You’ll run them often, so you want them to run fast.

The next test up the testing hierarchy is the integration test. Integration tests verify how well different parts of your code work with each other, and how well your app works with the world outside of the app, such as against external APIs. Integration tests are more complex than unit tests; they usually take longer to run, and as a result, you’ll run them less often.

The most complex test is the user interface test, or UI test; these tests verify the user-facing behavior of your app. They simulate user interaction with the app and verify the user interface behaves as expected after responding to the interaction.

As you move up the testing hierarchy, each level of test checks a broader scope of action in the app. For example, a unit test would verify that the calculateTotal() method in your app returns the correct amount for an order. An integration test would verify that your app correctly determines that the items in the order are in stock. A UI test would verify that after adding an item to an order, the amount displayed to the user displays the correct value.

This chapter focuses on how to write UI tests for SwiftUI apps. You’ll also learn how to debug your SwiftUI app and your tests by adding UI tests to a simple calculator app.

Debugging SwiftUI Apps

Begin by opening the starter project for this chapter, and build and run the app; it’s a simple calculator. The app also supports Catalyst, so it works on iOS, iPadOS and the Mac. Run a few calculations using the calculator to get an idea of how it works.

Button {
  if let val = Double(display) {
    memory += val
    display = ""
    pendingOperation = .none
  } else {
    // Add Bug Fix Here
    display = "Error"
  }
} label: {
  Text("M+")
}
.buttonStyle(CalcButtonStyle())

Setting Breakpoints

To stop code during the execution of an app, you set a breakpoint to tell the debugger to halt code execution when it reaches a particular line of code. You can then inspect variables, step through code and investigate other elements in your code.

App breakpoints
Ogg xsionquovns

Variables Console
Notoisdit Yahxijo

Exploring Breakpoint Control

When stopped at a breakpoint, you’ll see a toolbar between the code editor and debug area. The first button in this toolbar toggles all breakpoints but doesn’t delete them. The second button continues the execution of the app. You can also select Debug ▸ Continue in the menu to continue app execution.

Debug bar
Zurog heb

po _memory
Debugger output
Yuruflik aecsis

Adding UI Tests

There’s a bug in this code that you’ll notice when you Continue. The default value of the display is an empty string, and the display translates the empty string into 0. However, the code for the M+ button attempts to convert the empty string to a Double. When that conversion fails, the value Error appears to the user.

continueAfterFailure = false
Test names must start with 'test'. Note the lack of a diamond next to myCooltest()
Hiqy litez rifd csoyj degm 'joly'. Pizi vmo dozr uw o xuosoyn niqd vo jjSiuzgevj()

Creating a UI Test

Proper test names should be precise and clear about what the test validates since an app can end up with a large number of tests. Clear names make it easy to understand what failed. A test name should state what it tests, the circumstances of the test and what the result should be.

Run test icon
Hip rofj eduv

po app command
za exv xawnaqr

Accessing UI Elements

Add the following code to the end of the test method:

let memoryButton = app.buttons["M+"]
memoryButton.tap()
Button, 0x600002498540, {{184.5, 102.5}, {45.0, 45.0}}, label: ’M+’
First test run
Lacfb simw taf

Reading the User Interface

You found the M+ button by matching the label of the button. That won’t work for the display, though, because the text in the control changes based on the state of the app. However, you can add an attribute to the elements of the interface to make it easier to find from within your test. Open DisplayView. In the view, look for the two comments // Add display identifier and replace both with the following line:

.accessibilityIdentifier("display")
// 1
let display = app.staticTexts["display"]
// 2
let displayText = display.label
// 3
XCTAssert(displayText == "0")
Failed first test
Ceomoh wugzx warv

Fixing the Bug

Open SwiftCalcView, find the comment in the action for the M+ button that reads // Add Bug Fix Here, and change the next line to read:

display = ""
First passing test
Suhwy zunkens peft

Adding More Complex Tests

Ideally, you would be building out your UI tests at the same time as you built out your UI. This way, as your UI becomes more fleshed out, your test suite will expand along with it. However, with the realities of modern development, you’ll usually be adding tests after the application already exists.

func testAddingTwoDigits() {
  let app = XCUIApplication()
  app.launch()

  let threeButton = app.buttons["3"]
  threeButton.tap()

  let addButton = app.buttons["+"]
  addButton.tap()

  let fiveButton = app.buttons["5"]
  fiveButton.tap()

  let equalButton = app.buttons["="]
  equalButton.tap()

  let display = app.staticTexts["display"]
  let displayText = display.label
  XCTAssert(displayText == "8")
}
XCTAssert(displayText == "8.0")
Passing test
Zanhehy fivb

Simulating User Interaction

You’ll first add a gesture so that swiping the memory display to the left clears it. The effect of the gesture works the same as tapping the MC key by setting the value of memory to zero.

let memorySwipe = DragGesture(minimumDistance: 20)
  .onEnded { _ in
    memory = 0.0
  }
.gesture(memorySwipe)
.accessibilityIdentifier("memoryDisplay")
Swipe app
Bfojo orw

func testSwipeToClearMemory() {
  let app = XCUIApplication()
  app.launch()

  let threeButton = app.buttons["3"]
  threeButton.tap()
  let fiveButton = app.buttons["5"]
  fiveButton.tap()

  let memoryButton = app.buttons["M+"]
  memoryButton.tap()

  let memoryDisplay = app.staticTexts["memoryDisplay"]
  // 1
  XCTAssert(memoryDisplay.exists)
  // 2
  memoryDisplay.swipeLeft()
  // 3
  XCTAssertFalse(memoryDisplay.exists)
}

Testing Multiple Platforms

Much of the promise of SwiftUI comes from building apps that work on multiple Apple platforms. Your iOS app can become a macOS app with very little work: the sample project for this chapter supports Catalyst, letting the app run on macOS. However, there are always a few things that you’ll have to take care of yourself, to ensure your apps, and their tests, work properly on all platforms.

#if !targetEnvironment(macCatalyst)
  // Test to exclude
#endif
#if !os(watchOS)
  // Your XCTest code
#endif

Debugging Views and State Changes

When debugging a SwiftUI app, you’ll often run into situations where performance suffers because a view redraws more often than expected. Tracking down why SwiftUI redraws the view can be made easier with a couple of tricks. You can use a technique from Peter Steinberger to identify when a view redraws that assigns a random background color to the view. Open DisplayView and add the following code after the import statement:

extension Color {
  // Return a random color
  static var random: Color {
    return Color(
      red: .random(in: 0...1),
      green: .random(in: 0...1),
      blue: .random(in: 0...1)
    )
  }
}
.background(Color.random)
Random background color when view changes
Vayjom nixvwtiiht rodur cxic jiih msozwoq

let _ = Self._printChanges()
Displaying properties that cause view to redraw
Pixcmuxekz cwehibpeuv nquc fiafu fial tu signis

Challenge

Challenge: Add Swipe Gesture

As noted earlier, the swipe gesture to clear the memory doesn’t work under Catalyst. In the app, you would need to provide an alternate method of producing the same result.

Challenge Solution

You should begin by adding the new double-tap gesture. Change the current gesture definition of memorySwipe in MemoryView to:

#if targetEnvironment(macCatalyst)
let doubleTap = TapGesture(count: 2)
  .onEnded { _ in
    self.memory = 0.0
  }
#else
let memorySwipe = DragGesture(minimumDistance: 20)
  .onEnded { _ in
    self.memory = 0.0
  }
#endif
#if targetEnvironment(macCatalyst)
Text("\(memory)")
  .accessibility(identifier: "memoryDisplay")
  .padding(.horizontal, 5)
  .frame(
    width: geometry.size.width * 0.85,
    alignment: .trailing
  )
  .overlay(
    RoundedRectangle(cornerRadius: 8)
      .stroke(lineWidth: 2)
      .foregroundColor(Color.gray)
  )
  // Add gesture here
  .gesture(doubleTap)
#else
Text("\(memory)")
  .accessibility(identifier: "memoryDisplay")
  .padding(.horizontal, 5)
  .frame(
    width: geometry.size.width * 0.85,
    alignment: .trailing
  )
  .overlay(
    RoundedRectangle(cornerRadius: 8)
      .stroke(lineWidth: 2)
      .foregroundColor(Color.gray)
  )
  // Add gesture here
  .gesture(memorySwipe)
#endif
#if targetEnvironment(macCatalyst)
memoryDisplay.doubleTap()
#else
memoryDisplay.swipeLeft()
#endif

Key Points

  • Building and debugging tests require a bit more attention due to the combination of code and user interface elements in SwiftUI.
  • You can use breakpoints and debugging in SwiftUI as you do in standard Swift code.
  • Tests automate checking the behavior of your code. A test should ensure that given a known input and a known starting state, an expected output occurs.
  • User interface or UI tests verify that interactions with your app’s interface produce the expected results.
  • Add an accessibilityIdentifer to elements that do not have static text for their label to improve location for testing.
  • You find all user interface elements from the XCUIApplication element used to launch the app in the test.
  • Methods and properties allow you to locate and interact with the user interface in your tests as your user would.
  • Different platforms often need different user interface tests. Use conditional compilation to match tests to the platform and operating system.
  • You can use Self._printChanges() to view the state change that causes the view to redraw.

Where to Go From Here?

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now