Chapters

Hide chapters

SwiftUI by Tutorials

Fourth Edition · iOS 15, macOS 12 · Swift 5.5 · Xcode 13.1

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

4. Testing & Debugging
Written by Bill Morefield

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.

SwiftUI is a new visual framework, so 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.

Debugging SwiftUI takes a bit more forethought and planning than most tests because the user interface and code mix together under the SwiftUI paradigm. Since SwiftUI views are nothing but code, they execute just like any other code would.

Open SwiftCalcView.swift and look for the following lines of code. They should be near line 138:

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

This code defines a button for the user interface. The first block defines the action to perform when the user taps the button. The next block defines what the button looks like in the view. Even though the two pieces of code are adjacent, they won’t always execute at the same time.

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.

To set a breakpoint, you put your cursor on the line in question and then press Command + \ or select Debug ▸ Breakpoints ▸ Add Breakpoint at Current Line from the menu. You can also click on the margin at the line where you want the breakpoint.

Use one of these methods to set two breakpoints; one on the button, and then one on the first line of code in the action: for the M+ button as shown below:

App breakpoints
App breakpoints

Note: Prior to Xcode 13, you could also run the preview in debug mode. Apple removed this feature in Xcode 13 and you must now use the simulator or a device to debug your app’s views. Breakpoints will be ignored by the preview.

Run your app. After a moment, the app reaches the breakpoint at the Text control for the button. When it reaches the breakpoint for the Text(), execution pauses just as it would with any other code.

When execution reaches a breakpoint, the app pauses and Xcode returns control to you. At the bottom of the Xcode window, you’ll see the Debug Area consisting of two windows below the code editor. If you don’t see the Debug Area, go to View ▸ Debug Area ▸ Show Debug Area or press Shift + Command + Y to toggle the Debug Area.

The left pane of the Debug Area contains the Variables View. It shows you the current status and value of active variables in your app. The right pane contains an interactive Console, the most complex and powerful tool for debugging in Xcode.

Variables Console
Variables Console

Using breakpoints does more than halt code; it can also tell you whether or not the execution of the app actually reached this piece of code. If a breakpoint doesn’t trigger, then you know something caused the app to skip the code.

The mixing of code and UI elements in SwiftUI can be confusing, but breakpoints can help you make sense of what is executing and when. If you add a breakpoint and it never breaks, then you know that the execution never reached the declaration and the interface will not contain the element. If your breakpoint does get hit, you can investigate the state of the app at that point.

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 the visibility of the debug area. The second button disables all breakpoints but doesn’t delete them. The third button continues the execution of the app. You can also select Debug ▸ Continue in the menu to continue app execution.

The next three buttons allow you to step through your code. Clicking the first executes the current line of code, including any method or function calls. The second button also executes the current line of code, but if there is a method call, it pauses at the first line of code inside that method or function. The final button executes code through to the end of the current method or function.

Debug bar
Debug bar

Continue execution of the app by using either the toolbar button or the menu. After another short pause, you’ll see the view appear.

Tap the M+ button on the preview to see your breakpoint trigger. When it does, the code pauses at the breakpoint on the first line of the Button’s action block.

At the (lldb) prompt in the console, execute the following:

po _memory

The po command in the console lets you examine the state of an object. Note the underscore at the start of the variable name. For now, just know that within a SwiftUI view you will need to prefix the name of the variable with an underscore. You’ll see the result shows the contents of the memory state variable:

Debugger output
Debugger output

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.

Even if you don’t write a test for every case in your app, it’s a beneficial practice to create tests when you find bugs. Creating a test ensures that you have, in fact, fixed the bug. It also provides early notice if this bug were to reappear in the future. In the next section, you’re going to write a UI test for this bug.

Note: Delete the breakpoints you just created. You can do so by right-clicking on the breakpoint and choosing Delete Breakpoint. You can disable the breakpoints by clicking on them. They should turn light blue. Press them again whenever you want to reactivate them.

In the starter project, go to File ▸ New ▸ Target…. Select iOS and scroll down to find the Test section. Click UI Testing Bundle and click Next.

