Tests are often a big discussion in the software development community. We hear a lot of people saying “if you don’t write tests, you’re a bad developer” or “if you don’t know how to write tests, you’re doing everything wrong” or “tests are good but they are time-consuming”. However, I prefer saying
“If you don’t write tests, that’s OK. Just try writing them, you’ll end up understanding the benefits and you’ll learn and think more about your architecture decisions which you made before coding.”
I talked with a lot of people, watched a lot of talks in meet-ups and conferences about all kind of tests. After a while, I realized to believe that the common thought “In iOS, you don’t need to write tests if you have a QA person.”. This still kicks me in the back from time to time. But whether we think the same or we like writing tests or not, we should acknowledge the benefits. Believe me, when I saw the benefits in time I changed my mind and started writing automated tests even though we have QA people.
OK. But still, why should we write tests?
First, we should consider the workflow without tests and identify the (future) problems. Let’s think the scenario where we have only one QA person and no automated tests. We need to consider every case before and during the coding. Just because of human nature, we tend to make mistakes. That’s why manual testing is buggy. There will be unwanted behavior. I hear you, you have a great and hard-working QA people who test the builds. But small bugs tend to be missed and they can be easily identifiable by automated tests.
On the other hand, when we have automated tests, we tend to have fewer bugs. We create the test cases and fix the code to make the tests pass. Only thinking and writing these cases brings more benefits. They become a documentation of intent. It provides enough information to the new developers in the project. Therefore, during the onboarding process, tests flattens the learning curve.
Writing less code is one of the things that most of the developers want. This is generally a strong argument against tests. But without tests, we end up spending more time to find bugs and fix them. There isn’t an easy way to measure how much does it effect. But the test-written-projects tend to be more stable and solid. Even while adding the new feature, we can be comfortable. Because if our changes affect the other parts of the code, we will be notified by broken tests.
Architectural decisions are made better with tests. We’ll talk about the architecture in later posts, but I just want to say that while writing tests, we end up thinking our architectural approaches a lot. We start considering single responsibility and dependency inversion principles to set up better architectures. Sometimes we need to mock or stub services (and we generally do this by using
protocols in Swift) and this helps to understand the Protocol Oriented Programming. So, unconsciously we start researching and learning more architectural approaches and design patterns.
Lastly, tests make code reviews easy. If we already have a lot of tests, we are more confident that the change doesn’t affect the other parts of the code. And if we’re not really familiar with the content of thepull (or merge) request, thanks to the tests we understand that content with ease. I like the quote which Apple engineers say in one WWDC17 session, “Code reviews for test code, not code reviews with test code.”
Ok, let’s summarize until here;
- Manual tests are buggy and they may cause unwanted behavior
- Automated tests are robust and they provide self-documentation to the code
- Automated tests seem time-consuming first, but they save more time in the long run
- Automated tests help us understanding architectural approaches, teach a lot of things even without noticing.
- Automated tests make code reviews easier.
Now we’re kind of convinced. So, what do we need to know?
There are tons of great online tutorials about writing tests for iOS apps (links are below). Here, we will focus on important points and some testing tips.
“Design your code for testability” – John Sundell
As John says, we should ask ourselves one question: “What makes code easy to test?”. When we ask this question, there are two things which come to mind:
- We shouldn’t overuse singletons.
Singletons are great and Apple also uses them in important places like
UIApplication. Since we have only one screen and only one application in run-time, this makes sense. But making an object global is not always necessary. We should keep the state of the object local instead and don’t let everyone to change the state. So, we should think twice when we create a singleton.
- We should use protocols and parameterization.
Instead of subclassing for mocking purposes, we should prefer protocols (composition over inheritance). Protocols provide a more robust solution. When we subclass to create a mock for tests, Xcode doesn’t give any warning if we forget to
override a function. It is risky and also some classes cannot be subclassed (like
UIApplication). If we use protocols, we abstract the implementation and we create a proper mock. Also, Xcode shows an error when we forget to implement one function while conforming to a protocol. Let’s take a look at an example.
Let’s say we want to create a
FileOpener and its only purpose is opening files if the URL is correct. We’ll use
UIApplication.canOpenURL(url:) method. And we’ll write tests for this.
This should work in the app. But in UITests, this will open another app and our tests will be blocked. When we start thinking John’s question (“What makes code easy to test?”), we realize that we should parameterize the function first.
UIApplication is a dependency in class. So, it’s better to inject it.
As we see in the initializer, we used
UIApplication.shared in default parameter. This makes initializing the
DocumentOpener so much easier. But we still have a problem. We cannot mock
UIApplication because it’s a singleton. Now, we can get the power of protocols in Swift. Let’s implement a new protocol called
URLOpening and make
UIApplication to conform it.
Now, let’s adjust our
FileOpener and use the protocol as a parameter in the initializer.
Thanks to Swift extensions we don’t need to implement
URLOpening protocol in the
UIApplication extension as we see above. Because we’re following the same method signature which
UIApplication already has. Now, we abstracted the implementation, we can create a new mock class and just conform to
URLOpening protocol. Thus, we’ll be able to use mocking while testing
Separate logic and effects and create clear boundaries for APIs
We should create frameworks and libraries to separate the logic. Using Separation of Concerns, we may extract business logic and algorithms. They can have their own tests. And whenever we need to change something, we will know our changes won’t affect the business logic if we designed the API boundaries well. Swift has a powerful access control. We shouldn’t expose more information than needed to the outside of those frameworks.
Use pure functions
We should get leverage of functional style and reduce the effects of our functions. One function should always return the same output when given the same input. Also, it shouldn’t have any side effects. (in functional programming, these functions are called ‘Pure function’). Pure functions are predictable and they are easily testable.
Optimize App Launch for Testing
While running the tests, we see the simulator but wait for a couple of seconds. This is because the app is loading. Tests won’t start before
application:didFinishLaunchingWithOptions: method in AppDelegate returns. We generally do a lot of setup inside this method like analytics and crash reporting setup. But we generally don’t need them during tests. So, we should avoid the unnecessary work when the app is launched for tests. We can set a custom scheme environment variable and use it in
Avoid too much mocking
Mocking is good while testing. But it might result in a lot of implementation details. Mocks provide predictability. Whenever we need really good predictability, we can define them. But even in this case, they should be as simple as possible and they should be inline, not globally defined.
Use correct expectations in tests and avoid ambiguous tests
We should use faster, callback-based expectations in unit tests:
If you have a lot of tests, make them run in parallel. Xcode runs them with a good optimization to reduce the test running time. Try to watch your test classes’ execution times. If one test class takes huge time while the others are not, try splitting the class into several classes or find the reason why it takes so much time. This will accelerate running the tests
Covering the app code with tests is a neat way to be sure the app works properly. Not only we should set goals for test coverage, but we also should treat the tests with the same amount of care as our app. The quality of the test code is also really important to consider even though the code is not shipping. Coding principles in the app code should also apply to the test code. And lastly, test code should support the evolution of our app and they should grow together. Therefore, we should keep an eye on their growth with code coverage.
What do you think about the tips? Do you have some tips about tests? Which strategies do you follow to write or improve the tests? Let me know your thoughts, comments or feedback on Twitter @candosten or comments below.
All posts of the NS for iOS Devs Series:
Engineering for Testability – WWDC17
Writing Swift code with great testability – John Sundell
Testing Tips & Tricks – WWDC18
Mocking in Swift – Swift by Sundell
Protocol Oriented Programming – WWDC15
iOS Unit Testing and UI Testing Tutorial – Ray Wenderlich
Test Driven Development Tutorial for iOS – Ray Wenderlich
Xcode’s Secret Performance Tests – Indie Stack