I can’t test that! (Part I)

Daniel Sedam
3 min readOct 28, 2020

Tips and Tricks for writing better, more testable swift code.

The following article discusses one of my favorite techniques for writing testable code, specifically making the 3rd party frameworks and libraries you use abstract so that they can be mocked later for testing!

One problem we run into is testing a piece of code that uses something that we didnt write such as a library or framework. We don’t want to test the functionality of that code but we need to mock the output from functions within those frameworks and/or make sure they were called.

Let’s start off with a simple class that represents a Unix Shell. We have one function implemented, ls (list directory contents).

class Terminal {  func ls(path: String, args: [String]) {
let fileManager = FileManager()
do {
let contents = try fileManager.contentsOfDirectory(atPath: path)
print(contents)
} catch {
print(error)
}
}
...
}

When we go to test this function we run into a few problems. How do check what the fileManager returned? We can’t! The fileManager was declared inside the function so we have no way of accessing it.

If you haven’t guessed the answer yet (at least the first half of the answer), it’s Dependency Injection. We could pass in the fileManager of type FileManager to the init of this class. But then that forces our code to use only FileManager or a subclass of FileManager. What if we want to swap out of fileManager at a later time and use something else? Also we will be forced to use a partial mock (by extending FileManager and overriding contentsOfDirectory).

This works, but we can do better … The second half of the answer is:

Protocols to the rescue!

// 1.
protocol
FileManagerDefinition: class {
func contentsOfDirectory(atPath path: String) throws -> [String]
}
// 2.
extension
FileManager: FileManagerDefinition { }
class Terminal { private var fileManager: FileManagerDefinition // 3.
init(fileManager: FileManagerDefinition = FileManager()) {
self.fileManager = fileManager
}
func ls(path: String, args: [String]) {
do {
let contents = try fileManager.contentsOfDirectory(atPath: path)
print(contents)
} catch {
print(“\(error)”)
}
}
}
  1. First we create a protocol that contains the same function signature as the function we are using from FileManager. We only expose the functions that we need!
  2. Next we extend FileManager with our new protocol. Since FileManager already contains contentsOfDirectory, the contract is fulfilled and we don’t have to do anything else.
  3. Finally we use dependency Injection by passing in a FileManagerDefinition. We have a default value of FileManager if one isn’t supplied. When we test this later we can pass in a mock fileManager.

(Depending on the class we are trying to mock we may not be able to override functions! So the above approach is not only a better option, it may be your only option.)

Let’s write some unit tests 😄

class TerminalTests: XCTestCase {func testls() {
// Given
let mockFileManager = MockFileManager()
let terminal = Terminal(fileManager: mockFileManager)
let mockContents = [“/src/tests/TerminalTests.swift”]
mockFileManager.mockContents = mockContents
XCTAssertEqual(mockFileManager.returnedContents.count, 0)
// When
terminal.ls(path: "/somePath", args: [])
// Then
XCTAssertEqual([“/src/tests/TerminalTests.swift”],
mockFileManager.returnedContents)
}
class MockFileManager: FileManagerDefinition { var mockContents: [String] = []
var returnedContents: [String] = []
func contentsOfDirectory(atPath path: String) throws -> [String]
{
self.returnedContents = self.mockContents
return mockContents
}
}
}

The above test more or less makes sure that ‘contentsOfDirectory’ was called. A more useful example for this technique would be injecting mock data that other parts of your code consumes from these dependencies. I bet you are already thinking of the perfect place to try this in your code!

This works with Objective-C too.

@protocol FileManagerDefinition
- (NSArray<NSString *> *)contentsOfDirectoryAtPath:(NSString *)path
error:(NSError * _Nullable *)error;
@end@interface NSFileManager<FileManagerDefinition>
@end
...id<FileManagerDefinition> fileManager = [NSFileManager shared];

And that's it! This simple, yet powerful technique can help make your code more flexible and testable! Watch your test coverage go up now that you can get to those hard to reach places 🎊.

Share this with your coworkers and have fun with this technique for writing more testable code! If you liked this article please give it a clap and stay tuned for more tips and tricks on writing better, more testable swift code!

Check out more tips and tricks for writing tests!

--

--