Xcode suggests a name for the test bundle that combines the name of the project and the type of test. Accept the suggestion of SwiftCalcUITests. Select SwiftCalc as the Project and Target to be Tested. Finally, click Finish.

In the Project navigator, you’ll see a new group named SwiftCalcUITests. This new target contains the framework where you build your UI tests; expand the group and open SwiftCalcUITests.swift.

You’ll see the file starts by importing XCTest. The XCTest framework contains Apple’s default testing libraries. You’ll also see the test class inherits from XCTestCase, from which all test classes inherit their behavior.

You’ll also see four default methods provided in the Xcode template. The first two methods are an important part of your test process. The test process calls setUpWithError() before each test method in the class, and then calls tearDownWithError() after each test method completes.

Remember: a test should verify that a known set of inputs results in an expected set of outputs. You use setUpWithError() to ensure your app is in this known state before each test method begins. You use tearDownWithError() to clean up after each test so that you’re back to a known starting condition for the next test.

Note the following line in setUpWithError():

continueAfterFailure = false

This line stops testing if a failure occurs. Setting this value to false stops the test process after the first failure. Given the nature of UI testing, you will almost always end up in an unknown state when a test fails. Rather than continue what are often long-running tests for very little and potentially incorrect information, you should stop and fix the problem now.

In this chapter, you won’t have any other setup or cleanup work to perform for your tests.

The third method in the template is testExample(), which contains a sample test. You’ll also see the method has a small gray diamond next to its name; this means that Xcode recognizes it as a test, but the test hasn’t been run yet. Once the test runs, the diamond will change to a green checkmark, if the test passes, or to a white X on a red background after completion, if the test fails.

Test names must begin with test. If not, the testing framework ignores the method and will not execute it when testing. For example, the framework ignores a method named myCoolTest(), but it will execute testMyCoolCode().

Test samples
Test samples

You’ll see a comment in the sample test suggesting you “Use recording to get started writing UI tests.” Recording can save time when building UI tests, but here you’ll be writing these tests from scratch.

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.

Rename testExample() to testPressMemoryPlusAtAppStartShowZeroInDisplay(). Does that feel really long? Test names are not the place or time for brevity; the name should clearly provide all three elements at a glance.

A UI test begins with the app in the “just started” state, so you can write each test as though the app has just started. Note that this doesn’t mean the app state is reset each run. You use the setUpWithError() and tearDownWithError() methods to ensure your app is in a particular known state before each test and to clean up any changes made during the test. If you expect settings, data, configuration, location or other information to be present at the time the test is run, then you must set those up.

Clear the comments after the app.launch() command, and add a breakpoint at app.launch() line in the test.

There are several ways to start UI tests. First, you can go to the Test Navigator by pressing Command + 6 in Xcode. You’ll see your test along with the default testLaunchPerformance() test. If you hover the mouse over the name of a test, you’ll see a gray play button. Hover your mouse over the gray diamond to the left of the function name, and you’ll see a Play button.

If you hover over the name of the class or the testing framework either in the Test Navigator or the source code, a similar Play button appears that will start a group of tests to run in sequence.

This test isn’t complete, as it doesn’t test anything. This is a good time to run it and learn a bit about how a test runs. For now, use either method to start your testPressMemoryPlusAtAppStartShowZeroInDisplay() test.

Tests are Swift code, so you can debug tests just like you debug your app! You’ll sometimes need to determine why a test doesn’t behave as expected. When the test reaches the breakpoint, you’ll see execution stop, just as your breakpoint would behave in any other code.

The main element you’ll want to explore is the app element where you placed the breakpoint. Step over the command to launch the app using the toolbar button, pressing F6 or selecting Debug ▸ Step Over in the menu. In the simulator, you’ll see the app launch. Once you have the (lldb) prompt in the console, enter po app.

You’ll see output similar to the following:

po app command
po app command

You’re examining the app object, which you declared as an XCUIApplication, a subclass of XCUIElement. You’ll be working with this object in all of your UI tests.

