Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

15. Adding Features to Existing Classes
Written by Michael Katz

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

You won’t always have the time, or it may simply not be feasible, to break dependencies of a very large class. If you have a deadline to add a new feature and your app is a bowl of spaghetti, you won’t have the time to straighten it all out first. Fortunately, there are TDD techniques to tackle this situation.

In this chapter, you’ll learn strategies to add functionality to an existing class, while at the same time, avoiding modifying it! To do this, you’ll learn strategies like sprouts and dependency injection.

To demonstrate these ideas, you’ll add some basic analytics to the MyBiz app’s main view controllers. After all, every business wants to know what their users are doing.

Getting started

Use the back-end and starter projects from this chapter, as they have a few modifications from the last chapter that you’re going to need. Start up the back end. As always, refer back to Chapter 11, “Legacy Problems” if you need help getting it running.

Your objective is to add a screen to view analytics events for each of the five main view controllers: Announcements, Calendar, Org Chart, Purchase Orders and Settings. This way, the product owners will be able to identify the most-used screens, to figure out where to invest time and resources.

Reporting an analytics event involves:

  • A user-initiated action, like a screen view or button tap.
  • A Report that contains the metadata for the event.
  • Sending that report to the back end.

Sending reports

It will be easiest, in this case, to start from the bottom up: Adding the ability to send reports to the back end. You already have a class that communicates with the back end, API. You’ll create an extension for this class to handle the new functionality while avoiding bloating the current file.

Laying a foundation

First things first, take what you learned in the previous chapter and start with a protocol to keep the dependencies clean and make the work easier to test.

protocol AnalyticsAPI {

}
import XCTest
@testable import MyBiz

class AnalyticsAPITests: XCTestCase {
  var sut: AnalyticsAPI!

  override func setUpWithError() throws {
    try super.setUpWithError()
  }

  override func tearDownWithError() throws {
    try super.tearDownWithError()
  }

  func testAPI_whenReportSent_thenReportIsSent() {
    // given
    let date = Date()
    let interval: TimeInterval = 20.0
    let report = Report(
      name: "name",
      recordedDate: date,
      type: "type",
      duration: interval,
      device: "device",
      os: "os",
      appVersion: "appVersion")

    // when send a report?
    // ???

    // then assert a report was sent
    // ???
  }
}

Extending the API

The first step is to send the report. You already have a class that sends stuff to the back end: API. As you may have seen from previous chapters, this class is cumbersome and is interwoven with the rest of the app code. Ideally, you want to add new functionality to it without increasing its complexity or introducing new dependencies.

extension API: AnalyticsAPI {
}
var sut: AnalyticsAPI { return sutImplementation }
var sutImplementation: API!

override func setUpWithError() throws {
  try super.setUpWithError()
  sutImplementation = API(server: "test")
}

override func tearDownWithError() throws {
  sutImplementation = nil
  try super.tearDownWithError()
}

Sending a report

Now you can start thinking about that report.

func sendReport(report: Report)
func sendReport(report: Report) {
}
// when
sut.sendReport(report: report)
protocol RequestSender {
  func send<T: Decodable>(
    request: URLRequest,
    success: ((T) -> Void)?,
    failure: ((Error) -> Void)?
  )
}
extension API: RequestSender {
  func send<T>(
    request: URLRequest,
    success: ((T) -> Void)?,
    failure: ((Error) -> Void)?
  ) where T: Decodable {
    let task = loadTask(
      request: request,
      success: success,
      failure: failure)
    task.resume()
  }
}
lazy var sender: RequestSender = self

Testing the API

In the MyBizTests target, create a new group: Mocks. In that group, create a new file, MockSender.swift, and replace its contents with the following:

import XCTest
@testable import MyBiz

class MockSender: RequestSender {
  var lastSent: Decodable?

  func send<T: Decodable>(
    request: URLRequest,
    success: ((T) -> Void)?,
    failure: ((Error) -> Void)?
  ) {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601

    do {
      let obj = try decoder.decode(
        T.self,
        from: request.httpBody!)
      lastSent = obj
      success?(obj)
    } catch {
      print("error decoding a \(T.self): \(error)")
      failure?(error)
    }
  }
}
var mockSender: MockSender!
mockSender = MockSender()
sutImplementation.sender = mockSender
mockSender = nil
// then
XCTAssertNotNil(mockSender.lastSent)
XCTAssertEqual(report.name, "name")
XCTAssertEqual((mockSender.lastSent as? Report)?.name, "name")

Sprouting the send method

API already has a method that takes an object and sends it to the back end: submitPO(po:). It’s too bad that this is specifically for sending purchase orders. You could refactor this method by mapping its dependencies, writing characterization and unit tests, and expanding the API functionality in a reusable way.

extension API {
  // 1
  func logAnalytics(
    analytics: Report,
    completion: @escaping (Result<Report, Error>) -> Void
  ) throws {
    // 2
    let url = URL(string: server + "api/analytics")!
    var request = URLRequest(url: url)
    if let token = token?.token {
      let bearer = "Bearer \(token)"
      request.addValue(
        bearer,
        forHTTPHeaderField: "Authorization")
    }
    request.addValue(
      "application/json",
      forHTTPHeaderField: "Content-Type")
    request.httpMethod = "POST"

    let coder = JSONEncoder()
    coder.dateEncodingStrategy = .iso8601
    let data = try coder.encode(analytics)
    request.httpBody = data

    // 3
    sender.send(
      request: request,
      success: { savedEvent in
        completion(.success(savedEvent))
      },
      failure: { error in
        completion(.failure(error))
      })
  }
}
try? logAnalytics(analytics: report) { _ in }

