Pure functions, as defined by Functional Programming, can provide a simple set of rules for one to follow to improve code testability.
🤔 What are pure functions?
Pure functions, when called with a given input, compute and return output and do nothing else. It’s a mapping of values to values.
Pure functions should verify these three rules:
- total: there’s a result for any possible input
- deterministic: the result will always be the same if the input is the same
- no side-effect: the function does nothing but computing the result
Pure functions are crucial in Functional Programming for “referential transparency”. It allows for composition of functions in a way we can reason about, much like mathematics. But even if you’re not interested in adopting Functional Programming, you can use this definition to improve your code.
😰 Why impure functions may make your code harder to test
Impure functions (functions that break one or more of the rules listed above) are harder to work with:
- A partial function is one that has preconditions. The compiler can’t help you to make sure you’re always verifying the preconditions. You need to read the code or the documentation to know about them and write code and tests for that. Calling the function with unsupported input will cause issues (e.g. dividing by zero)
- A non-deterministic function may return different results when called with a given input. Such functions can hardly be unit tested, as you can’t call the function with fixed input and compare the result with an expected value.
- Functions with side-effects, much like partial functions, require knowledge of their internals. It does more than it says on the box if the box is the function signature. So like partial functions, compilers can’t help you. This time though, the problem is not that some input is invalid, it’s that somehow, part of the result is “hidden”.
🐛 Impurity spreads
One important thing to note is that impurity tends to spread. Let’s take an example of a function that can tell me how many cookies I can have today. When it’s my birthday, I’m allowed to have two cookies, instead of one:
In “isMyBirthday()”, we use “Date()” which is a non-deterministic function (it creates a Date object with the current date and time which won’t return the same result if we call the function today and tomorrow). “isMyBirthday()” becomes non-deterministic, as a result will change over time.
Finally, because “isMyBirthday()” is non-deterministic, “maximumCookie()” will be too (the function returns a different value if today is my birthday) and any function that uses it, and so on.
The same thing would happen with partial functions or functions with side-effects.
We can see that using an impure function is not a local problem for testability, and can have a significant impact on our test coverage.
🧹 Refactoring impure functions
Can we make all our functions pure then? No. You will still need some impure functions to write any valuable application — like calling an endpoint, storing a value in the UserDefaults, getting the current locale to format numbers, etc. But we can try to remove impure functions from the parts of our app that matter most like our business logic, that we would like to cover with tests.
Whenever you find a function that is hard to test, identify which rule is not satisfied, and follow the offenders until you reach a piece of code that causes the issues, and you can’t change. For our example, it would be when we call “Date()” in “isMyBirthday()”. Start by fixing the caller of this function:
- Is the function partial? You can change the input type to enforce the preconditions, or you can change the return type to account for unsupported inputs (“Optional” works well, or “Result” if you want to provide details about the broken precondition)
- Is the function non-deterministic? You may want to find a way to inject the state you depend on (could be a value or a function)
- Does the function have a side effect? Change your return type to one that describes the side-effect, without performing it, or pass the impure function as a parameter. Run the effects, or define and pass the impure function, at the outer layer of your application, for example, in your UIViewController.
Your function is now pure, and you’ll need to refactor the code using it. Repeat until you can test your initial problematic function.
Let’s refactor our example:
This is how you use the function, for example in your UIViewController:
You can now test your cookie distribution logic:
🍪 Parting words
Understanding what makes a function impure is an easy way to know why a piece of code is hard to test. Refactoring still requires a bit of practice, but it makes it easier.
Pure functions can also help much more than this. Join the Tide iOS team if you’re interested to see how we use these concepts in our app: