Pt. 3 Swift Testability By Example
In my last article, I discuss the easiest path to testable Swift. In that article I list qualities that make tests valuable. Additionally, I show that business and application logic should be decoupled from volatile or asynchronous dependencies. Now I’d like to focus on the “State” object that houses all of this logic and illustrate some design decisions that will make it easier to test.
An experienced tester knows that certain features are trivial to test while others must be mangled and distorted before they yield to testing. Tests that are easy to write are ones that require little or no arrangement code, don’t require new classes to enable testing (ie. mocks or stubs) and produce easily verifiable output. The easiest thing in the world to test is a pure function:
let sum = addInts(3, 4)
XCTestAssertEqual(sum, 7)
We dream of tests that look like this. A pure function is one who’s return value is solely determined by its input parameters. Its internals don’t reference any global mutable data which can affect the return value. For a given input, it will always return the same output. Its output is another value, not a write to a database or network request. The output is a value that can be easily verified using the ==
operator.
The next easiest thing to test is a mutable object that has internal state but doesn’t reference any volatile dependencies. To test these objects we must first construct the object, then mutate it until it is in the state we’d like to verify and finally check that certain conditions are true. An example of this would be:
var calculator = Calculator(startingValue:0)
calculator.press(key: .three)
calculator.press(key: .plus)
calculator.press(key: .four)
calculator.press(key: .equals)
let finalValue = calculator.currentValue
XCTestAssertEqual(finalValue, 7)
Again, this class (or struct) doesn’t make any network calls and doesn’t reference any global data; calling these methods, in this order, with these parameters will always result in the same final value. It’s not quite as simple as the previous example but it is still obvious what this test does and why.
We only want to write tests that resemble these two forms. Unfortunately, most unit tests I’ve seen in Swift follow a different pattern.
class MockAPIClient: APIClient {
var error: Error?
func make(request: APIRequest, onComplete complete:(Error?) -> ()) {
complete(error)
}
//Add all other methods required by APIClient,
//possibly stubbing return values to make
//MockAPIClient behave like the real APIClient
}
class MockLoginDelegate: LoginViewModelDelegate {
var showErrorBannerCalled: Bool = false
func showErrorBanner(withMessage: String) {
showErrorBannerCalled = true
}
//Stub all other LoginViewModelDelegate methods
}
let mockDelegate = MockLoginDelegate()
let apiClient = MockAPIClient()
apiClient.error = //some error
let viewModel = LoginViewModel(apiClient: apiClient)
viewModel.delegate = mockDelegate
viewModel.userTappedLogin(username: "steve_jobs", password: "i<3apple")
XCTestAssertTrue(mockDelegate.showErrorBannerCalled)
I avoid this style because it violates a number of rules outlined in my previous article.
- First, it is long and it would be a lot longer if I had implemented every method in
LoginViewDelegate
andAPIClient
. Long tests force the reader to figure out what code is required by the test versus what is there just to keep the compiler happy. They are also brittle; every line must be maintained and can break if an interface changes. Besides, who wants to type that much. - In order for
LoginViewModel
to function properly,MockAPIClient
must return the proper values from all its public methods. For instance, ifhasNetworkConnection: Bool
is declared inAPIClient
, we need to grok the implementation details ofLoginViewModel
in order to know that this method must returntrue
for the tests to behave properly. Any time a test is coupled to implementation details like this, it’s sure to cause trouble. - In order to inject the
MockAPIClient
, we are forced to add theAPIClient
protocol which wouldn’t otherwise be there. This protocol needs to be updated wheneverRealAPIClient
‘s public interface changes. This forces all of the tests to be updated as well. Protocols add a level of abstraction that can make code harder to reason about; it’s unclear to the reader thatAPIClient
will always be aRealAPIClient
at runtime.
Converting the above to a more simplified form gives us the following:
let loginState = LoginState()
let error = /*some error*/
let commands: [LoginState.Command] = loginState.handleLogin(response: nil, error: error)
XCTestAssertEqual(commands, [.showErrorBanner("Failed to log in")])
Four lines of code, no mocks or stubs and no protocols added. Value types are passed in and value types are returned. The production code might look like the following:
class LoginViewModel {
var state = LoginViewState()
let apiClient = APIClient()
func loginTapped() {
let commands = state.loginTapped()
self.handle(commands: commands)
}
func handle(commands: [LoginViewState.Command]) {
for command in commands {
switch command {
case .sendLoginRequest(let request):
apiClient.send(request: request) { response, error in
let commands = self.state.handleLogin(response, error)
self.handle(commands: commands)
}
case .showErrorBanner(let message):
self.ui.showErrorBanner(message)
case .showLandingScreen:
self.delegate.transitionToLandingPage()
}
}
}
}
disclaimer: I have no idea if any of this compiles
I’ve left out some details for brevity but you can see how the logic of what to do is embedded in the state object while how to do it is found elsewhere. Of course, this is a toy example but you can imagine the state object will become more complex as requirements are added (eg. validation logic, error cases). This will be contained and kept separate from the IO represented by the Command
objects. You can find more examples of these patterns here.
In this article I’ve outlined some specific examples of code that is easily testable. I’ve also given some pointers on how to restructure code to be tested painlessly. Thanks for reading!
Edit: Here I’ve been carrying on about separating logic and IO, using value types and avoiding mutation like it was some great discovery but turns out apple outlined a lot of these ideas at WWDC 2017. 🤦♀️ If you haven’t caught that talk yet, give it a watch. It’s full of good information.
I'm a freelance iOS developer based in San Francisco. Feel free to contact me.