7 important aspects when writing Unit Tests in Xcode
Unit testing should be fast, reliable, and easy to understand by any current or future team member. Considering this in the development process can have a huge positive impact on the speed and quality of the project.
Below are 7 aspects that might help an iOS software developer enhance the way they write unit tests.
1. Use a SUT.
The System Under Test (SUT) from a Unit Testing perspective represents all of the actors (i.e one or more classes) in a test that are not mocks or stubs.
Think of this as the tested ViewModel object in the ViewModelTests file. It’s the object tested in isolation, using mocks and spies as helpers or dependencies. Almost all unit tests have a SUT, unlike acceptance or end-to-end tests.
In many cases, a unit test file has a factory method (i.e. createSUT() or makeSUT) responsible for setting up and instantiating the SUT object in the test suite. This centralizes the setup process, allowing for easier changes and fewer broken methods when some aspects of the setup change.
2. Consider using a Mock or a Spy, but don’t confuse them.
Mocks are used to create fully mock or dummy objects, usually conforming to a given protocol. They are mainly used to avoid performing certain operations that can be costly or are not necessary for testing the SUT, but the test setup requires them.
Sometimes mock objects have stubbed data, allowing for easy testing of the behavior for a given response coming from the mock protocol implementation.
A good example would be mocking an HTTP client when the test suite doesn’t require actual HTTP requests.
Another example is using a mock for a store, without performing disk operations or anything that is not required to perform the tests.
Spies are partially mocked objects, usually helpful when the test suite needs to assert that certain calls are being made. Just as mocks, spies are conforming to a given protocol most of the time, and spying calls is easily achieved with call counters or boolean variables for specific methods.
An example is using a spy to check that navigation actions are triggered in some specific scenarios. Another one is using a spy to guarantee that delegate methods are called when expected (mostly for POP).
3. Don’t leak business logic in testing.
Ideally, business logic (aka domain logic) is decoupled from tests, since it would (probably) change or become more complex in the future. Updating tests every time a policy or business detail is changing will require extra effort and attention. When possible, it is best to decouple tests from specific details that can and probably will change. For example, there might be a cache policy to invalidate cached feed older than 7 days. But after a while, the business decides to change it to 14 days. The tests should work regardless of this number. Setting a test-specific max age for the cache and testing against that could be a solution.
4. Don’t always use @testable if not necessary.
First of all, it’s important to understand that the test target, by default, has access only to public and open types and properties of an imported framework/target. This means anything with a lower access level is not accessible.
In many codebases, @testable is used in the test files, providing access to internal properties, making it easier to make assertions, and have exposure to more details of the SUT and associated components.
Depending on what the SUT is doing, it can be either:
- tested in detail (with testable) when internal behavior is less likely to change and can be exposed to clients (i.e. other components of the app)
- tested like an API (without testable) when only the given scenario and the outcome matter, regardless of how it is achieved internally, either because the implementation can vary or because it shouldn’t be exposed to clients (i.e. developers using the framework)
5. Use a macOS target if you can.
A good chunk of any iOS project is not related to UI. Things like networking, database, threading, and business logic can work just as well in a macOS target as they do in an iOS target.
The main advantage of separating them as packages, frameworks, or modules in macOS targets is that executing the entire test suite will take much less time. This is due to not needing to launch a simulator every time a test is run.
Faster test execution means developers could run tests more often locally, write code with higher confidence, and add more tests without worrying about how this will impact the CI time.
On another note, this can save the business money shortening CI time and having a compounded effect the bigger the team (and more CI triggers).
6. The importance of code coverage.
100% code coverage, although great, is not enough or the ultimate goal. Having full code coverage doesn’t mean that all the test cases have been covered. It’s important to test happy paths, non-happy paths, edge cases, and failure cases. This way, the result will be a 360° view of how the SUT should behave.
Plus, there are a few things in each application that are difficult to cover in testing without a big amount of effort, and they may not even provide more confidence in the end.
Acceptance and end-to-end tests should help with covering certain code, but again, that should not be a developer’s main objective.
7. Writing tests before or after the implementation is done.
More important than when the tests are written is their existence and quality. Both approaches are valid and beneficial to a project. In the beginning, it might feel easier to write tests at the end, when everything is ready, but later, the advantages of following TDD could change the developers’ approach.
I have an article on Why TDD is amazing which goes into more detail on this topic.