Weekly Swift articles, podcasts and tips by John Sundell.

Refactoring Swift code for testability

Published on 15 Jul 2018
Basics article available: Unit Testing

Unit testing can be a great tool in order to improve the quality of an app, and to enable the team working on it to iterate and release faster and more often. However, being able to use unit testing in a productive way also requires the various parts of an app to be written with testability in mind, which isn't always the case.

While we've already covered a lot of different testing techniques, as well as architectural tools for enabling testability (such as dependency injection and logic controllers), in previous articles - this week, let's take a step back and take a look at a few different refactoring techniques that can help us make non-testable code much easier to test.

Pure functions

One characteristic of easy-to-test code is that its APIs act more or less like pure functions. A function is considered "pure" when it doesn't generate any side-effects, so that we always get the exact same output for a given input, no matter where or how many times the function is called.

While most of us don't do pure functional programming when building apps (especially since Apple's SDKs are very heavily object-oriented and stateful), trying to organize the code we wish to test as pure input and output can really help improve its testability.

Let's take a look at an example, in which we want to start testing a ShoppingCart API in a shopping app, which currently looks like this:

class ShoppingCart {
    static let shared = ShoppingCart()

    private var products = [Product]()
    private var coupon: Coupon?

    func add(_ product: Product) {
        products.append(product)
    }

    func apply(_ coupon: Coupon) {
        self.coupon = coupon
    }

    func startCheckout() {
        var finalPrice = products.reduce(0) { price, product in
            return price + product.cost
        }

        if let coupon = coupon {
            let multiplier = coupon.discountPercentage / 100
            let discount = Double(finalPrice) * multiplier
            finalPrice -= Int(discount)
        }

        App.router.openCheckoutPage(forProducts: products,
                                    finalPrice: finalPrice)
    }
}

As you can see above, ShoppingCart performs almost all of its logic internally, keeps its state private, and globally accesses the App.router API to navigate to the checkout page once it's told to start the checkout process.

While keeping code contained and state private can be a really good thing, in this case it prevents us from writing any meaningful tests (without jumping through lots of hoops and doing things like hacking App.router to be able to intercept calls to open the checkout page).

Let's start refactoring ShoppingCart to improve its testability, starting with extracting the price calculation from above into a pure function that we can easily test. What we're going to do in this case is to create a PriceCalculator class that we can use to statically calculate the final price for an array of products, without keeping any form of state, like this:

class PriceCalculator {
    static func calculateFinalPrice(for products: [Product],
                                    applying coupon: Coupon?) -> Int {
        var finalPrice = products.reduce(0) { price, product in
            return price + product.cost
        }

        if let coupon = coupon {
            let multiplier = coupon.discountPercentage / 100
            let discount = Double(finalPrice) * multiplier
            finalPrice -= Int(discount)
        }

        return finalPrice
    }
}

The beauty of the above approach is that we can now test our price calculation code completely in isolation. For example, we can now write two tests to verify that the final price is being correctly calculated, both with and without a coupon:

import XCTest

class PriceCalculatorTests: XCTestCase {
    func testCalculatingFinalPriceWithoutCoupon() {
        let products = [
            Product(name: "A", cost: 30),
            Product(name: "B", cost: 80)
        ]

        let price = PriceCalculator.calculateFinalPrice(
            for: products,
            applying: nil
        )

        // We hard code the expected value here, rather than dynamically
        // calculating it. That way we can avoid calculation mistakes
        // and be more confident in our tests.
        XCTAssertEqual(price, 110)
    }

    func testCalculatingFinalPriceWithCoupon() {
        let products = [
            Product(name: "A", cost: 30),
            Product(name: "B", cost: 80)
        ]

        let coupon = Coupon(
            code: "swiftbysundell",
            discountPercentage: 30
        )

        let price = PriceCalculator.calculateFinalPrice(
            for: products,
            applying: coupon
        )

        XCTAssertEqual(price, 77)
    }
}

All we have to do now is to swap out the inline price calculation logic in ShoppingCart with a call to our shiny new, fully tested, PriceCalculator:

func startCheckout() {
    let finalPrice = PriceCalculator.calculateFinalPrice(
        for: products,
        applying: coupon
    )

    App.router.openCheckoutPage(forProducts: products,
                                finalPrice: finalPrice)
}

One big step towards a more complete test coverage! 👍

Dependency Injection

Next, let's improve the testability of our ShoppingCart even further by injecting its dependencies.