The app object contains a tree that begins with the application and continues through all of the UI elements in your app. Each of these elements is also of type XCUIElement. You’ll access the UI elements in your app by running filter queries against the app object to select items in the tree that you see.

Next, you’ll see how to run a query to find buttons in the app.

Accessing UI elements

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

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

XCUIApplication contains a set of elements for each type of user interface object. This query first filters for only .buttons in the app. It then filters to the element which has a label of M+.

SwiftUI apps render to the native elements of the platform; they’re not new components. Even though SwiftUI provides a new way to define an interface, it still uses the existing elements of the platform. A SwiftUI Button becomes a UIButton on iOS and a NSButton on macOS. In this app, the filter matches the label you saw in the output from po app.

Button, 0x600002498540, {{184.5, 102.5}, {45.0, 45.0}}, label: ’M+’

Once you have the button object, you call tap() on the button. This method simulates someone tapping on the button. Delete the breakpoint on the app launch, and re-run the test.

First test run
First test run

You’ll see the app start and run in the simulator as the test runs. If you watch the simulator, you’ll see the display of the calculator show Error just as it did when you ran it manually. Once the tests are done, the app will stop. You’ll see the gray diamond changes into a green checkmark both next to the function and in the Test Navigator.

The green check signifies a passed test. In this case, the test didn’t check anything. The framework treats a test that doesn’t fail as a passing test.

In a UI test, the known set of inputs to your test is the set of interactions with the app. Here you performed an interaction by tapping the M+ button, so now you need to check the result. In the next section, you’ll see how to get the value from a control.

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.swift. In the view, look for the two comments // Add display identifier and replace both with the following line:

.accessibility(identifier: "display")

This method sets the accessibilityIdentifer for the resulting UI element. Despite the name, VoiceOver doesn’t read the accessibilityIdentifer attribute; this simply provides a way to give a UI element a constant label for testing. If you don’t provide this identifier for an element, it will generally be the same as the label for the control as it was with the M+ button.

Go back to SwiftCalcUITests.swift. Add the following code at the end of testPressMemoryPlusAtAppStartShowZeroInDisplay():

// 1
let display = app.staticTexts["display"]
// 2
let displayText = display.label
// 3
XCTAssert(displayText == "0")

You’ve written your first real test! Here’s what each step does:

  1. You use the accessibility(identifier:) you added to find the display element in your app.
  2. The result of step 1 is an XCUIElement, as are most UI elements in a UI test. You want to investigate the label property of the element which contains the text of the label.
  3. You use an assertion to verify the label matches the expected result. All testing assertions begin with the prefix XCT — a holdover from Objective-C naming conventions. In each test, you perform one or more assertions that determine if the test passes or fails.

In this case, you are checking that the text for display is the string “0”. You already know the result will be a failing test, but still, run the completed test to see what happens. You’ll get the expected failure and see a white X on red.

Failed first test
Failed first test

Now that you have a test in place, you can fix the bug!

Fixing the bug

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

display = ""

Re-run the test. You’ll see that it passes.

First passing test
First passing test

You may be wondering why you went through the extra effort: You changed one line of code to fix the bug, but you added another framework to your app and had to write five lines of code to create the test.

Although this may feel like a lot of work to prove that you’ve fixed a tiny issue, you’ll find this pattern of writing a failing test, fixing the bug and then verifying that the test passes, to be a useful pattern. Taking an existing app without tests, and adding a test each time you fix a bug, quickly builds a useful set of tests for now, and more importantly, for the future.

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.

Add a more complex test that verifies adding two single-digit numbers gives the correct sum. Open SwiftCalcUITests.swift and add the following test at the end of the class:

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")
}

When you run the test, you might not expect it to fail. Three plus five does equal eight, right? Take a moment to see if you can figure out why before continuing.

Your test compares the label of the display to the string 8. Place a breakpoint at XCTAssert statement and rerun the test. Wait until execution stops at the breakpoint. At the console prompt enter po displayText.

You’ll see the text of the display reads 8.0, not 8. A UI test focuses on the user interface and not on the behind-the-scenes elements. A unit test, in contrast, would check that the code properly calculated 3 + 5 = 8. The UI test should verify what the user sees when performing this calculation.

