Chapters

Hide chapters

Combine: Asynchronous Programming with Swift

Third Edition · iOS 15 · Swift 5.5 · Xcode 13

19. Testing
Written by Florent Pillet

Studies show that there are two reasons why developers skip writing tests:

  1. They write bug-free code.
  2. Are you still reading this?

If you cannot say with a straight face that you always write bug-free code — and presuming you answered yes to number two — this chapter is for you. Thanks for sticking around!

Writing tests is a great way to ensure intended functionality in your app as you are developing new features and especially after the fact, to ensure your latest work did not introduce a regression in some previous code that worked fine.

This chapter will introduce you to writing unit tests against your Combine code, and you’ll have some fun along the way. You’ll write tests against this handy app:

ColorCalc was developed using Combine and SwiftUI. It’s got some issues though. If it only had some decent unit tests to help find and fix those issues. Good thing you’re here!

Getting started

Open the starter project for this chapter in the projects/starter folder. This is designed to give you the red, green, blue, and opacity — aka alpha — values for the hex color code you enter in. It will also adjust the background color to match the current hex if possible and give the color’s name if available. If a color cannot be derived from the currently entered hex value, the background will be set to white instead. This is what it’s designed to do. But something is rotten in the state of Denmark — or more like some things.

Fortunately, you’ve got a thorough QA team that takes their time to find and document issues. It’s your job to streamline the development-QA process by not only fixing these issues but also writing some tests to verify correct functionality after the fix. Run the app and confirm the following issues reported by your QA team:

Issue 1

  • Action: Launch the app.
  • Expected: The name label should display aqua.
  • Actual: The name label displays Optional(ColorCalc.ColorNam….

Issue 2

  • Action: Tap the button.
  • Expected: The last character is removed in the hex display.
  • Actual: The last two characters are removed.

Issue 3

  • Action: Tap the button.
  • Expected: The background turns white.
  • Actual: The background turns red.

Issue 4

  • Action: Tap the button.
  • Expected: The hex value display clears to #.
  • Actual: The hex value display does not change.

Issue 5

  • Action: Enter hex value 006636.
  • Expected: The red-green-blue-opacity display shows 0, 102, 54, 255.
  • Actual: The red-green-blue-opacity display shows 0, 62, 32, 155.

You’ll get to the work on writing tests and fixing these issues shortly, but first, you’ll learn about testing Combine code by — wait for it — testing Combine’s actual code! Specifically, you’ll test a few operators.

Note: The chapter presumes you have some familiarity with unit testing in iOS. If not, you can still follow along, and everything will work fine. However, this chapter will not delve into the details of test-driven development — aka TDD. If you are seeking to gain a more in-depth understanding of this topic, check out iOS Test-Driven Development by Tutorials from the raywenderlich.com library.

Testing Combine operators

Throughout this chapter, you’ll employ the Given-When-Then pattern to organize your test logic:

  • Given a condition.
  • When an action is performed.
  • Then an expected result occurs.

Still in the ColorCalc project, open ColorCalcTests/CombineOperatorsTests.swift.

To start things off, add a subscriptions property to store subscriptions in, and set it to an empty array in tearDown(). Your code should look like this:

var subscriptions = Set<AnyCancellable>()

override func tearDown() {
  subscriptions = []
}

Testing collect()

Your first test will be for the collect operator. Recall that this operator will buffer the values an upstream publisher emits, wait for it to complete, and then emit an array containing those values downstream.

Employing the Given — When — Then pattern, begin a new test method by adding this code below tearDown():

func test_collect() {
  // Given
  let values = [0, 1, 2]
  let publisher = values.publisher
}

With this code, you create an array of integers, and then a publisher from that array.

Now, add this code to the test:

// When
publisher
  .collect()
  .sink(receiveValue: {
    // Then
    XCTAssert(
     $0 == values,
     "Result was expected to be \(values) but was \($0)"
    )
  })
  .store(in: &subscriptions)

Here, you use the collect operator and then subscribe to its output, asserting that the output equals the values — and store the subscription.

You can run unit tests in Xcode in several ways:

  1. To run a single test, click the diamond next to the method definition.
  2. To run all the tests in a single test class, click the diamond next to the class definition.
  3. To run all the tests in all test targets in a project, press Command-U. Keep in mind that each test target may contain multiple test classes, each potentially containing multiple tests.
  4. You can also use the Product ▸ Perform Action ▸ Run “TestClassName menu — which also has its own keyboard shortcut: Command-Control-Option-U.

Run this test by clicking the diamond next to test_collect(). The project will build and run in the simulator briefly while it executes the test, and then report if it succeeded or failed.

As expected, the test will pass and you’ll see the following:

The diamond next to the test definition will also turn green and contain a checkmark.

You can also show the Console via the View ▸ Debug Area ▸ Activate Console menu item or by pressing Command-Shift-Y to see details about the test results (results truncated here):

Test Suite 'Selected tests' passed at 2021-08-25 00:44:59.629.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.007) seconds

