Unit Testing is Easier Than You Think

I am ashamed to admit how many years I avoided incorporating unit tests into my iOS projects. The simple truth is that I was afraid of what I didn’t know. I don’t have a CS degree. I never studied programming formally. The terminology itself is intimidating. What is a unit? How do I know if my app has units in it? What does it mean to test them? Not understanding what they are or even what good unit tests look like, my anxiety filled the gaps in my knowledge with frightening mental imagery.

After struggling with them for a few years, and after finding the occasional inspiring tech talk, I have come to understand that not only is unit testing not scary, but in fact good unit testing is surprisingly easy. The simplest and best unit test looks exactly like this:

XCTAssertEqual(actual, expected)

That’s it. A straightforward comparison of some unknown value against what you expect that value to be. The goal with unit testing is to write simple, direct assertions like that one. Every other choice you make is just a means to that end. To see how, first let’s widen our field of vision to the code surrounding that assertion:

let input = ... // hard-coded inputs
let actual = SomeWidget().doSomething(with: input)
let expected = ... // hard-coded output
XCTAssertEqual(actual, expected)

A good unit test answers the question, “When I pass something into this other thing, what value do I get out?” Answering that question is easier if your input and expected output are written using simple, hard-coded constants. Unlike writing regular code, when you’re writing a unit test, using hard-coded data is mandatory. Swift literals are your friends. You jot down some hard-coded input values, and also a hard-coded expected output value. Sandwiched in the middle is the behavior you’re testing. Imagine if you wanted to test String.lowercased():

let input = "unIT TesTING Is NoT SO BAD"
let actual = input.lowercased()
let expected = "unit testing is not so bad"
XCTAssertEqual(actual, expected)

I’m calling a method called lowercased(). I’m passing a string into it (input) and I’m getting another string out of it (actual). I hope that the returned value is the same as another string (expected). By using string literals (instead of, say, dynamic values obtained from a networked resource), you’ve eliminated unpredictability from the test. There’s now only a single variable (in the algebraic sense) at play, the behavior of lowercased(). This is a good unit test.

This may strike you as overly simplistic, but I assure you it isn’t. Even the most complex behaviors in your app can be tested in this manner. If you have some dark corner of your app that you wish had unit tests, start by building a mental model of the problem that’s oriented towards that XCTAssert assertion you’re going to write. Say you want to add unit tests to some code that interacts with a web service. You have a class that looks like this:

class APIManagerHamburgerHelper {
    func getUser(withId id: String, completion: @escaping (Result) -> Void) {...}
}

Right now there’s no way to unit test that getUser method, not in the way that I’m advocating. There are several things hindering you. The method has no return value. It requires making a roundtrip request to an actual server. There are many jobs hiding inside the implementation of that method: building a URL request, evaluating a URLSession response envelope (response, data, and error), decoding JSON-encoded data, mapping any error along the way to your APIError type. Each of these hidden jobs is itself something that needs unit test coverage. To test them, you’ll need to expose those jobs in a form that is “shaped” like the .lowercased() example above. There’s no one single way to do this, but here’s a rough example. You can break out these jobs into a single-purpose utilities:

struct URLRequestBuilder {
    func getUserRequest(userId: String) -> URLRequest
}

struct URLResponseEnvelopeEvaluator {
    struct Success: Equatable {
        let response: HTTPURLResponse
        let data: Data
    }

    struct Failure: Swift.Error, Equatable {
        let response: URLResponse?
        let error: APIError?
    }

    typealias Result = Result<Success, Failure>

    func evaluate(data: Data?, response: URLResponse?, error: Error?) -> Result {...}
}

struct User: Decodable {
    let id: String
    let name: String
    let displayName: String
}

The knowledge of how to implement each of these jobs (building requests, evaluating responses, parsing data) has been extracted out of the untestable getUser method and into discrete types that lend themselves to straightforward unit tests. Testing the request builder might look something like this:

let id = "abc"
let actual = URLRequestBuilder().buildGetUserProfileRequest(userId: id)
let expected: URLRequest = {
    let url = URL(string: "https://baseurl.com/user/\(id)")!
    var request = URLRequest(url: url)
    request.addValue("foo", forHTTPHeaderField: "Bar")
    return request
}()
XCTAssertEqual(actual, expected)

Note how the input value and expected output value are all written using hard-coded constants as possible. As with all good unit tests, we pass hard-coded input into the member being tested, and compare the actual output against a hard-coded expected output value. Because inputs and expected outputs are hard-coded, we can write unit tests to cover any imaginable scenario. Perhaps you want to test a specific error pathway, what happens when the web service replies with a 401 status code. We set up the input values to closely reflect what a URLSession would actually present to the developer in a completion block:

let data: Data? = nil
let response = HTTPURLResponse(
    url: URL(string: "https://baseurl.com/user/abc")!,
    statusCode: 401,
    httpVersion: "1.0",
    headerFields: nil
)
let error = NSError(
    domain: NSURLErrorDomain,
    code: 401,
    userInfo: ["foo": "bar"]
)

Then we use those values as inputs to the method being unit tested, as well as to the expected result (where applicable):

let actual = URLResponseEnvelopeEvaluator().evaluate(
    data: data,
    response: response,
    error: error
)
let expected: URLResponseEnvelopeEvaluator.Result = .failure(Failure(
    response: response,
    error: .authenticationError401(error)
))
XCTAssertEqual(actual, expected)

In all the foregoing examples, no matter how hairy the subject matter, all the unit tests take the same shape:

This simple, repeatable pattern is what makes good unit tests “easy”. The hardest part isn’t writing the tests themselves, but rather structuring your code so that the behaviors are unit-testable in the first place. Doing that takes experience and much trial-and-error. That effort will come more easily to you once you have internalized the essential simplicity of a good unit test.

If you would like to learn more about refactoring your code for unit testing, I have a screencast on Big Nerd Ranch’s The Frontier with some live coding examples that you may find helpful.

|  1 Apr 2019