I’m coming clean. I’m embarrassed to admit it, but I’ve been a professional software developer for twelve years and have hardly written any tests. I’ve embraced the unit testing cult a few times, but every time I did, I spent more time hacking my code to make it testable than writing new features. My velocity went into the toilet, my once beautiful code became riddled with concessions to make it testable, tests would break after small changes, and worst of all, they never seemed to surface any genuine defects. I would eventually give up with the excuse that “iOS code isn’t testable” or “unit tests are for people who write bad code”. Such hubris!
The cycle would repeat itself several times over the course of my career. Eventually the proponents of unit testing got louder and louder, even smart people in the iOS community. Clearly I was missing something. By this point I had been consulting for a few years, and had worked with several companies that enforced test coverage. They all suffered from the same problems I did. Everyone was recommending tests but few were doing it effectively.
At some point I stumbled upon the Boundaries and Functional Core, Imperative Shell talks by Gary Bernhardt. Finally, I found someone else who understood my pain and, more importantly, had a way forward. I consumed all material I could find relating to the subject and found more people espousing the same ideas. The venerable Uncle Bob discusses similar concepts in his Clean Architecture talks and book.
At a high level, these speakers introduced several ideas that were new to me: Mocks should be avoided, logic and I/O should be separate, source code dependencies (#imports) should point from implementation details like the network or database toward the core business and application logic, and that the core should be more functional than OOP. Many of these ideas seemed counter to common practice.
For the last decade my architecture hadn’t fundamentally change. Classes perform some logic and then write data to a database or network. They were difficult to test because data in the DB would have to be verified or network connections mocked in order to verify what was posted to the server. These tests involved a lot of arrangement code before the actual test, it wasn’t obvious what they were doing and they would often break after small changes.
The problem was that I was testing too many layers of my app. I hadn’t separated my logic from my controllers, network/database or UI. To verify the logic I’d have to fake interactions from the user and verify requests made to the backend. I would need mock users, mock authentications and mock network requests. I was trying to test my code as one big lump. The unit tests had become quasi integration tests.
If you fundamentally separate the code into two different categories these problems disappear. One side is made of structs and other value types. It contains all the business and application logic. It does no I/O, and has little or no external or volatile dependencies; which is to say it makes no contact with the outside world. It is functional in nature and doesn’t make any asynchronous calls. This half of the source code represent the app’s ‘state’; the part that changes over the lifetime of the app. It is what you write unit tests against, nothing else.
The other side is comprised of controller, coordinator and manager classes. It handles all of the network, database and interface I/O (yes, the screen is an I/O device). The dependency arrow points from these classes to the logic code above. The goal is to have no logic here whatsoever. It’s purpose is to marshal data and events back and forth to the logic level. Ideally it has a cyclomatic complexity of 1 (meaning it contains no conditional statements) and no state at all. These classes are not unit tested. They are simple to the point that they can be verified by looking at them and do not change often.
The logic layer can be tested at whatever granularity you wish. It is comprised of simple value types so there is no need to mock anything. No unnecessary protocols or interfaces are needed to facilitate testing. Values are much easier to verify than database writes or JSON blobs. These tests run faster since they aren’t writing to a database and don’t depend on any app scaffolding. They can be run without a simulator, meaning they complete in milliseconds instead of seconds.
The implication is that nothing under test will have dependencies on UIKit (or any other *Kit). There wont be any tests against views or view controllers, but that’s ok, the action isn’t located there anyway. As an example, instead of instantiating a view controller, passing mock data to it and verifying a label’s text property, tests would be written against a view state struct that would have a text property on it. Tests would verify this property instead. It is true that it’s impossible to achieve 100% test coverage with this approach but there are diminishing returns in trying to cover every single line.
Mock objects are a useful tool but must be used conservatively and with care because they have some considerable drawbacks. Mocks make tests brittle since they must be updated whenever the interface they are mocking changes. They are complicated to arrange before each test and many times they require their dependencies to be mocked as well. They also trigger more false positives since they don’t always behave the way the real object might. Another drawback mocks have in strongly typed languages like Swift is that they require a protocol where you wouldn’t otherwise need one. Unnecessary abstractions make code harder to reason about and is an example of test induced design damage.
By keeping the dependency arrows pointing from the controllers toward the business logic, we ensure that our logic is testable and dependency free. This is achieved through dependency injection; the controller implements a protocol defined by the logic layer. To test the system at a macro level, simply mock and inject the I/O controller. This is one place where mocks are useful.
That’s enough for today. Thanks for taking the time to listen to my thoughts. The take away is to separate the logic from other aspects of the source code, if you find yourself writing too many nested mocks you may have taken a wrong turn and make sure the controller/manager/coordinator classes are dependent on the logic objects and not the other way around.