Practical unit testing for iOS applications
Unit tests are the de facto standard for establishing a consistent product and an efficient continuous delivery process. They force you to write well-structured code that is split into modules with clear interfaces between them. Unit testing is necessary to safely refactor your code. In this post, we will discuss the process we used during a customer project that can be used to make even the most complex codebase unit testable.
Understanding unit testing
Before we dive in let's reivew the basics of unit testing. According to Martin Fowler, one of the best specialists in refactoring, unit tests are:
- low-level, focusing on a small part of the system (one unit)
- usually written by the developers themselves
- faster than other types of tests — and therefore should be run more often
A customer’s testing problem — and its solution
Our customer, which we’re calling “X” here, is an e-commerce organization that asked us to help with a mobile project which had no unit test coverage. They were in the process of moving to a new version of their authorization API, but without unit tests it was quite hard to achieve because changes were causing bugs in other components. The few tests they had weren’t proper unit tests, since they depended on networking logic and external services. It was also hard to test some of the functionality because their code hadn’t been divided into small, isolated, robust parts. Plus, the project contained some reactive code in the service layer.
This is a common problem for all customers like X — and this is why it hurts
This is a classic issue for large enterprise e-commerce projects. It’s really hard to fight against complexity in huge code bases. Usually, e-commerce mobile projects require sophisticated deep linking, analytics, and a huge networking layer with an API originally prepared for non-mobile projects. The number of features to be implemented is extremely high for every iteration, so technical “debt” grows and the app’s stability keeps getting worse over time. But there are ways to reduce complexity and make your project unit-testable. Here are some best practices to follow:
- Isolated code
- Properly-configured build file and project file
- Small tested units
- Meaningful assertions
- HTTP request stubs
- Self sufficiency
- Test all clauses and test cases
This is how we solved X’s problem
At first we prepared a plan to refactor the project and cover it with unit tests. Since the team that worked on the project before us was not very experienced in unit testing, we prepared tutorials and presentations to show them the best unit testing practices. Then we started refactoring the code, step by step, with small iterative changes that didn’t materially affect the development process.
Stubbed network requests and API calls
We advised them to stub all network requests for unit tests, which we did using OCMock and OHHTTPStubs. For unit testing, using real data like IDs, passwords, user data, and so on is not a reliable approach; once the data is changed and becomes outdated, your tests fail even though your logic is still correct. Unit tests are supposed to test only internal app logic independent of other services, backend and real data; for these purposes you can use Integration Tests or Functional Testing, also called E2E integration testing.
Isolated token validation logic
We figured out that the main problem occurred in the token validation logic, which contained many lines of static methods in the single class and was coupled with lots of services. So we isolated that logic and covered it with unit tests to be sure it didn’t have problems in the future.
Also, token expiration problems were being caused by incorrect storing of the token. We extracted the storage logic into a new module to isolate it and make it testable.
Injections make your code more testable because they allow you to test only that part of the code you need to test. For example, you can inject networking services in services which use them. Now you can pass a fake networking service with stubbed network requests to the services you would like to test, i.e. some parsing logic or request processing. This becomes even more useful for languages like Swift which don’t have many runtime hooks for mocking the objects.
Writing highly maintainable unit tests
A unit test should cover behavior of one unit of code, and its name should be meaningful enough that it helps find what is broken.
Test all clauses and edge cases
Ideally, you need to be sure that all clauses (if, else, else if, switch) of your logic are being executed during the test run. If you assume that your parameters have some edges, i.e. mix and max values, you need to pass them as well.
All practices for writing good, maintainable code are applicable for unit tests as well. Think DRY, SOLID and so on. No magic numbers, no copy/paste. You will need to maintain these tests and write new ones as needed.
- Architecting iOS apps with VIPER
- A testable architecture
- iOS architecture patterns
- The book of VIPER
- “ VIPER is an application of Clean Architecture to iOS apps. The word VIPER is a backronym for View, Interactor, Presenter, Entity, and Routing. “ - quote
Of course, to make unit tests useful you need to run them, and you need to run them on every code change attempt. That is where Continuous Integration helps. We established a CI process which runs unit tests on presubmit. That means the change list which hasn’t passed its unit tests cannot be pushed into the repository. We also made it mandatory to run unit tests on every part of the service and models code in order to pass a code review. For this step it would be cool to have a tool for static analysis and collecting the metrics of your code, perhaps something like Sonar, which is not available as part of the iOS CI infrastructure as of this writing.
Here’s the business benefit our client got from solving the problem mentioned above
After refactoring and running unit tests, the app became much more stable. Indeed, we were able to refactor two key business features in the app without any major defects, and this significantly increased user involvement. We have made it possible to cover the project with tests and run CI/CD both quickly and easily so we can always be sure the app is stable and that we’ll see red flags if unit tests are not passed. This new architecture means we can ship new features faster — and without risk of adding new bugs despite our increase in development speed.
We’ll start with “classic” texts. You’re probably already familiar with them:
1. Test Driven Development: By Example: Kent Beck - https://www.amazon.com/Test-Driven-Development-Kent-Beck/dp/0321146530
2. Refactoring: Martin Fowler - http://www.martinfowler.com/books/refactoring.html
3. Code complete: Mcconnell (it’s about code in general) - https://www.amazon.com/Code-Complete-Practical-Handbook-Construction/dp/0735619670
Some links dedicated to iOS unit testing:
1. Quality Coding
2. objc - issue 15 - testing
3. Test Driven Development
4. Unit Testing, by Matt Thompson
5. iOS Unit Testing
iOS Unit testing books:
1. Test-Driven iOS Development: Graham Lee
2. xUnit Test Patterns: Refactoring Test Code: Gerard Meszaros