How to implement unit tests in Swift like a pro
Unit tests are critical in the quality of software products, as they allow us to identify and fix bugs before the features are available to users and also protect our flows in future modifications.
Unfortunately, we don’t always care about the quality of test code in the same way that we invest in the quality of production code, which ends up generating unreadable or unreliable test cases.
Among the “undesirable tests” we can mention:
- Weak tests: test cases that pass successfully without properly testing the functionality, creating a false sense of safety while they don’t actually test anything;
- Tests with a large number of assertions: they violate the Single Responsibility Principle (SRP) and make the code difficult to read due to verbosity.
In this article, I present an approach that I use in my iOS projects to obtain tests with greater readability, robustness, and reliability, avoiding weak tests and tests with a large number of assertions.
Let’s consider a simple to-do list built on the MVVM pattern, whose initial code can be obtained from GitHub by checking out the initial branch. The business rules are in the view model layer, so that’s what we want to test:
Basically, we have a
loadTaskList function that enables the loading state, loads the necessary data, and presents it on the screen through a delegate. The delegate has three functions:
Assuming we want to write a test for the successful case of loading the task list. Initially, we need to define a Spy for the delegate in order to observe the values the view model sends to this element:
Then, we can write the desired test case:
Running the test, we can verify that it passes successfully.
Apparently no problem.
This is an approach I’ve seen in many projects and it’s supposedly the most common way we all learn to create unit tests initially. If we search at YouTube, we will find several test examples following this approach.
But, if we want to evolve in the developer career and reach higher levels of seniority, we need to always improve our techniques and make our codes more and more professional.
With that in mind, let’s discuss some issues with this approach that deteriorate the quality of our tests.
Let’s try to modify the behavior of the view model by adding a call to the
If we run the test again, we will see that it still passes successfully (!!!).
Note that we introduced some unwanted behavior and the test was not able to identify the bug, so this is a fragile test.
At this point, the reader may be thinking: “but then, just adding new assertions to ensure no other functions are called will solve the problem!”.
However, this configuration would not scale well, as it violates the Single Responsibility Principle by introducing more than one responsibility for the same test case. It’s not difficult to realize that in more complex classes the number of assertions required reaches tens, making the test code very difficult to read.
Also, we wouldn’t be covering every possible case. Let’s now try to call the correct functions but in reverse order:
When running the test, we verify that it still passes.
This occurs because we never check in which order the functions were called, which creates gaps to introduce unexpected behavior as in this example.
But then, how to make our tests more resilient without breaking the SRP?
The good news is that there is a very simple approach that fits in this case: the Message pattern.
It basically consists of defining an enum of messages in the Spy class and an array responsible for storing “messages” of all actions received by the Spy. Thus, we have the information of exactly which actions were performed and their order:
The advantage of applying this pattern in Swift is that it allows us to store values associated with cases, which is very useful in functions that have arguments, as in the case of
On re-running the test, we see that it now fails, which is the expected behavior.
However, if we adjust the behavior of the function to the correct state, we see that the test now passes, so we have a much more reliable test capable of indicating the presence of unexpected behavior.
Writing good unit tests is valuing the quality of our code, so we need to be careful with them and avoid implementing fragile tests or tests that are difficult to read and understand.
This article presents a pattern for writing tests for the view model layer in an MVVM architecture, but the same approach can be adapted to other more robust architectures, such as VIP and Clean Swift, just by adapting the structures inherent to each architectural pattern.
Thanks for reading! If you have any questions, please leave a reply.