Approaching Unit Testing in iOS Correctly
Why does my application
get so buggy after each release?
Why does my QA team frequently report repetitive issues and crashes?
The best solution
to curb problems like these is UNIT TESTING.
In this blog post, we will see how we can incorporate unit testing effectively into our codebases reducing repetitive test efforts and bug frequencies.
There are 3 types of developer level testing:
- Unit testing (we will be focusing on this today)
- Integration testing
- UI testing
Unit testing is basically breaking down bigger complex logic into smaller testable logic.
Writing unit tests for an existing codebase could be a huge challenge. That’s the reason why we should keep developing unit test cases along with the code. So this is how an ideal development workflow should look like:
- Make the BRD and backend workflows(your UML).
- Make the Design document.
- Write test cases based on the Business logic for each feature/module.
- Start writing unit test cases for each feature by breaking down the bigger logics into smaller pieces.
- Start building and improvising the logic basis your unit test cases.
Since we don’t live in an ideal world, we will have to write test cases for an existing codebase. Unit testing can be easily approached when starting for a new project using techniques like Test Driven Development, Protocol Oriented Programming and Dependency Injection whereas while approaching with unit testing for an existing codebase/project, we will have to incorporate these techniques followed by a lot of code refactor(and by a lot, I mean A LOT of code refactor).
The XCTest library
XCTest library provides a common framework for writing unit tests in Swift for Swift packages and applications. We can either include unit test cases when creating a new project or add a unit test target later into our project. Mention your testable target(the app to be tested) in your test class to access its classes. A unit test case lifecycle has 2 main methods
These methods get called automatically before and after each test case execution respectively. We can perform any resource allocation-deallocation in these methods.
Note: Test cases should be completely independent of one another because they execute asynchronously.
The What? Why? How? of a unit test case
What qualifies as a valid test case? One that can pass and fail both. Most of the times, we test the core business logic of the application which is built of the following pieces:
- Data parsing and data manipulation: data from APIs, files, cache, DB, local storage, user defaults, shared objects, globals, constants, and so on.
- Utils/support classes: logic like date formatter, string manipulation logic, data validation checks, nil checks and so on.
- Logic in classes like extensions, data managers, data models, view models(specific to a particular module).
- Methods containing the if-else logic, switch, map-reduce-filter logic and so on.
We need to identify and isolate each testable piece making it independent of other pieces of code. For example, one method should only do one single task like checking for valid numbers in an entered text.
How? Now that we know what pieces have to be tested, let’s have a look at how we can test them.
1. Mocking is testing your code logic using dummy data or mocked data. Writing unit test cases with mocked data gives a near to real simulation of testing making it an ideal way to unit test. Listing down pieces of code that need to be mocked:
- Shared instances, local and global variables, data models
- Files and other local resources
- Core Data and other databases like SQLite
- Apple APIs like CNContact, keychain store instance, location and so on
- Network calls
- Cached data
Mocking the data for unit testing can sometimes be a real pain especially network calls. We might have to put more coding efforts in mocking the data than the actual development of the code itself. Sometimes developers have to mock an entire class. Listing down 2 approaches to curb the mocking problem:
- Dependency injection: It helps in initializing a class or object distinctively so that it can easily be mocked. For example, initializing a controller class with the data source as a compulsory parameter or initializing a network call with a shared session passed as a parameter making the mocking easier.
- POP (Protocol Oriented Programming): It helps in abstracting the code of a class in the form of loosely coupled extensions and protocols. Extension methods cannot be directly tested in our XCTest class, we need to expose extension-specific logic via extensions protocols only.An example of partial mocking CNContact for testing ContactsManager class
Some cases can be tested by partial mocking whereas some cases need complete mocking of the objects being tested. This approach needs a lot of code refactor if being applied to an existing codebase.
is using a custom framework/library to stub network responses without actually making any network call to the server. Here, we have complete control over what responses we want to test without any internet or server dependencies. Stubbing needs a lot less coding efforts when it comes to writing the unit test cases. Some most popular libraries for stubbing are OHHTTPStubs
for XCTest(unit) testing.
We can say we will be able to simulate most of the happy testing scenarios with stubbing. We will have to update our stubs if there are any additions or changes to the responses we receive from the actual server.
3. TDD (Test Driven Development) will significantly reduce mocking and stubbing efforts for a developer or rather make mocking easier so that we don’t need to stub the data at all for unit testing. Test-driven development as the name suggests influences the development approach in a way that the resulting code is more distinct, independent and testable.
The developer starts writing unit test cases for each feature by breaking down the bigger logics into smaller pieces and then continues developing and improvising the logic basis the unit test cases identified in the process. TDD focuses on the crux of the logical piece being tested hence isolating individual testable pieces of the code. I will try to cover TDD with an example in another article.
NOTE: We have to check for the “Why” when we want to unit test a piece of code. Ask yourself “should this piece of code be tested under unit testing” or are there other testing measures that can better automate the testing in this case. We cannot test a method that only returns a particular string like font, color, a key or a constant.
Additionally, we should make sure we do not add too much code to a class to make it testable and then write unit test cases to test the extra code added to test the original class. We don’t wanna fall into that spiral loop.
Always, find a way to write your unit test case with as minimum mocking and data manipulation as possibe. Focus on the crux of the logic.
- Writing test cases for existing legacy code VS new code.
- Making the code testable followed by code refactor(a lot of code refactor).
- Cannot access private variables for mocking directly — use getter setter methods.
- It cannot test private methods directly.
- Acts as code documentation. We will have most of our business logic test case wise separated nicely in our unit test cases.
- Significantly reduces bug frequency. Yes, it does, speaking from personal experience.
- It makes us write better code.
- Useful when refactoring existing code. When we refactor or make a code upgrade, chances of breaking it are high. Test cases will immediately point us to the direction of the fault and we can fix it before the bug goes to QA.
- App behavior can also be tested as well (to an extent).
- Cyclomatic complexity is reduced making the code simpler and more robust. Ideal cyclomatic complexity should be between 4–7(good to medium range).
If you want to learn more about cyclomatic complexity, watch this
. It nicely explains this tongue-twisting terminology.
- The code has to be re-written and refactored a lot to make it testable.
The ideal code coverage should be 80% — 100%. Xcode has a provision check class wise code coverage for unit testing.
Below is a picture depicting how a typical pipeline would look like when we integrate unit testing into our codebase. We can either run the unit test cases manually before making a code commit or automate with a script for running the unit cases in our CICD pipeline or do both(recommended).
This was more of an informative article about unit testing. The next article would be more coding-oriented covering dependency injection, stubbing and POP.
Feel free to comment if you have any feedback or wish to add-on something that you feel is useful.
I hope you enjoyed reading this article. Thank you!
Subscribe to get your daily round-up of top tech stories!