Home
Writing testable code
Writing iOS code at large corporations made me realize how important writing testable code is. I think this way of thinking is one the most important indicators of a better software engineer.
The "What"
To keep our definition simple, we'll define testability as
the practice of writing software that can be tested easily.
To go a bit deeper, that definition brings these following properties underneath.
⭐️ Draw clear boundaries between different entities of your code
Proactively think about where to place new code, and creatE new entities before one entity gets too big doing many things.
Think about how the public interface for the entity will look like first. This is a good practice to think about when adding new methods to your entity, "should X be really doing Y, it's public interface is about doing Z and Z & Y seems a bit irrelevant".
protocol FileManaging {
func fileExists(named: String) -> Bool
// 🚩 Ooh, "file" in the name might be tempting, but this seems more like
// an authentication thing than managing files!
func canAccessSecretFiles() -> Bool
}
⭐️ Understand dependencies of each entity
Whenever an entity has non-hermetic behavior, actually wait, let's define what "hermetic" means first.
Hermetic: insensitive to the libraries, environment, runtime and other software installed on a device. (more here)
Going back, whenever an entity is doing something non-hermetic, it is a big red flag 🚩 because we're losing control of what our own code does. Some usual suspects here are networking, file system, or 3rd party libraries.
Whenever we detect such a case, it's important to realize that as a dependency.
It's like saying
"for X to do something, it needs a Y (where Y is some behavior that breaks X's hermeticity)".
// Note that it's clear what dependency is needed to get a user profile.
async func getUserProfile(using: UserProfileManaging) -> UserProfile?
When we mark such a dependency, we can have control over it. And yeah, it's the famous dependency injection I'm talking about here. (I bet you haven't seen such definition for it before, well let's say I tried something.)
The "Why"
Maybe first question comes to mind is,
Why would I optimize writing code for testability? tests aren't something shipped to users nor have any effect on the user experience.
While technically you'd be correct, turns out writing "testable" code has some benefits that extends to the user experience.
⭐️ Make safer changes to code
When the boundaries and public API's are clear, changing X won't break Y, so we can be more confident in changing X's implementation without any side effects.
⭐️ Have granular control of the behavior
It becomes easy to isolate a piece of code since all it's dependencies are known, and test only the intended parts of the code.
func uploadFile(named fileName: String) {
// We need some non hermetic behavior to get a file manager to find the file,
// and perhaps another mechanism to upload it.
}
func uploadFile(named fileName: String,
fileManager: FileManaging,
fileUploader: FileUploading) {
// We know what exactly will be injected as `fileManager` and `fileUploader`
// and their specific behaviors.
}
The "How"
Well if I still have you sticking around, let's talk about "how" does one go about writing testable code.
⭐️ Break down your code into logically grouped Services
Think of the entities in your code as services with a clear Public API and dependencies. Non hermeticity is a great signal for creating a service for an entity, but not the only one.
The "service" nomenclature comes from my days at Google and their internal iOS dependency injection framework called Service Registry (SRL). You obviously don't need to call them "services".
Another very strong signal is the ability to provide various implementations for specific functionality. For example, let's think of a service for visualizing a car's dashboard.
protocol DashboardVisualizing {
func dashboardController() -> UIViewController
}
When we isolate drawing the UI for a car, we can conform to DashboardVisualizing
in many ways,
LowBatteryDashboardVisualizer
to show minimal UI when battery is low,RentalVehicleDashboardVisualizer
to show rental related information when the vehicle is used by a renter,TechnicianDashboardVisualizer
to show more detailed internal information about the vehicle that only technician's are allowed to see, and etc.
and inject the one we need based on our needs.
class Dashboard {
init (visualizer: DashboardVisualizing) {
// ...
}
}
// We can inject any implementation here as long as it conforms to `DashboardVisualizing`.
let dashboard = Dasboard(visualizer: technicianDashboardVisualizer)
⭐️ Swap to fake implementations in tests
Last but not least, here comes the part we actually talk about testing.
Since we now provide our dependencies to each service, we can provide "fake" dependencies that that are optimized for writing tests and verifying results and don't deal with any hermetic behavior at all.
Staying away from hermetic behavior also helps with more reliable tests since we isolate external factors of where the test is run and reduce complexity.
For example, we can use a fake file manager that doesn't actually get a file from the actual file system, but always returns a small file to be used in the upload operation in order to isolate the logic for uploading the file.
protocol FileManaging {
func file(named fileName: String) -> File?
}
class FileManagerFake: FileManaging {
private var readFileNames: Set<String> = Set()
func file(named fileName: String) -> File? {
readFileNames.insert(fileName)
return File(named: fileName)
}
func verifyReadFile(named fileName: String) -> Bool {
readFileNames.contains(fileName)
}
}
class FileUploadTests {
func testUploadFile_readsCorrectFile() {
let fileName = "test file"
let fileManager = FileManagerFake()
let fileUploader = FileUploaderFake()
uploadFile(named:"test file", fileManager, fileUploader)
XCTAssertTrue(fileManager.verifyReadFile(named: fileName), "uploadFile method did not read the correct file.")
}
}
In this particular example, we're not necessarily focused on whether the actual file from the disk was read correctly, but rather if the uploadFile
method correctly reads the file with the right name in its implementation. By using protocols in the public API of a service, we are able to inject fake implementations in tests to isolate our testing environment to uploadFile
s behavior, and easily verify that it does what's intended.
The Conclusion
I hope this post was helpful and makes you think differently next time you write code. Some examples here might not be the best in class, but I've tried my best to demonstrate. Take care ✌️.