Chapters

Hide chapters

Combine: Asynchronous Programming With Swift

Fourth Edition · iOS 16 · Swift 5.8 · Xcode 14

19. Testing
Written by Florent Pillet

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

Testing Combine Operators

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

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.

func test_collect() {
  // Given
  let values = [0, 1, 2]
  let publisher = values.publisher
}
// When
publisher
  .collect()
  .sink(receiveValue: {
    // Then
    XCTAssert(
     $0 == values,
     "Result was expected to be \(values) but was \($0)"
    )
  })
  .store(in: &subscriptions)

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
XCTAssert(
  $0 == values + [1],
  "Result was expected to be \(values + [1]) but was \($0)"
)

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.

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)
}
// 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)
// Then
XCTAssert(
  results == expected,
  "Results expected to be \(expected) but were \(results)"
)

Testing publish(every:on:in:)

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

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)
}
// When
publisher
  .sink(
    receiveCompletion: { _ in expectation.fulfill() },
    receiveValue: {
      results.append(
        normalized($0.timeIntervalSinceReferenceDate - now)
      )
    }
  )
  .store(in: &subscriptions)
// Then
// 6
waitForExpectations(timeout: 2, handler: nil)

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

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.

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]()
}
// 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)
XCTAssert(
  results == expected,
  "Results expected to be \(expected) but were \(results)"
)

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.

var viewModel: CalculatorViewModel!
var subscriptions = Set<AnyCancellable>()
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)"
  )
}
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)
.map {
  if let name = ColorName(hex: $0) {
    return "\(name) \(Color.opacityString(forHex: $0))"
  } else {
    return "------------"
  }
}

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)"
  )
}
case Constant.backspace:
  if hexText.count > 1 {
    hexText.removeLast(2)
  }

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)"
  )
}
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)"
  )
}
colorValuesShared
  .map { $0 != nil ? Color(values: $0!) : .red }
  .assign(to: &$color)
.map { $0 != nil ? Color(values: $0!) : .white }

Testing for Bad Input

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

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

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.

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

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.

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

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.

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 accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now