Change the final line of the test to:

XCTAssert(displayText == "8.0")

Re-run the test, and you’ll see it passes now.

Passing test
Passing test

XCTAssert() evaluates a condition and fails if it’s not true. If you had used the more specific XCTAssertEqual(displayText, "8") for the initial assertion, it would have provided the information you discovered using the debugger in the failure message. You used XCTAssert() to explore debugging a failed test. Change your test to XCTAssertEqual(displayText, "8.0") and verify it still passes.

Next, you’ll make a change to the user interface, and, because you want to form good testing habits, you’ll add a test to verify the change.

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.

Open MemoryView.swift. At the top of the body definition, right before HStack, add a gesture:

let memorySwipe = DragGesture(minimumDistance: 20)
  .onEnded { _ in
    memory = 0.0
  }

You can add this gesture to the memory display. Find the text // Add gesture here and replace it with:

.gesture(memorySwipe)

Like with main display, you will also add an identifier to the memory display. Add the following line below Text("\(memory)"):

.accessibility(identifier: "memoryDisplay")

Build and run the app; type in a few digits and tap M+ to store the value in memory. The memory display appears and shows the stored digits. Swipe the memory display to the left, and verify the display clears.

Swipe app
Swipe app

Now, because you’re practicing good development and testing habits, you’ll add a UI test to verify this behavior. The steps of the test replicate the actions you just performed manually.

Open SwiftCalcUITests.swift and add the following code after the existing tests:

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)
}

You’ve seen most of this code before. Here’s what the new code does:

  1. The exists property on an XCUIElement is true when the element exists. If the memory display were not visible, then this assert would fail.
  2. The swipeLeft() method produces a swipe action to the left on the calling element. There are additional methods for swipeRight(), swipeUp() and swipeDown().
  3. The XCTAssertFalse() test acts as an opposite for XCTAssert. It succeeds when the checked value is false instead of true. The swipe should set memory to zero after the gesture, and the action should hide the memory display, wiping it out of existence.

Run the test, and you’ll see it confirms that your UI works as expected.

There are many testing elements beyond those discussed in this chapter. Some of the common attributes and methods that you haven’t had a chance to use in this chapter are:

  • .isHittable: An element is hittable if the element exists and the user can click, tap or press it at its current location. An offscreen element exists but is not hittable.
  • .typeText(): This method acts as though the user types the text into the calling control.
  • .press(forDuration:): This allows you to perform a one-finger touch for a specified amount of time.
  • .press(forDuration:thenDragTo:): The swipe methods provide no guarantee of the velocity of the gesture. You can use this method to perform a more precise drag action.
  • .waitForExistence(): Useful to pause when an element may not appear on the screen immediately.

You’ll find a complete list of methods and properties in Apple’s documentation at https://developer.apple.com/documentation/xctest/xcuielement

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.

In Xcode, change the target device for the app to My Mac. In the project settings, select the SwiftCalc target. Choose Signing and Capabilities, set Team and verify that Signing Certificate is set to Sign to Run Locally. Now build and run the app to see it run for macOS.

You will learn about using SwiftUI with different operating systems in Chapter 21: “Building a Mac App”. Since, as you expect, running on different platforms may require tweaks to the user interface, testing the UI on various operating systems will require different tests. Some UI actions translate directly; for instance, tapping a button on an iOS device works just like clicking your mouse on a button would on macOS.

With the target device still set to My Mac, build and run your tests. You’ll get a compilation error: “Value of type ’XCUIElement’ has no member ’swipeLeft’”. Aha — not all actions have direct equivalents on every operating system. The .swipeLeft() action produces an error because Catalyst provides no swipe equivalent for macOS in the test framework.

The solution lies in Xcode’s conditional compilation blocks. These blocks tell Xcode to only compile the wrapped code when one or more of the conditions are true at compile time. A block begins with #if followed by a test. You can optionally use #elseif and #else as with traditional if statements, and you end the block with #endif.