To verify that this test is working correctly, change the assertion code to:

XCTAssert(
  $0 == values + [1],
  "Result was expected to be \(values + [1]) but was \($0)"
)

You added a 1 to the values array being compared to the array emitted by collect(), and to the interpolated value in the message.

Rerun the test, and you’ll see it fails, along with the message Result was expected to be [0, 1, 2, 1] but was [0, 1, 2]. You may need to click on the error to expand and see the full message or show the Console, and the full message will also print there.

Undo that last set of changes before moving on, and re-run the test to ensure it passes.

Note: In the interest of time and space, this chapter will focus on writing tests that test for positive conditions. However, you are encouraged to experiment by testing for negative results along the way if you’re interested. Just remember to return the test to the original passing state before continuing.

This was a fairly simple test. The next example will test a more intricate operator.

Testing flatMap(maxPublishers:)

As you learned in Chapter 3, “Transforming Operators,” the flatMap operator can be used to flatten multiple upstream publishers into a single publisher, and you can optionally specify the maximum number of publishers it will receive and flatten.

Add a new test method for flatMap by adding this code:

func test_flatMapWithMax2Publishers() {
  // Given
  // 1
  let intSubject1 = PassthroughSubject<Int, Never>()
  let intSubject2 = PassthroughSubject<Int, Never>()
  let intSubject3 = PassthroughSubject<Int, Never>()
  
  // 2
  let publisher = CurrentValueSubject<PassthroughSubject<Int, Never>, Never>(intSubject1)
  
  // 3
  let expected = [1, 2, 4]
  var results = [Int]()
  
  // 4
  publisher
    .flatMap(maxPublishers: .max(2)) { $0 }
    .sink(receiveValue: {
      results.append($0)
    })
    .store(in: &subscriptions)
}

You start this test by creating:

  1. Three instances of a passthrough subject expecting integer values.
  2. A current value subject that itself accepts and publishes integer passthrough subjects, initialized with the first integer subject.
  3. Expected results and an array to hold actual results received.
  4. A subscription to the publisher, using flatMap with a max of two publishers. In the handler, you append each value received to the results array.

That takes care of Given. Now add this code to your test to create the action:

// When
// 5
intSubject1.send(1)

// 6
publisher.send(intSubject2)
intSubject2.send(2)

// 7
publisher.send(intSubject3)
intSubject3.send(3)
intSubject2.send(4)

// 8
publisher.send(completion: .finished)

Because the publisher is a current value subject, it will replay the current value to new subscribers. So with the above code, you continue that publisher’s work and:

  1. Send a new value to the first integer publisher.
  2. Send the second integer subject through the current value subject and then send that subject a new value.
  3. Repeat the previous step for the third integer subject, except passing it two values this time.
  4. Send a completion event through the current value subject.

All that’s left to complete this test is to assert these actions will produce the expected results. Add this code to create this assertion:

// Then
XCTAssert(
  results == expected,
  "Results expected to be \(expected) but were \(results)"
)

Run the test by clicking the diamond next to its definition and you will see it passes with flying colors!

If you have previous experience with reactive programming, you may be familiar with using a test scheduler, which is a virtual time scheduler that gives you granular control over testing time-based operations.

