Testing Tips & Tricks

Description: Testing is an essential tool to consistently verify your code works correctly, but often your code has dependencies that are out of your control. Discover techniques for making hard-to-test code testable on Apple platforms using XCTest. Learn a variety of tips for writing higher-quality tests that run fast and require less maintenance.

  • The usual reminder about the “Pyramid of Tests”, three kinds of tests (from small to big, fast to slow):
    • Unit: test specific functions/methods
    • Integration: test classes/methods interaction
    • End-To-End (System Tests, UI): test the user final experience
  • NotificationCenter can use different environments (kind of like UserDefaults have different groups): you should avoid using the default notification center in your tests as this may affect other parts of the code (the test is therefore NOT isolated). To make things testable you should inject the notificationCenter in all your methods, going from this:
class Points0fInterestTableViewController { 
  var observer: AnyObject? 

  init() { 
    let name = CurrentLocationProvider.authChangedNotification
    observer = NotificationCenter.default.addObserver(
      forName: name, 
      object: nil, 
      queue: .main
    ) { [weak self] _ in 
      self?.handleAuthChanged()
    }
  }

  var didHandleNotification = false 

  func handleAuthChanged() { 
    didHandleNotification = true 
  }
}

To this:

class Points0fInterestTableViewController { 
  let notificationCenter: NotificationCenter
  var observer: AnyObject? 
  
  init(notificationCenter: NotificationCenter) {
    self.notificationCenter = notificationCenter
    let name = CurrentLocationProvider.authChangedNotification
    observer = notificationCenter.addObserver(
      forName:name, 
      object: nil, 
      queue: .main
    ) { [weak self] _ in 
      self?.handleAuthChanged() 
    }
  }

  var didHandleNotification = false 

  func handleAuthChanged() { 
    didHandleNotification = true 
  }
}
  • You can also add a default value so you need to change the parameter only in the tests:
class PointsofInterestTableViewController {
  let notificationCenter: NotificationCenter 
  var observer: AnyObject? 

  init(notificationCenter: NotificationCenter = .default) { 
    self.notificationCenter = notificationCenter
    let name = CurrentLocationProvider.authChangedNotification
    observer = notificationCenter.addObserver(
      forName: name,
      object: nil,
      queue: .main
    ) { [weak self] _ in
      self?.handleAuthChanged() 
    }
  }

  var didHandleNotification = false 

  func handleAuthChanged() { 
    didHandleNotification = true 
  }
}
  • When creating mocks, beside relying as much as possible on protocols, another neat thing your mock classes/struct should have is the possibility to overwrite some of their methods, so every test can change the behaviour of the mock accordingly on what you’re trying to test
  • To make tests run even faster, you can try to skip some setups in your app launch by adding an environment variable or a launch argument in your schema:

And then use it in your app:

func application (_ application: UIApplication, didFinishLaunchingWithOptions opts: ...) -> Bool {
  let isUnitTesting = ProcessInfo.processInfo.environment["IS_UNIT_TESTING"] == "1"

  if isUnitTesting == false { 
    // Do UI-related setup, which can be skipped when testing 
  }

  return true
}

Missing anything? Corrections? Contributions are welcome 😃

Related

Written by

Federico Zanetello

Federico Zanetello

Software engineer with a strong passion for well-written code, thought-out composable architectures, automation, tests, and more.