Like we took a look at in "Different flavors of dependency injection in Swift", there are multiple ways that we can use dependency injection, each with its own use cases and pros/cons. However, regardless of the flavor we pick, the goal remains the same - to explicitly define what dependencies a certain type has and to enable those dependencies to be fully controlled in our tests.

Apart from our new PriceCalculator utility, our ShoppingCart currently depends on a Router type that it uses for navigation. Now, the question is whether a shopping cart really should know anything about navigation, but for now - let's focus on improving the testability of our code without changing it too much. What we want to do here is to create an abstraction over Router that ShoppingCart can use to open the checkout page, without depending on any concrete implementation.

To do that, let's start by defining a protocol that ShoppingCart can use to open the checkout page. We'll simply extract the method that we're using from Router and add it to a new protocol, which we'll then make Router conform to through an extension, like this:

protocol CheckoutPageOpener {
    func openCheckoutPage(forProducts products: [Product],
                          finalPrice: Int)
}

extension Router: CheckoutPageOpener {}

Next, instead of having ShoppingCart access the global App.router directly, we'll inject it as part of its initializer, disguised as any type conforming to CheckoutPageOpener. We'll then store it in a property and use it in our startCheckout method:

class ShoppingCart {
    private let checkoutPageOpener: CheckoutPageOpener

    init(checkoutPageOpener: CheckoutPageOpener = App.router) {
        self.checkoutPageOpener = checkoutPageOpener
    }

    func startCheckout() {
        let finalPrice = PriceCalculator.calculateFinalPrice(
            for: products,
            applying: coupon
        )

        checkoutPageOpener.openCheckoutPage(forProducts: products,
                                            finalPrice: finalPrice)
    }
}

As you can see above, we use App.router as a default argument in the initializer. That way we can maintain backward compatibility and still enable our shopping cart to be just as easy to use as before, while still improving its testability.

The benefit of injecting our dependencies like above (apart from making it crystal clear what external types our code depends on) is that we can now easily mock CheckoutPageOpener in our tests to be able to verify that it's being correctly called:

class CheckoutPageOpenerMock: CheckoutPageOpener {
    private(set) var products: [Product]?
    private(set) var finalPrice: Int?

    func openCheckoutPage(forProducts products: [Product], finalPrice: Int) {
        self.products = products
        self.finalPrice = finalPrice
    }
}

The above CheckoutPageOpenerMock is a simple capturing mock, that captures and stores the parameters it gets sent, in order to enable us to later verify that those parameters were correct. A key to being able to easily use mocking in Swift is to keep the protocols we wish to mock as simple as possible, eliminating the need for complex mocks that have a ton of logic in them.

Finally, let's write a test using our new mock and the ability to inject a custom CheckoutPageOpener into our shopping cart:

import XCTest

class ShoppingCartTests: XCTestCase {
    func testStartingCheckoutOpensCheckoutPage() {
        // Given
        let opener = CheckoutPageOpenerMock()
        let cart = ShoppingCart(CheckoutPageOpener: opener)
        let product = Product(name: "Product", cost: 50)
        let coupon = Coupon(code: "Coupon", discountPercentage: 20)

        // When
        cart.add(product)
        cart.apply(coupon)
        cart.startCheckout()

        // Then
        XCTAssertEqual(opener.products, [product])
        XCTAssertEqual(opener.finalPrice, 40)
    }
}

Above we're using the "Given, When, Then" structure from "Making Swift tests easier to debug", to make our test code easier to read and to separate actions from verification.

We've now transformed ShoppingCart from a very hard to test class into one that has 100% test coverage. All we had to do was to extract part of our logic into a pure function and enable our external dependencies to be injected. Pretty cool! 🎉

Conclusion

Identifying what changes and refactors that need to be made in order to make a certain piece of code testable can be really tricky at first. Some classes might seem like lost causes and that implicit dependencies and globally shared state runs too deep in order for them to ever be tested. However, by breaking the problem down and starting to extract pieces one by one, we can eventually turn even the most untestable code into something we can start writing tests against.

When performing refactors like this I recommend starting at the very bottom, and working your way up the stack. It might be tempting to start refactoring the top-level APIs directly, but it often leads to having to replace the entire implementation. For example, now that we've successfully refactored ShoppingCart, we could move on to refactoring the types that use it, and keep working our way through our code base.

What do you think? Have you recently performed some of these refactors in order to improve testability - or is it something you'll try out? Let me know - along with your comments, questions and feedback - on Twitter @johnsundell.

Thanks for reading! 🚀