At the time of this writing, Combine does not include a formal test scheduler. An open-source test scheduler called Entwine (https://github.com/tcldr/Entwine) is already available though, and it’s worth a look if a formal test scheduler is what you seek.

However, given that this book is focused on using Apple’s native Combine framework, when you want to test Combine code, then you can definitely use the built-in capabilities of XCTest. This will be demonstrated in your next test.

Testing publish(every:on:in:)

In this next example, the system under test will be a Timer publisher.

As you might remember from Chapter 11, “Timers,” this publisher can be used to create a repeating timer without a lot of boilerplate setup code. To test this, you will use XCTest’s expectation APIs to wait for asynchronous operations to complete.

Start a new test by adding this code:

func test_timerPublish() {
  // Given
  // 1
  func normalized(_ ti: TimeInterval) -> TimeInterval {
    return Double(round(ti * 10) / 10)
  }
  
  // 2
  let now = Date().timeIntervalSinceReferenceDate
  // 3
  let expectation = self.expectation(description: #function)
  // 4
  let expected = [0.5, 1, 1.5]
  var results = [TimeInterval]()
  
  // 5
  let publisher = Timer
    .publish(every: 0.5, on: .main, in: .common)
    .autoconnect()
    .prefix(3)
}

In this setup code, you:

  1. Define a helper function to normalize time intervals by rounding to one decimal place.
  2. Store the current time interval.
  3. Create an expectation that you will use to wait for an asynchronous operation to complete.
  4. Define the expected results and an array to store actual results.
  5. Create a timer publisher that auto-connects, and only take the first three values it emits. Refer back to Chapter 11, “Timers” for a refresher on the details of this operator.

Next, add this code to test this publisher:

// When
publisher
  .sink(
    receiveCompletion: { _ in expectation.fulfill() },
    receiveValue: {
      results.append(
        normalized($0.timeIntervalSinceReferenceDate - now)
      )
    }
  )
  .store(in: &subscriptions)

In the subscription handler above, you use the helper function to get a normalized version of each of the emitted dates’ time intervals and append then to the results array.

With that done, it’s time to wait for the publisher to do its work and complete and then do your verification.

Add this code to do so:

// Then
// 6
waitForExpectations(timeout: 2, handler: nil)

// 7
XCTAssert(
  results == expected,
  "Results expected to be \(expected) but were \(results)"
)

Here you: 6. Wait for a maximum of 2 seconds.

  1. Assert that the actual results are equal to the expected results.

Run the test, and you’ll get another pass — +1 for the Combine team at Apple, everything here is working as advertised!

Speaking of which, so far you’ve tested operators built-in to Combine. Why not test a custom operator, such as the one you created in Chapter 18, “Custom Publishers & Handling Backpressure?”

Testing shareReplay(capacity:)

This operator provides a commonly-needed capability: To share a publisher’s output with multiple subscribers while also replaying a buffer of the last N values to new subscribers. This operator takes a capacity parameter that specifies the size of the rolling buffer. Once again, refer back to Chapter 18, “Custom Publishers & Handling Backpressure” for additional details about this operator.

You’ll test both the share and replay components of this operator in the next test. Add this code to get started:

func test_shareReplay() {
  // Given
  // 1
  let subject = PassthroughSubject<Int, Never>()
  // 2
  let publisher = subject.shareReplay(capacity: 2)
  // 3
  let expected = [0, 1, 2, 1, 2, 3, 3]
  var results = [Int]()
}

Similar to previous tests, you:

  1. Create a subject to send new integer values to.
  2. Create a publisher from that subject, using shareReplay with a capacity of two.
  3. Define the expected results and, create an array to store the actual output.

Next, add this code to trigger the actions that should produce the expected output:

// When
// 4
publisher
  .sink(receiveValue: { results.append($0) })
  .store(in: &subscriptions)

// 5
subject.send(0)
subject.send(1)
subject.send(2)

// 6
publisher
  .sink(receiveValue: { results.append($0) })
  .store(in: &subscriptions)

// 7
subject.send(3)

From the top, you:

  1. Create a subscription to the publisher and store any emitted values.
  2. Send some values through the subject that the publisher is share-replaying.
  3. Create another subscription and also store any emitted values.
  4. Send one more value through the subject.

With that done, all that’s left is to make sure this operator is up-to-snuff is create an assertion. Add this code to wrap up this test:

XCTAssert(
  results == expected,
  "Results expected to be \(expected) but were \(results)"
)

This is the same assertion code as the previous two tests.

Run this test and voilà, you have a bonafide member worthy of use in your Combine-driven projects!

By learning how to test this small variety of Combine operators, you’ve picked up the skills necessary to test almost anything Combine can throw at you. In the next section, you’ll put these skills to practice by testing the ColorCalc app you saw earlier.

Testing production code

At the beginning of the chapter, you observed several issues with the ColorCalc app. It’s now time to do something about it.

The project is organized using the MVVM pattern, and all the logic you’ll need to test and fix is contained in the app’s only view model: CalculatorViewModel.

Note: Apps can have issues in other areas such as SwiftUI View files, however, UI testing is not the focus of this chapter. If you find yourself needing to write unit tests against your UI code, it could be a sign that your code should be reorganized to separate responsibilities. MVVM is a useful architectural design pattern for this purpose. If you’d like to learn more about MVVM with Combine, check out the tutorial MVVM with Combine Tutorial for iOS.

Open ColorCalcTests/ColorCalcTests.swift, and add the following two properties at the top of the ColorCalcTests class definition:

var viewModel: CalculatorViewModel!
var subscriptions = Set<AnyCancellable>()

You’ll reset both properties’ values for every test, viewModel right before and subscriptions right after each test. Change the setUp() and tearDown() methods to look like this:

override func setUp() {
  viewModel = CalculatorViewModel()
}

override func tearDown() {
  subscriptions = []
}

Issue 1: Incorrect name displayed

With that setup code in place, you can now write your first test against the view model. Add this code:

func test_correctNameReceived() {
  // Given
  // 1
  let expected = "rwGreen 66%"
  var result = ""
  
  // 2
  viewModel.$name
    .sink(receiveValue: { result = $0 })
    .store(in: &subscriptions)
  
  // When
  // 3
  viewModel.hexText = "006636AA"
  
  // Then
  // 4
  XCTAssert(
    result == expected,
    "Name expected to be \(expected) but was \(result)"
  )
}

Here’s what you did:

  1. Store the expected name label text for this test.
  2. Subscribe to the view model’s $name publisher and save the received value.
  3. Perform the action that should trigger the expected result.
  4. Assert that the actual result equals the expected one.

Run this test, and it will fail with this message: Name expected to be rwGreen 66% but was Optional(ColorCalc.ColorName.rwGreen)66%. Ah, the Optional bug bites once again!

Open View Models/CalculatorViewModel.swift. At the bottom of the class definition is a method called configure(). This method is called in the initializer, and it’s where all the view model’s subscriptions are set up. First, a hexTextShared publisher is created to, well, share the hexText publisher.

How’s that for self-documenting code? Right after that is the subscription that sets name:

hexTextShared
  .map {
    let name = ColorName(hex: $0)
    
    if name != nil {
      return String(describing: name) +
        String(describing: Color.opacityString(forHex: $0))
    } else {
      return "------------"
    }
  }
  .assign(to: &$name)

Review that code. Do you see what’s wrong? Instead of just checking that the local name instance of ColorName is not nil, it should use optional binding to unwrap non-nil values.

Change the entire map block of code to the following:

.map {
  if let name = ColorName(hex: $0) {
    return "\(name) \(Color.opacityString(forHex: $0))"
  } else {
    return "------------"
  }
}

Now return to ColorCalcTests/ColorCalcTests.swift and rerun test_correctNameReceived(). It passes!

Instead of fixing and rerunning the project once to verify the fix, you now have a test that will verify the code works as expected every time you run tests. You’ve helped to prevent a future regression that could be easy to overlook and make it into production. Have you ever seen an app in the App Store displaying Optional(something...)?

Nice job!

Issue 2: Tapping backspace deletes two characters

Still in ColorCalcTests.swift, add this new test:

func test_processBackspaceDeletesLastCharacter() {
  // Given
  // 1
  let expected = "#0080F"
  var result = ""
  
  // 2
  viewModel.$hexText
    .dropFirst()
    .sink(receiveValue: { result = $0 })
    .store(in: &subscriptions)
  
  // When
  // 3
  viewModel.process(CalculatorViewModel.Constant.backspace)
  
  // Then
  // 4
  XCTAssert(
    result == expected,
    "Hex was expected to be \(expected) but was \(result)"
  )
}

Similarly to the previous test, you:

  1. Set the result you expect and create a variable to store the actual result.
  2. Subscribe to viewModel.$hexText and save the value you get after dropping the first replayed value.
  3. Call viewModel.process(_:) passing a constant string that represents the character.
  4. Assert the actual and expected results are equal.

Run the test and, as you might expect, it fails. The message this time is Hex was expected to be #0080F but was #0080.

Head back to CalculatorViewModel and find the process(_:) method. Check out the switch case in that method that deals with the backspace:

case Constant.backspace:
  if hexText.count > 1 {
    hexText.removeLast(2)
  }

This must’ve been left behind by some manual testing during development. The fix couldn’t be more straightforward: Delete the 2 so that removeLast() is only removing the last character.

Return to ColorCalcTests, rerun test_processBackspaceDeletesLastCharacter(), and it passes!

Issue 3: Incorrect background color

Writing unit tests can very much be a rinse-and-repeat activity. This next test follows the same approach as the previous two. Add this new test to ColorCalcTests:

func test_correctColorReceived() {
  // Given
  let expected = Color(hex: ColorName.rwGreen.rawValue)!
  var result: Color = .clear
  
  viewModel.$color
    .sink(receiveValue: { result = $0 })
    .store(in: &subscriptions)
  
  // When
  viewModel.hexText = ColorName.rwGreen.rawValue
  
  // Then
  XCTAssert(
    result == expected,
    "Color expected to be \(expected) but was \(result)"
  )
}

You’re testing the view model’s $color publisher this time, expecting the color’s hex value to be rwGreen when viewModel.hexText is set to rwGreen. This may seem to be doing nothing at first, but remember that this is testing that the $color publisher outputs the correct value for the entered hex value.

Run the test, and it passes! Did you do something wrong? Absolutely not! Writing tests is meant to be proactive as much if not more reactive. You now have a test that verifies the correct color is received for the entered hex. So definitely keep that test to be alerted for possible future regressions.

Back to the drawing board on this issue though. Think about it. What’s causing the issue? Is it the hex value you entered, or is it… wait a minute, it’s that button again!

Add this test that verifies the correct color is received when the button is tapped:

func test_processBackspaceReceivesCorrectColor() {
  // Given
  // 1
  let expected = Color.white
  var result = Color.clear
  
  viewModel.$color
    .sink(receiveValue: { result = $0 })
    .store(in: &subscriptions)
  
  // When
  // 2
  viewModel.process(CalculatorViewModel.Constant.backspace)

  // Then
  // 3
  XCTAssert(
    result == expected,
    "Hex was expected to be \(expected) but was \(result)"
  )
}

From the top, you:

  1. Create local values for the expected and actual results, and subscribe to viewModel.$color, the same as in the previous test.
  2. Process a backspace input this time — instead of explicitly setting the hex text as in the previous test.
  3. Verify the results are as expected.

Run this test and it fails with the message: Hex was expected to be white but was red. The last word here is the most important one: red. You may need to open the Console to see the entire message.

Now you’re cooking with gas! Jump back to CalculatorViewModel and check out the subscription that sets the color in configure():

colorValuesShared
  .map { $0 != nil ? Color(values: $0!) : .red }
  .assign(to: &$color)

Maybe setting the background to red was another quick development-time test that was never replaced with the intended value? The design calls for the background to be white when a color cannot be derived from the current hex value. Make it so by changing the map implementation to:

.map { $0 != nil ? Color(values: $0!) : .white }

Return to ColorCalcTests, run test_processBackspaceReceivesCorrectColor(), and it passes.

So far your tests have focused on testing positive conditions. Next you’ll implement a test for a negative condition.

Testing for bad input

The UI for this app will prevent the user from being able to enter bad data for the hex value.

However, things can change. For example, maybe you change the hex Text to a TextField someday, to allow for pasting in values. So it would be a good idea to add a test now to verify the expected results for when bad data is input for the hex value.

Add this test to ColorCalcTests:

func test_whiteColorReceivedForBadData() {
  // Given
  let expected = Color.white
  var result = Color.clear

  viewModel.$color
    .sink(receiveValue: { result = $0 })
    .store(in: &subscriptions)
  
  // When
  viewModel.hexText = "abc"
  
  // Then
  XCTAssert(
    result == expected,
    "Color expected to be \(expected) but was \(result)"
  )
}

This test is almost identical to the previous one. The only difference is, this time, you pass bad data to hexText.

Run this test, and it will pass. However, if logic is ever added or changed such that bad data could be input for the hex value, your test will catch this issue before it makes it into the hands of your users.

There are still two more issues to test and fix. However, you’ve already acquired the skills to pay the bills here. So you’ll tackle the remaining issues in the challenges section below.

Before that, go ahead and run all your existing tests by using the Product ▸ Test menu or press Command-U and bask in the glory: They all pass!

Challenges

Completing these challenges will help ensure you’ve achieved the learning goals for this chapter.

Challenge 1: Resolve Issue 4: Tapping clear does not clear hex display

Currently, tapping has no effect. It’s supposed to clear the hex display to #. Write a test that fails because the hex display is not correctly updated, identify and fix the offending code, and then rerun your test and ensure it passes.

Tip: The constant CalculatorViewModel.Constant.clear can be used for the character.

Solution

This challenge’s solution will look almost identical to the test_processBackspaceDeletesLastCharacter() test you wrote earlier. The only difference is that the expected result is just #, and the action is to pass instead of . Here’s what this test should look like:

func test_processClearSetsHexToHashtag() {
  // Given
  let expected = "#"
  var result = ""
  
  viewModel.$hexText
    .dropFirst()
    .sink(receiveValue: { result = $0 })
    .store(in: &subscriptions)
  
  // When
  viewModel.process(CalculatorViewModel.Constant.clear)
  
  // Then
  XCTAssert(
    result == expected,
    "Hex was expected to be \(expected) but was \"\(result)\""
  )
}

Following the same step-by-step process you’ve done numerous times already in this chapter, you would:

  • Create local values to store the expected and actual results.
  • Subscribe to the $hexText publisher.
  • Perform the action that should produce the expected result.
  • Assert that expected equals actual.

Running this test on the project as it stands will fail with the message Hex was expected to be # but was "".

Investigating the related code in the view model, you would’ve found the case that handles the Constant.clear input in process(_:) only had a break in it. Maybe the developer who wrote this code was itching to take a break?

The fix is to change break to hexText = "#". Then, the test will pass, and you’ll be guarded against future regressions in this area.

Challenge 2: Resolve Issue 5: Incorrect red-green-blue-opacity display for entered hex

Currently, the red-green-blue-opacity (RGBO) display is incorrect after you change the initial hex displayed on app launch to something else. This can be the sort of issue that gets a “could not reproduce” response from development because it “works fine on my device.” Luckily, your QA team provided the explicit instructions that the display is incorrect after entering in a value such as 006636, which should result in the RGBO display being set to 0, 102, 54, 170.

So the test you would create that will fail at first would look like this:

func test_correctRGBOTextReceived() {
  // Given
  let expected = "0, 102, 54, 170"
  var result = ""
  
  viewModel.$rgboText
    .sink(receiveValue: { result = $0 })
    .store(in: &subscriptions)
  
  // When
  viewModel.hexText = "#006636AA"
  
  // Then
  XCTAssert(
    result == expected,
    "RGBO text expected to be \(expected) but was \(result)"
  )
}

Narrowing down to the cause of this issue, you would find in CalculatorViewModel.configure() the subscription code that sets the RGBO display:

colorValuesShared
  .map { values -> String in
    if let values = values {
      return [values.0, values.1, values.2, values.3]
        .map { String(describing: Int($0 * 155)) }
        .joined(separator: ", ")
    } else {
      return "---, ---, ---, ---"
    }
  }
  .assign(to: &$rgboText)

This code currently uses the incorrect value to multiply each of the values returned in the emitted tuple. It should be 255, not 155, because each red, green, blue and opacity string should represent the underlying value from 0 to 255.

Changing 155 to 255 resolves the issue, and the test will subsequently pass.

Key points

  • Unit tests help ensure your code works as expected during initial development and that regressions are not introduced down the road.
  • You should organize your code to separate the business logic you will unit test from the presentation logic you will UI test. MVVM is a very suitable pattern for this purpose.
  • It helps to organize your test code using a pattern such as Given-When-Then.
  • You can use expectations to test time-based asynchronous Combine code.
  • It’s important to test both for positive as well as negative conditions.

Where to go from here?

Excellent job! You’ve tackled testing several different Combine operators and brought law and order to a previously untested and unruly codebase.

One more chapter to go before you cross the finish line. You’ll finish developing a complete iOS app that draws on what you’ve learned throughout the book, including this chapter. Go for it!

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.
© 2026 Kodeco Inc.