Adding analytics to the view controllers

The hard work is over, and the rest should be easy, right? If you think back to the list of steps for analytics, you still need to implement this part:

import XCTest
@testable import MyBiz

class AnnouncementsTableViewControllerTests: XCTestCase {

  var sut: AnnouncementsTableViewController!

  override func setUp() {
    super.setUp()
    sut = UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier:
        "announcements")
      as? AnnouncementsTableViewController
  }

  override func tearDown() {
    sut = nil
    super.tearDown()
  }

  func whenShown() {
    sut.viewWillAppear(false)
  }

  func testController_whenShown_sendsAnalytics() {
    // when
    whenShown()

    // then the report will be sent
    // ???
  }
}

Not mocking all of the API

You’ve already set up a protocol to help out with the testing: AnalyticsAPI. You don’t need to use API or mock out the RequestSender at all.

import XCTest
@testable import MyBiz

class MockAnalyticsAPI: AnalyticsAPI {
  var reportSent = false

  func sendReport(report: Report) {
    reportSent = true
  }
}
var mockAnalytics: MockAnalyticsAPI!
mockAnalytics = MockAnalyticsAPI()
sut.analytics = mockAnalytics
mockAnalytics = nil
XCTAssertTrue(mockAnalytics.reportSent)
var analytics: AnalyticsAPI?
let screenReport = Report(
  name: AnalyticsEvent.announcementsShown.rawValue,
  recordedDate: Date(),
  type: AnalyticsType.screenView.rawValue,
  duration: nil,
  device: UIDevice.current.model,
  os: UIDevice.current.systemVersion,
  appVersion: Bundle.main.object(
    forInfoDictionaryKey: "CFBundleShortVersionString")
    as! String)
analytics?.sendReport(report: screenReport)

Another interesting use case

To implement the prior test, you set up a whole mock instance of AnalyticsAPI. You can use this for testing, without having to worry about the messiness that was previously built into MockAPI as a subclass of API. By using this protocol and starting with a mock implementation, you’ll ensure by default that any new methods you add to the app will be testable.

var reportCount = 0
reportCount += 1
func testController_whenShownTwice_sendsTwoReports() {
  // when
  whenShown()
  whenShown()

  // then
  XCTAssertEqual(mockAnalytics.reportCount, 2)
}

Passing around dependencies

The analytics feature now works in tests, but not when you run the app. That’s because you still need to pass AnalyticsAPI to the AnnouncementsTableViewController.

protocol ReportSending: AnyObject {
  var analytics: AnalyticsAPI? { get set }
}
extension AnnouncementsTableViewController: ReportSending {}
func handleLogin(userId: String) {
  self.userId = userId

  let storyboard = UIStoryboard(name: "Main", bundle: nil)
  let tabController = storyboard
    .instantiateViewController(withIdentifier: "tabController")
    as! UITabBarController

  tabController.viewControllers?
    .compactMap { $0 as? ReportSending }
    .forEach { $0.analytics = api }

  rootController = tabController
}

Adding more events

So you now have one screen sending reports. It should be straightforward to add reports to additional screens. For example, in OrgTableViewController.swift add the following var:

var analytics: AnalyticsAPI?
extension OrgTableViewController: ReportSending {}
import XCTest
@testable import MyBiz

class OrgTableViewControllerTests: XCTestCase {

  var sut: OrgTableViewController!
  var mockAnalytics: MockAnalyticsAPI!

  override func setUp() {
    super.setUp()
    sut = UIStoryboard(name: "Main", bundle: nil)
      .instantiateViewController(withIdentifier: "org")
      as? OrgTableViewController

    mockAnalytics = MockAnalyticsAPI()
    sut.analytics = mockAnalytics
  }

  override func tearDown() {
    sut = nil
    mockAnalytics = nil
    super.tearDown()
  }

  func whenShown() {
    sut.viewWillAppear(false)
  }

  func testController_whenShown_sendsAnalytics() {
    // when
    whenShown()

    // then
    XCTAssertTrue(mockAnalytics.reportSent)
  }
}
static func make(event: AnalyticsEvent,
                 type: AnalyticsType) -> Report {
  Report(
    name: event.rawValue,
    recordedDate: Date(),
    type: type.rawValue,
    duration: nil,
    device: UIDevice.current.model,
    os: UIDevice.current.systemVersion,
    appVersion: Bundle.main.object(
      forInfoDictionaryKey: "CFBundleShortVersionString")
      as! String)
}
let report = Report.make(
  event: .orgChartShown,
  type: .screenView)
analytics?.sendReport(report: report)

Challenge

There are few tasks left undone that you should take care of:

Key points

  • You don’t have to bring a whole class under test to add new functionality.
  • You can sprout a method to extend functionality, even if it adds a little redundancy.
  • Use protocols to inject dependencies and extensions to separate new code from legacy code.
  • TDD methods will guide the way for clean and tested features.

Where to go from here?

Although you’ve come a long way, you’ve just scratched the surface of making changes and improving code. You can continue to decompose API into specific protocols like AnalyticsAPI and LoginAPI. You can also now incrementally improve API by replacing delegates with Results and using the RequestSender to make the code more testable.

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