This post will focus on how Swift pure functions can help you write more effective unit tests for your iOS app. However, I’d like to start with a quick backstory about how our team discovered these benefits.
I worked on the Salesforce Events iOS team from 2016 to 2020. During this time, we began updating our codebase from Objective-C to Swift. Early into this process, we decided that we also wanted to adopt functional reactive principles made possible by Swift first-class functions.
As we worked on migrating from Objective-C to Swift, we became aware of the transformative nature brought about by first-class functions. As this awareness increased, our effort became less a line-per-line code migration between Objective-C and Swift. Instead, we began to focus on how best to use first-class functions to make a better app for our users.
After some time, we settled on some guiding principles which can be distilled to:
- Keep our functions pure
- Minimize the state our classes hold
In the end, these principles helped to lower our crash rate, increased our development velocity, improved app performance, and made it easier to unit test our code.
In this article, I will focus on the specifics of why pure functions can improve the impact your unit tests.
What are pure functions?
- A function is pure if it always returns the same value for a given set of inputs
- A pure function is free of side effects
Side effects are reads or writes to the state outside of the function’s set of input parameters.
Here’s an example of an impure function that produces the sum of two numbers.
A pure example, which meets our two criteria above, returns the same output for given input and is free of side effects.
Cost per test
Let’s start by defining what I mean by “cost” when it comes to unit tests. In this article, test “cost” is a measure of the effort needed to write a unit test. The two examples below will show the relative cost difference to test pure and non-pure functions.
Given the example functions above, testing
pureAdd can be done compactly. This is because writing a test that pairs an input with its expected output is as easy it gets. In these cases, no mocking or state initialization are required.
However, the non-pure example requires more setup per assertion.
In general, the more side effects that a function has, the higher the cost per test.
Test effectiveness of pure functions
The goal of unit tests should be to prove that the function under test produces the correct output for the input space.
For the function
isTrue(input: Bool) → Bool, the input space has two possibilities: true and false.
So, to prove correctness, we’ll need two assertions:
If we add a second Bool parameter, we double the input space and double the number of assertions needed to prove correctness:
For pure functions, the number of assertions needed to prove correctness is a product of the input space. This means that limiting the input space helps to lower testing effort for proving the correct output for all input.
To lower the input space, we should favor using input parameters with a discrete number of possible values. For example, consider using enum vs String when possible because an enum has a finite set of values while a String has an unbounded number of possible values.
Test effectiveness of non-pure functions
For non-pure functions, proving the correct output for the input space is considerably more challenging for a few reasons:
- The input into a non-pure function may include object and global state
- This expanded input space increases the number of assertions needed to prove correctness
- As seen above, the scaffolding cost per test is often higher for these functions
- Shared state can change externally before the function completes
Given the increased effort required, proving correctness may become intractable for these functions and we instead target code coverage.
While coverage is a good metric to help make sure functions have some level of unit testing, I don’t believe having code coverage is equivalent to ensuring the correctness of the function.
This article is an introduction into what defines a pure function and how you can leverage pure functions to amplify the effectiveness of your unit testing efforts by proving correctness with lower effort.
Although I focused on Swift pure functions and testing, there are many more benefits to be gained from functional programming and Swift like: concurrency, reactive, and composition that are worth exploring.
About the author
Adrian Ruvalcaba is an iOS developer on the Salesforce App mobile team. He has been obsessed with mobile development since the iPhone was introduced. He’s especially fond of bringing functional programming concepts to mobile. When he’s not coding, Adrian enjoys learning Italian, Spanish, and taking dance classes with his kids.