You want to exclude the failing test when testing the app under Catalyst. Wrap the testSwipeToClearMemory() test inside a targetEnvironment check to exclude tests from Catalyst:

#if !targetEnvironment(macCatalyst)
  // Test to exclude
#endif

You can also specify the operating system as a condition. The operating system can be any one of macOS, iOS, watchOS, tvOS or Linux. For example, XCTest doesn’t support watchOS yet. If you’re building an app for watchOS, you’ll need to wrap tests to prevent the code from running against watchOS. To exclude tests from watchOS, wrap the tests with a similar check that excludes watchOS:

#if !os(watchOS)
  // Your XCTest code
#endif

A best practice when designing UI tests for cross-platform apps is to keep tests for specific operating systems together in a single test class. Use conditional compilation wrappers to isolate the code to compile only under the target platform and operating system.

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.swift 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)
    )
  }
}

This code creates an extension for the Color type named random. Each time called, it will present a new random color. When you apply it to a view, each time the view redraws, it will receive a new background color making it easy to identify redrawn views visually.

To use the extension, apply to a view as you would with any other color. At the end of the HStack for the view, add the following code:

.background(Color.random)

Run the app and tap a few numbers buttons to change the display. You’ll see the background color of the DisplayView change to a random color each time you tap a button.

Random background color when view changes
Random background color when view changes

While this can show you what view changes, it doesn’t tell you why. SwiftUI 3 introduced a new method to help solve that problem. You can use the new Self._printChanges() method to determine what caused the view to redraw. Open DisplayView.swift and add the following code after the var body: some View { declaring the body property for the view:

let _ = Self._printChanges()

This odd-looking code tells SwiftUI to identify the change that led to SwiftUI deciding to redraw the view. SwiftUI will display the name of the view and the properties that changed each time it draws the view. Notice that the method begins with an underscore hinting that you should only use this when debugging and remove it from a finished app. You must place it inside the body property of the view you want to monitor.

Run the app and again type a few buttons. You’ll still see the background color of the display change with each tap. In the interactive console, you’ll see a message stating the view that changed along with the property or properties that caused the view to redraw with each tap.

Displaying properties that cause view to redraw
Displaying properties that cause view to redraw

Don’t forget to take these out before you ship your app.

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.

For the Catalyst version of this app, add a double-tap gesture to the memory display to accomplish the same result as the swipe gesture. Update testSwipeToClearMemory() to check the functionality appropriately on each environment.

Challenge solution

You should begin by adding the new double-tap gesture. Change the current gesture definition in MemoryView.swift 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

This keeps the current swipe gesture on phones and tablets but creates a tap gesture that expects two taps on Catalyst.

Now update the memory display to similarly use the correct gesture for each environment.

#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

SwiftUI doesn’t support putting a targetEnvironment() condition within the modifiers to a view. That means you have to place the view twice, changing the desired gesture in each. Normally, you would extract the view into a subview to reduce the amount of code.

Lastly, update your testSwipeToClearMemory() test and replace the code after the second step earlier with:

#if targetEnvironment(macCatalyst)
memoryDisplay.doubleTap()
#else
memoryDisplay.swipeLeft()
#endif

This will call the appropriate UI gesture on each environment. Run your test on both My Mac and the iOS Simulator to validate your changes.

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?

This chapter provided an introduction to testing and debugging your SwiftUI projects. Your starting point to go more in-depth should be Apple’s documentation on XCTest at

Our book iOS Test-Driven Development by Tutorials provides a more in-depth look at testing iOS apps and test-driven development. You can find that book here:

You’ll also find more about testing in the WWDC 2019 video Testing in Xcode at

You’ll find a lot more information about the Xcode debugger and using it in our deep-dive book Advanced Apple Debugging & Reverse Engineering, available here:

Apple often releases new videos on the changes related to debugging each year at WWDC. For 2019, there’s a video dedicated to Debugging in Xcode 11 at

For 2021, there’s a video on breakpoint improvements at

Once you’re ready to go deeper into debugging, you’ll also want to watch LLDB: Beyond “po” at

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.