15.
Adding Features to Existing Classes
Written by Michael Katz
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 13, “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
Reportthat 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.
Create a new group named Analytics in the starter project under the MyBiz group. You’ll use this to organize all the analytics-related code and will make the project easier to navigate. It should have been better organized from the beginning, but you don’t always get to choose your starting project. Move Report.swift to this group. This file holds Report, which represents an individual analytics event to send back to the server.
Next, in that group, create a new Swift file named AnalyticsAPI.swift. You’ll use this to define a protocol to keep the analytics work separate from other back-end functions.
Replace the contents of AnalyticsAPI.swift with the following placeholder code:
protocol AnalyticsAPI {
}
Whenever you add new code, you should add tests first. In the MyBizTests/Cases group, create a new Unit Test Case Class named AnalyticsAPITests and add it to the MyBizTests target.
Replace the contents of the file with the following:
import XCTest
@testable import MyBiz
class AnalyticsAPITests: XCTestCase {
var sut: AnalyticsAPI!
override func setUp() {
super.setUp()
}
override func tearDown() {
super.tearDown()
}
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
// ???
}
}
testAPI_whenReportSent_thenReportIsSent() assumes AnalyticsAPI can send a Report, and then you’ll be able to verify that it was sent. The only question is how? There’s no good extension point in the app to easily do this.
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.
Thankfully, Swift allows you to split implementation across files through the use of extensions. Using extensions, you can add new functionality to API for analytics without having to perturb the existing mess any more than necessary.
Create a new file in the Analytics group: API+Analytics.swift. This naming convention lets you know that the file will contain an extension of API that has something to do with analytics.
Next, add the following extension to the file:
extension API: AnalyticsAPI {
}
And now you have a concrete AnalyticsAPI that you can use in your test.
Go back to AnalyticsAPITests.swift and replace sut, setUp() and tearDown() with the following:
var sut: AnalyticsAPI { return sutImplementation }
var sutImplementation: API!
override func setUp() {
super.setUp()
sutImplementation = API(server: "test")
}
override func tearDown() {
sutImplementation = nil
super.tearDown()
}
This creates a specific API instance stored in sutImplementation, but exposes it only as an AnalyticsAPI through the variable sut. This way, you can be sure you’re testing only AnalyticsAPI’s methods and not any other logic that might come along with API.
Sending a report
Now you can start thinking about that report.
Open AnalyticsAPI.swift add the following method to the protocol:
func sendReport(report: Report)
Next, open API+Analytics.swift and add the following implementation to the extension:
func sendReport(report: Report) {
}
Now, you have a method to send the report that you can use within the test.
Open AnalyticsAPITests.swift, find testAPI_whenReportSent_thenReportIsSent() and replace the when section with the following:
// when
sut.sendReport(report: report)
The hard part is figuring out how to test that the report was sent. This is a unit test, so you don’t want to rely on a live back end to verify the app logic. On of top that, the test instance of API doesn’t even have a valid URL to call!
What you really want is a mock object that stands in for the back end, but also uses the real API implementation. If you just mock AnalyticsAPI, then the test would only verify that, when you call an object method, the method executes. So you need the real API.
To get around this, another protocol and injection comes to the rescue! Open API.swift and add the following protocol to the file:
protocol RequestSender {
func send<T: Decodable>(request: URLRequest,
success: ((T) -> ())?,
failure: ((Error) -> ())?)
}
This method takes a URLRequest, sends it and reports back successes or failures in one or the other completion blocks.
API already has a method that basically does this, so it will be easy to implement. Add the following extension to the bottom of the file:
extension API: RequestSender {
func send<T>(request: URLRequest,
success: ((T) -> ())?,
failure: ((Error) -> ())?) where T : Decodable {
let task = loadTask(request: request,
success: success,
failure: failure)
task.resume()
}
}
This reuses loadTask(request:success:failure:) to make URLSessionTask and to forward the success and failure blocks. The method also starts the task, since it doesn’t return a value and there’s no other way to execute task.
Finally, add the following var to API below var token:
lazy var sender: RequestSender = self
This sets up a request sender that can be injected later, but uses itself as a default. This will be a leverage point for adding testing to API in the next step. It may seem a little indirect to have a self-reference like this. However, taking this step allows you to otherwise leave this class untouched and still add new functionality to it, including testing.
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? = nil
func send<T: Decodable>(request: URLRequest,
success: ((T) -> ())?,
failure: ((Error) -> ())?) {
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)
}
}
}
This class implements the RequestSender protocol by returning the object that you used to create the request body then storing it in lastSent. There are a lot of things you could do from here, but this is sufficient to finish the test.
Go back to AnalyticsAPITests.swift and add a variable for the mock:
var mockSender: MockSender!
Next, add the following to the end of setUp() :
mockSender = MockSender()
sutImplementation.sender = mockSender
Next, add the following to tearDown(), just before super.tearDown():
mockSender = nil
Finally replace the then section of testAPI_whenReportSent_thenReportIsSent() with the following:
// then
XCTAssertNotNil(mockSender.lastSent)
XCTAssertEqual(report.name, "name")
XCTAssertEqual((mockSender.lastSent as? Report)?.name, "name")
Remember that MockSender stores the sent object in lastSent, so you’re able to use this to verify the passed-in Report was sent. Build and run the test and you’ll see it still fails. You still need to supply the implementation for sendReport(report:).
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.
BUT, you don’t have time for that amount of refactoring right now. In this case, you’re going to do something your teachers told you never to do: Copy code. It’s okay. You’re going to have tests for this copied method, and this work is only meant to support you as you add analytics. You will go back and finish the refactor after you get this working.
Open API+Analytics.swift and add the following API extension to the end of the file:
extension API {
// 1
func logAnalytics(analytics: Report,
completion: @escaping (Result<Report, Error>) -> ()) 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))
})
}
}
This code replicates the code of submitPO(po:) with a few notable changes:
-
logAnalytics(analytics:completion:)takes an analyticsReportinstead of aPurchaseOrder. Also, importantly, it has a completion block which returns aResultinstead of relying on the hard-to-understand, and probably buggy,delegatethat came with the original app code. Taking advantage of new language features and modern patterns is a good idea if you can roll them out as you improve or add code. - Instead of the hard-coded endpoint for purchase orders, this has a hard-coded analytics endpoint.
- This uses the new
RequestSender.send(request:success:failure:)that you introduced toAPIearlier. This means that you’ll be able to test this method!
Here, you’re using a technique called sprouting a method, which is when you add a new method in an existing class that enhances or duplicates existing functionality so you can add a new feature. This technique allows you to sidestep going down a hole refactoring a class or potentially breaking things not yet under test. It allows you to define a new interface, cleanly separated from the legacy part of the code. In this case, the interface is even defined in a separate file.
To finish up this task, connect logAnalytics to AnalyticsAPI by adding the following to sendReport(report:) in the extension:
try? logAnalytics(analytics: report) { _ in }
You call logAnalytics(analytics:completion:), passing the report and a blank completion and no error handling.
Now, build and test and the tests will pass. You’ve successfully added a new (and testable!) method to API with only minimal intrusion into the existing codebase.
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:
- A user-initiated action, like a screen view or button tap.
You’ll start with the leftmost view controller: AnnouncementsTableViewController. First, create a new Swift File in MyBizTests/Cases named AnnouncementsTableViewControllerTests.swift.
Finally, replace the contents of the file with the following:
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
// ???
}
}
This sets up a test where the system under test is an AnnouncementsTableViewController. The purpose of testController_whenShown_sendsAnalytics() is to test that viewWillAppear(_:) will result in an analytics report being sent. whenShown() triggers this step. The next step is figuring out how to verify that.
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.
In the Mocks group, create a new Swift File named MockAnalyticsAPI.swift and replace its contents with the following:
import XCTest
@testable import MyBiz
class MockAnalyticsAPI: AnalyticsAPI {
var reportSent = false
func sendReport(report: Report) {
reportSent = true
}
}
This class implements AnalyticsAPI, but instead of sending the report on, it uses reportSent to flag that it triggered. Your previous tests on API ensure that the report will make its way to the server.
Back in AnnouncementsTableViewControllerTests.swift, add a new var to the class:
var mockAnalytics: MockAnalyticsAPI!
Next, add the following to the end of setUp():
mockAnalytics = MockAnalyticsAPI()
sut.analytics = mockAnalytics
This creates the new mock and sets it on the sut.
Next, add the following to tearDown(), just above super.tearDown():
mockAnalytics = nil
Next, in testController_whenShown_sendsAnalytics() add the following to the then condition:
XCTAssertTrue(mockAnalytics.reportSent)
Recall that MockAnalyticsAPI sets reportSent to false on initialization, and a successful sendReport(report:) should set it to true. This allows the test to verify the report will be sent.
Finally, to get the test to build and pass, you need to wire up viewWillAppear(_:) to the analytics API. In AnnouncementsTableViewController.swift add the following below var announcements:
var analytics: AnalyticsAPI?
Finally, add the following to the end of viewWillAppear(_:):
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)
This creates a Report with some useful information about the app, device and the specific event. You then hand it off to AnalyticsAPI, which sends it to the back end.
Build and test; you’re back to green.
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.
Another thing you can do with mocks is to verify the number of times a method is called or the order in which methods are called.
Open MockAnalyticsAPI.swift, and add the following below var reportSent:
var reportCount = 0
Next, add the following to the end of sendReport(report:):
reportCount = reportCount + 1
Now, every time you call sendReport(report:), reportCount increments.
Next, add the following test at the end of AnnouncementsTableViewControllerTests.swift:
func testController_whenShownTwice_sendsTwoReports() {
// when
whenShown()
whenShown()
// then
XCTAssertEqual(mockAnalytics.reportCount, 2)
}
This tests that each time the screen displays, it will send a report. Build and test and you should be all green.
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.
When using storyboards, you want to do this in a prepare(for:sender:) segue method to inject whatever dependencies you need into the next view controller (or, similarly, in a view model or other helper). This app uses a plain UITabBarController that’s manually added to the screen: There’s no prepare(for:sender:) method to override.
Therefore, you have to set analytics manually, too. You know that you’re potentially going to add it to many view controllers. It makes sense to think about a way that you can add it to existing classes with minimal impact. That means protocols to the rescue, once again.
Open AnalyticsAPI.swift and add the following protocol to the end of the file:
protocol ReportSending: AnyObject {
var analytics: AnalyticsAPI? {get set}
}
By adding a var and adhering to this protocol in any class, you’ll be able to inject an AnalyticsAPI implementation.
Open AnnouncementsTableViewController.swift and add the following extension to the end of the file:
extension AnnouncementsTableViewController: ReportSending {}
And, just like that, you can provide AnnouncementsTableViewController an analytics object without exposing any additional information about itself.
Open AppDelegate.swift, replace the contents of handleLogin(userId:) with the following:
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 }
window?.rootViewController = tabController
This now adds an AnalyticsAPI to all of the tab bar’s view controllers that adhere to ReportSending. Because that includes AnnouncementsTableViewController, you’ll now see logging whenever its viewWillAppear(_:) fires.
Build and run the app. After logging in, the AnnouncementsTableViewController tab will display. Open http://localhost:8080/api/analytics in a browser and you’ll see recorded events similar to those below:
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?
Finally, add the following extension to the end of the file:
extension OrgTableViewController: ReportSending {}
To implement ReportSending on this controller, start with a test. Create a new Swift File named OrgTableViewControllerTests.swift. Open MyBizTests\Cases and replace the contents with the following:
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)
}
}
This should look familiar, as it’s very similar to AnnouncementsTableViewControllerTests. testController_whenShown_sendsAnalytics() tests that a report is sent when OrgTableViewController displays.
To get the test to pass, OrgTableViewController will need to send the report when its view displays. But, before modifying viewWillAppear(_:), it would be a good idea to create a helper method so you don’t have to copy over the boilerplate.
Open Report.swift and add the following method to Report:
static func make(
event: AnalyticsEvent,
type: AnalyticsType) -> Report {
return 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)
}
This factory method takes care of all the constants that go into a report, so the caller only has to worry about the specifics on each screen. You should be comfortable enough with TDD at this point to write a test for it on your own (Check out ReportTests.swift in the final project if you want a hint).
You can now use this method in OrgTableViewController.swift. Add the following to the end of viewWillAppear(_:):
let report = Report.make(event: .orgChartShown,
type: .screenView)
analytics?.sendReport(report: report)
Now, the tests will pass. Build and run, and you should see two different screen events recorded as you change tabs.
Congrats, you’ve managed to add a new feature to a reasonably-complicated app. You’ve done so with minimal changes to the existing code and you’ve written tests along the way.
Challenge
There are few tasks left undone that you should take care of:
- Clean up the
AnnouncementsTableViewControllerto use theReport.makemethod. - Add
screenViewanalytics to the other screens. As a hint, you’ll have to forward theAnalyticsAPIthroughUINavigationControllers.
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.
You can also rework RequestSender into its own object to pass into API that contains the server details. Then you could replace MockAPI in the existing tests so you can write better and more comprehensive unit tests. This eliminates the need for characterization tests to contact a live sever altogether. Your work is never done.
This approach also has some downsides. The indirection introduced by lots of small protocols can make the code harder to debug, which is why having comprehensive tests is crucial. When sprouting methods, it can be tempting to never to go back and revisit your old code, leaving the app in a state that might be confusing for newcomers. It also means the legacy code never improves.
This is the end of the legacy code tutorials. Check out Design Patterns by Tutorials https://store.raywenderlich.com/products/design-patterns-by-tutorials for more techniques for reorganizing and isolating code.