Refactoring Swift code for testability
Discover page available: Unit TestingUnit 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! 🚀