TDD is not an easy concept to begin with. Even though writing a unit-test is a rather obvious task, many developers restrain themselves from trying to build a new feature in a test-first approach.
Speaking with newcomers to TDD world, the biggest obstacle with a test-first approach is envisioning how the test should look like. This stems from the fact that most of the testing tutorials are based upon solving mathematical and therefore functional problems. Imagine finding prime factors of the given integer:
func primeFactors(of integer: Int) -> [Int] {
// actual implementation...
}
It's pretty clear how to proceed with testing:
- The above shape of the function is easy to visualise. Inputs and outputs are clearly defined.
- The problem at hand is known upfront. We have complete knowledge of the expectations from the "unit" we are building.
- It is easy to predict that the implementation will not fit into 2 trivial lines of code, so it might be worth a shot to check whether it is correct.
Building on those premises, we can easily come up with infinite scenarios for our testing to begin:
class PrimeFactorsSpec: QuickSpec {
override func spec() {
describe("prime factors") {
it("returns empty list for 1") {
expect(primeFactors(of: 1)).to(beEmpty())
}
it("returns list of 2 for 2") {
expect(primeFactors(of: 2)) == [2]
}
it("returns list of 3 for 3") {
expect(primeFactors(of: 3)) == [3]
}
it("returns list of 2, 2 for 4") {
expect(primeFactors(of: 4)) == [2, 2]
}
it("returns list of 2, 2, 2 for 8") {
expect(primeFactors(of: 8)) == [2, 2, 2]
}
it("returns list of 3, 3 for 9") {
expect(primeFactors(of: 9)) == [3, 3]
}
it("returns list of divisors for the large number") {
expect(primeFactors(of: 2 * 2 * 7 * 7 * 11 * 17 * 17))
== [2, 2, 7, 7, 11, 17, 17]
}
}
}
}
And finally, build the implementation solving the problem:
func primeFactors(of integer: Int) -> [Int] {
var factors = [Int]()
var reminder = integer
var divisor = 2
while reminder > 1 {
while reminder % divisor == 0 {
factors.append(divisor)
reminder /= divisor
}
divisor += 1
}
return factors
}
Real-life testing
The primeFactors
example is a pretty cool illustration of what TDD is. But how does it relate to real-life engineering? Well, the truth is that it does not, not even a tiny bit:
- It rarely happens that we can envision the final solution before actually implementing it.
- It is seldom possible to anticipate the final complexity of the feature. The requirements change often, unexpected relationships between functionalities happen and the dependencies we use behave in a different way than anticipated.
- The problem at hand is rarely closed. Tiny units of the code eventually become bloated class hierarchies due to endless stream of new requirements.
- We rarely deal with mathematical problems. We need to build functional abstractions ourselves.
Acknowledging how the software development process works, we can now try to build our understanding of the Test Driven Development:
- It is not required to predict any part of the final solution before writing the first test. In fact, thinking upfront about production code is damaging to the TDD workflow and should be avoided at all costs.
- Before writing any test code one needs to forget about the general problem to solve. The strategy is to focus on the smallest possible part of the task and try to build it in a test-first approach. Every time begin with asking yourself a question: What is the next smallest thing I will need to achieve my goal?
- Do not worry about completing the actual task. You will get there anyway. After building each part of the feature ask yourself: Am I already finished? Is there any more work to do?
- Tests should support deleting and modifying the code. Do not feel attached to any part of the existing code. Alter and delete it with a courage. Try to treat both the production and test code as a temporary scratchpad to improve later.
- Build tests for the smallest and the dumbest of all the things. You cannot predict which parts of the code will grow in future, so it is important to treat all parts of the system equally.
- Take things slowly. Very slowly. Turtle-like slowly.
The rules above might come to you as a shock if you are not a testing veteran:
- It is impossible to build a feature without thinking about it as a whole, right?
- It is not viable to write a test not knowing the shape of the function, correct?
- I am paid to deliver code as quickly as possible, I cannot afford to go slowly!
Before jumping to any conclusion, please scrutinise the "regular" development workflow:
- Copying & pasting a bunch of code from Stack Overflow.
- Understanding what is happening: adding multiple
print
statements and fiddling with the debugger analysing stack traces left and right. - Re-running the code hundreds of times with a plethora of changes between the executions (maybe this will do the trick?).
- It worked, finally! Now let's go through every edge case again in case anything has broken.
See? Software development is nothing like taking the shortest path from the problem to the solution. It takes a lot of wandering, going back and forth and experimenting to craft a final piece of code.
Test Driven Development is a structured software research and development process with a side-effect of automated tests that enable refactoring later in the process.
Building the 1st feature in TDD
Imagine you are building the application for conducting the surveys:
- Each respondents' details are stored locally on the device in the form of a
.yaml
file. - Your task is to implement a synchronise feature which will send new respondents' details to the backend service upon tapping a button.
Can we TDD the whole feature at once? Of course not! Even though it is described as a single sentence, there is a lot to be implemented as a part of that feature:
- Reading file contents.
- Parsing YAML input.
- Converting parsed YAML input into JSON body suitable for our backend service.
- Invoking a call to the backend service.
- Adding the synchronisation button to the UI of the application.
- Wiring the service call to the
UIButton
. - Handling service calls exceptions (they are not even specified yet).
- Handling file reading and parsing exceptions (we have not discovered them yet).
As you can see, that is plenty of work to do. It is impossible to know the interfaces, method signatures and class hierarchy upfront. How can we even start with the TDD process then? It turns out that, as previously mentioned:
The key to starting building the feature in TDD is to pick the smallest possible subset of work and experiment with building it.
At this point, the top bullet point from our feature description (reading file contents) seems like a reasonable starting point.
Before we proceed, let's ask ourselves a couple of questions:
- Is our file reading code is supposed to be a global method?
- Should our file reader be a class with dependencies injected?
- Are there any dependencies that we need for file reading?
The interesting point is that we cannot answer any of those questions because we quite frankly do not know yet. Does it stop us from building the feature in a test-driven fashion? No! It is perfectly desirable not to have any assumptions about the implementation before starting it.
Building a dummy implementation
From now on we will be building a file reading feature in a TDD fashion. Since there are a lot of micro-steps to do so, I have created a sample repository with commits history presenting the whole process for easier understanding. The history is available here. I strongly advise you to follow it along with the post.
Since we intend to build a feature in TDD, we need to start with a failing test. Let's create an empty Swift file in the test target and call it FileReaderSpec.swift
:
func fileReader(path: String) -> String? {
return nil
}
class FileReaderSpec: QuickSpec {
override func spec() {
describe("fileReader") {
var fileContents: String?
beforeEach {
fileContents = fileReader(
path: "a-021-293-121-test.txt")
}
afterEach {
fileContents = nil
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
}
}
}
}
We can anticipate that our file reading method will take a path
input (we need to tell the system which file we want to read), but we cannot yet tell whether the result should be an optional or maybe the function should throw an exception. We will figure that out later.
As a rule of thumb, it is good to start each test by prototyping some kind of a global method with optional output and doing so in the same file as the test code itself.
At this point, you might ask: how on Earth is that a good idea? Usage of global methods and having the test code mixed with production code deliberately violates "clean code" common sense. Yes, that is right. However, the rule is only to make sure that heavy refactoring is required later in the process. Counter-intuitively, it makes you way less scared of deleting, moving and refactoring the code.
Having our function prototyped, the next step is to make the tests pass. Since we have no idea how the code should look like, let's throw a dummy implementation in there:
func fileReader(path: String) -> String? {
return ""
}
The code does nothing yet, but at least we have some working test to build upon. However, the non-nil matcher is not exhaustive proof that our file reading code does its job. We likely want to assert for a specific content to be read from the file:
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
Once again, we have the failing test and should proceed towards making it pass. We still have no idea what the actual implementation is, so let's replace the method with a dummy again:
func fileReader(path: String) -> String? {
return "FILE CONTENTS"
}
The tests should pass again, even though we are not doing any file reading. It is a great starter.
Getting to know the API
Since we want to implement the real file reading, let's first consult Stack Overflow on how to read and write a String
from/to a file in Swift:
let file = "file.txt" //this is the file. we will write to and read from it
let text = "some text" //just a text
if let dir = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask).last {
let fileURL = dir.appendingPathComponent(file)
//writing
do {
try text.write(to: fileURL, atomically: false,
encoding: .utf8)
}
catch {/* error handling here */}
//reading
do {
let text2 = try String(contentsOf: fileURL,
encoding: .utf8)
}
catch {/* error handling here */}
}
It seems that file reading requires us to:
- obtain the URL to the documents directory,
- append a file name to the documents URL,
- use
String(contentsOf:)
initialiser.
Unfortunately, the documents directory URL is optional. The API does not guarantee the value will be present. Sadly, we cannot proceed with file reading code without a handle to the documents directory. The logical step is to make sure we have any. Let's add a failing test for that:
func fileReader(path: String) -> String? {
return "FILE CONTENTS"
}
class FileReaderSpec: QuickSpec {
override func spec() {
describe("fileReader") {
var documentsURL: URL?
var fileContents: String?
beforeEach {
fileContents = fileReader(
path: "a-021-293-121-test.txt")
}
afterEach {
fileContents = nil
}
// added failing test
it("should return documents URL") {
expect(documentsURL).toNot(beNil())
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
}
}
}
And try to make it pass with the code from Stack Overflow:
beforeEach {
documentsURL = FileManager.default.urls(
for: .documentDirectory, in: .userDomainMask).last
fileContents = fileReader(path: "a-021-293-121-test.txt")
}
The test passes! As you can see, it is not very specific but it still does its job. It proves that we have the documentsURL
to write.
Since the documentsURL
is computed on FileManager
it does not fit into our FileReaderSpec
. Let's perform a refactoring to move the FileManager
related code to a separate test case. We create FileManagerSpec.swift
file with the following contents:
import Nimble
import Quick
class FileManagerSpec: QuickSpec {
override func spec() {
describe("FileManager") {
var sut: FileManager!
beforeEach {
sut = .default
}
afterEach {
sut = nil
}
describe("documents URL") {
var documentsURL: URL?
beforeEach {
documentsURL = sut.urls(for: .documentDirectory,
in: .userDomainMask).last
}
it("should return documents URL") {
expect(documentsURL).toNot(beNil())
}
}
}
}
}
And remove the documentsURL
related test code from FileReaderSpec.swift
. Since we are in the refactoring phase, the tests should pass. If not, we have made a mistake that needs to be fixed.
At this point, you might raise a very important question: Why are we testing the framework code? Indeed, FileManager.default.urls(for:in:)
is not our piece of code so we should not test it, right?
The answer to that question is all about confidence. As stated previously, having the documentsURL
to read from is a hard requirement for our code to work, so it is worth examining that our presence assumption is correct. Notice that we are not interested in whether the URL is built correctly. We assume it is. Knowing that the URL is there is enough for us to be confident to build code on top of it.
Before we continue with our file reading test, let's do a little more refactoring. The documents URL extraction takes in 2 non-obvious arguments and we do not want to bother every time we need it. Instead, let's move the resolving code into FileManager
extension:
import Nimble
import Quick
extension FileManager {
var documentsURL: URL? {
return urls(for: .documentDirectory,
in: .userDomainMask).last
}
}
class FileManagerSpec: QuickSpec {
override func spec() {
describe("FileManager") {
var sut: FileManager!
beforeEach {
sut = .default
}
afterEach {
sut = nil
}
describe("documents URL") {
var documentsURL: URL?
beforeEach {
documentsURL = sut.documentsURL
}
it("should return documents URL") {
expect(documentsURL).toNot(beNil())
}
}
}
}
}
The test passes and it seems the extension is ready to move to the production target. We create FileManager+DocumentsURL.swift
file in a production target with the following contents:
extension FileManager {
var documentsURL: URL? {
return urls(for: .documentDirectory,
in: .userDomainMask).last
}
}
And then we modify the FileManagerSpec.swift
:
import Nimble
import Quick
@testable import FileReaderTDD
class FileManagerSpec: QuickSpec {
override func spec() {
describe("FileManager") {
var sut: FileManager!
beforeEach {
sut = .default
}
afterEach {
sut = nil
}
describe("documents URL") {
var documentsURL: URL?
beforeEach {
documentsURL = sut.documentsURL
}
it("should return documents URL") {
expect(documentsURL).toNot(beNil())
}
}
}
}
}
The tests should pass. Now, let's go back to our actual file reading code. To make sure we can read from the file properly, we need to create a file with some content first. Let's alter the FileReaderSpec.swift
file:
func fileReader(path: String) -> String? {
return "FILE CONTENTS"
}
class FileReaderSpec: QuickSpec {
override func spec() {
describe("fileReader") {
var fileURL: URL!
var fileContents: String?
beforeEach {
fileURL = FileManager.default.documentsURL?
.appendingPathComponent("a-021-293-121-test.txt")
try! "FILE CONTENTS".write(to: fileURL,
atomically: true, encoding: .utf8)
fileContents = fileReader(
path: "a-021-293-121-test.txt")
}
afterEach {
try! FileManager.default.removeItem(at: fileURL)
fileURL = nil
fileContents = nil
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
}
}
}
The tests still pass. At first glance, it seems that we are not testing anything new. However, notice that the .removeItem(at:)
on a FileManager
is a throwing function. In case the item is not present it throws the exception. Therefore, running the afterEach
block proves we were at least able to create a blank file and our setup code is invoked. Good! Let's try to change the fileReader
implementation to read from this file:
func fileReader(path: String) throws -> String {
guard let documentsURL = FileManager.default.documentsURL?
.appendingPathComponent(path) else {
fatalError()
}
return try String(contentsOf: documentsURL)
}
documentsURL
is optional, but for our test, we do not care what happens on nil
. For the time being, fatalError()
is good enough.
Notice that String(contentsOf:)
method returns a non-optional value but can throw an error. This makes us update the fileReader(path:)
signature accordingly. Also, the call site in the test has to change slightly:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("fileReader") {
var fileURL: URL!
var fileContents: String?
beforeEach {
fileURL = FileManager.default.documentsURL?
.appendingPathComponent("a-021-293-121-test.txt")
try! "FILE CONTENTS".write(to: fileURL,
atomically: true, encoding: .utf8)
// the function is throwing, minor change necessary
fileContents = try? fileReader(
path: "a-021-293-121-test.txt")
}
afterEach {
try! FileManager.default.removeItem(at: fileURL)
fileURL = nil
fileContents = nil
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
}
}
}
The test passes. We have done it! We can read from the file with high confidence in our implementation.
Stubbing the file system in production code
The work is not finished yet. Right now our code relies on the real FileManager
and String(contentsOf:)
implementations, but we have no absolute control over the two:
- We cannot be certain whether the write-and-read operation will always succeed. We do not know the internal implementation details of
FileManager
. What if the storage is low? - We cannot test the case where we cannot resolve the
documentsURL
. - Our test modifies the file system state. Our tests might override files created during a normal application run.
Let's start with the observation that our fileReader
method relies on FileManager.default
singleton that is not replaceable in testing. It is the hidden dependency of the method. We can make it a real dependency:
func fileReader(path: String,
fileManager: FileManager = .default) throws -> String {
guard let documentsURL = fileManager.documentsURL?
.appendingPathComponent(path) else {
fatalError()
}
return try String(contentsOf: documentsURL)
}
The test code has not changed. Running the tests will reassure us that the code still works after the refactoring.
Next, we want to get rid of concrete FileManager
class instance and replace it with a protocol for better testability. We are only relying on a single method from FileManager
class:
open func urls(for directory: FileManager.SearchPathDirectory,
in domainMask: FileManager.SearchPathDomainMask) -> [URL]
Let's refactor and create a protocol in a production file named FileManaging.swift
:
protocol FileManaging {
func urls(for directory: FileManager.SearchPathDirectory,
in domainMask: FileManager.SearchPathDomainMask) -> [URL]
}
We aim to rely on this protocol in our file reading method. However, we cannot perform that refactoring straight away:
FileManager
does not yet conform toFileManaging
protocol.documentsURL
is defined as theFileManager
class extension, so we cannot use it on our protocol.
Let's fix the issues above. The first one is as easy as creating a single line extension FileManager+Protocol.swift
in a production target:
extension FileManager: FileManaging {}
Next, we can move our documentsURL
definition to extend a protocol, not a concrete class:
extension FileManaging {
var documentsURL: URL? {
return urls(for: .documentDirectory,
in: .userDomainMask).last
}
}
We can run the tests to verify our refactoring. They will all pass giving us the confidence boost regarding the changes.
The next step is to also update the FileManagerSpec.swift
to reflect moving the documentsURL
property to a protocol:
import Nimble
import Quick
@testable import FileReaderTDD
class FileManagingSpec: QuickSpec {
override func spec() {
describe("FileManaging") {
var sut: FileManaging!
afterEach {
sut = nil
}
context("with real file manager instance") {
beforeEach {
sut = FileManager.default
}
describe("documents URL") {
var documentsURL: URL?
beforeEach {
documentsURL = sut.documentsURL
}
afterEach {
documentsURL = nil
}
it("should return documents URL") {
expect(documentsURL).toNot(beNil())
}
}
}
}
}
}
The unexpected consequence of our refactoring is that we can make sure that we invoke FileManaging.urls(for:in:)
method passing correct arguments. To do so, we need a stubbed implementation of the FileManaging
protocol. Let's put it inside FileManagerStub.swift
file in our test target:
import Foundation
@testable import FileReaderTDD
class FileManagerStub: FileManaging {
var stubbedDocumentsURLs: [URL] = [
URL(string: "/var/data/derived")!,
URL(string: "/var/data/documents")!,
]
func urls(for directory: FileManager.SearchPathDirectory,
in domainMask: FileManager.SearchPathDomainMask) -> [URL] {
guard directory == .documentDirectory
&& domainMask == .userDomainMask else {
return []
}
return stubbedDocumentsURLs
}
}
Our stubbed implementation returns the URLs only when asked for .documentDirectory
in .userDomainMask
specifically. We can now expand the test suite in FileManagingSpec
with the following:
import Nimble
import Quick
@testable import FileReaderTDD
class FileManagingSpec: QuickSpec {
override func spec() {
describe("FileManaging") {
var sut: FileManaging!
afterEach {
sut = nil
}
context("with real file manager instance") {
beforeEach {
sut = FileManager.default
}
describe("documents URL") {
var documentsURL: URL?
beforeEach {
documentsURL = sut.documentsURL
}
afterEach {
documentsURL = nil
}
it("should return documents URL") {
expect(documentsURL).toNot(beNil())
}
}
}
// added test context
context("with stubbed file manager instance") {
beforeEach {
sut = FileManagerStub()
}
describe("documents URL") {
var documentsURL: URL?
beforeEach {
documentsURL = sut.documentsURL
}
afterEach {
documentsURL = nil
}
it("should return the correct documents URL") {
expect(documentsURL).toNot(beNil())
expect(documentsURL?.absoluteString)
== "/var/data/documents"
}
}
}
}
}
}
Again, the tests pass. The freshly added test case improves our faith in the documentsURL
implementation.
Finally, we are ready to replace FileManager
class with FileManaging
protocol. Let's update the fileReader
method to accept FileManaging
instance as a dependency:
func fileReader(path: String, fileManager: FileManaging
= FileManager.default) throws -> String {
guard let documentsURL = fileManager.documentsURL?
.appendingPathComponent(path) else {
fatalError()
}
return try String(contentsOf: documentsURL)
}
The tests pass and we are halfway through isolating ourselves from the real file system. The other half is to allow the different implementation of String(contentsOf:)
for testing. It turns out the task is quite simple:
func fileReader(path: String, fileManager: FileManaging
= FileManager.default, urlReader: (URL) throws -> String
= String.init(contentsOf:)) throws -> String {
guard let documentsURL = fileManager.documentsURL?
.appendingPathComponent(path) else {
fatalError()
}
return try urlReader(documentsURL)
}
We expose the function with (URL) throws -> String
signature as a parameter to our original method. It allows us to use a different instance for testing.
At this point, we should, of course, run the tests and verify whether our refactoring was correct. All tests passing makes us sure we did a good job.
Getting rid of the global method
Before we proceed to remove the real file system code from our tests, we might notice that it is high time to convert a fileReader()
method to a class on its own:
- It seems to have own dependencies that would be great to inject using Dependency Injection pattern.
- We are rarely using global methods in the codebase anyway.
Let's restructure FileReaderSpec.swift
to the following:
class FileReader {
func read(path: String, fileManager: FileManaging
= FileManager.default, urlReader: (URL) throws -> String
= String.init(contentsOf:)) throws -> String {
guard let documentsURL = fileManager.documentsURL?
.appendingPathComponent(path) else {
fatalError()
}
return try urlReader(documentsURL)
}
}
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var sut: FileReader!
beforeEach {
sut = FileReader()
}
afterEach {
sut = nil
}
describe("read") {
var fileURL: URL!
var fileContents: String?
beforeEach {
fileURL = FileManager.default.documentsURL?
.appendingPathComponent("a-021-293-121-test.txt")
try! "FILE CONTENTS".write(to: fileURL,
atomically: true, encoding: .utf8)
fileContents = try? sut.read(
path: "a-021-293-121-test.txt")
}
afterEach {
try! FileManager.default.removeItem(at: fileURL)
fileURL = nil
fileContents = nil
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
}
}
}
}
The refactoring was done in two pretty simple steps:
- renaming
fileReader
method toread
, - wrapping
read
method withFileReader
class.
Each of the steps was reassured by running the tests.
So far, we have only wrapped our method in a class, but have not yet converted the fileManager
and urlReader
to be the dependencies of the class instance. Let's do so by moving fileManager
to the initialiser:
class FileReader {
init(fileManager: FileManaging = FileManager.default) {
self.fileManager = fileManager
}
func read(path: String, urlReader: (URL) throws -> String
= String.init(contentsOf:)) throws -> String {
guard let documentsURL = fileManager.documentsURL?
.appendingPathComponent(path) else {
fatalError()
}
return try urlReader(documentsURL)
}
private let fileManager: FileManaging
}
And finish the refactoring by moving urlReader
to the initialiser:
class FileReader {
init(fileManager: FileManaging = FileManager.default,
urlReader: @escaping (URL) throws -> String
= String.init(contentsOf:)) {
self.fileManager = fileManager
self.urlReader = urlReader
}
func read(path: String) throws -> String {
guard let documentsURL = fileManager.documentsURL?
.appendingPathComponent(path) else {
fatalError()
}
return try urlReader(documentsURL)
}
private let fileManager: FileManaging
private let urlReader: (URL) throws -> String
}
Each step is reassured by the green tests. We can also get rid of default values for the initialiser fields by modifying the test code first:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var sut: FileReader!
beforeEach {
sut = FileReader(fileManager: FileManager.default,
urlReader: String.init(contentsOf:))
}
// the remaining test code is unchanged
}
}
}
And deleting the default assignments later. We have successfully converted our FileReader
to be a fully-fledged class!
Stubbing the file system in tests
Since our FileReader
class is dependant on FileManaging
protocol, we can easily replace the test code to be dependant on a FileManagerStub
instead of the real instance:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var fileManager: FileManagerStub!
var sut: FileReader!
beforeEach {
fileManager = FileManagerStub()
fileManager.stubbedDocumentsURLs = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)
sut = FileReader(fileManager: fileManager,
urlReader: String.init(contentsOf:))
}
afterEach {
sut = nil
fileManager = nil
}
// the remaining test code is unchanged
}
}
}
Now, we can do the first real isolation from the file system. Let's try to rebuild URL reader dependency so that it returns a hardcoded value instead of reading the file:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var fileManager: FileManagerStub!
var sut: FileReader!
beforeEach {
fileManager = FileManagerStub()
fileManager.stubbedDocumentsURLs = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)
sut = FileReader(fileManager: fileManager) { url in
let expectedURL = fileManager.documentsURL?
.appendingPathComponent("a-021-293-121-test.txt")
guard expectedURL! == url else { fatalError() }
return "FILE CONTENTS"
}
}
afterEach {
sut = nil
fileManager = nil
}
// the remaining test code is unchanged
}
}
}
The tests still pass, that is great! Although we are not reading from the file system anymore, we are still fairly confident that our implementation does what it is supposed to.
Let's isolate ourselves from real .documentsURL
implementation, too:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var fileManager: FileManagerStub!
var sut: FileReader!
beforeEach {
fileManager = FileManagerStub()
sut = FileReader(fileManager: fileManager) { url in
let expectedURL =
"/var/data/documents/a-021-293-121-test.txt"
guard URL(string: expectedURL)! == url else {
fatalError()
}
return "FILE CONTENTS"
}
}
afterEach {
sut = nil
fileManager = nil
}
// the remaining test code is unchanged
}
}
}
The tests pass as expected, but we are not using any file system dependencies anymore. We can clear the rest of the file system manipulation code:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var fileManager: FileManagerStub!
var sut: FileReader!
beforeEach {
fileManager = FileManagerStub()
sut = FileReader(fileManager: fileManager) { url in
let expectedURL =
"/var/data/documents/a-021-293-121-test.txt"
guard URL(string: expectedURL)! == url else {
fatalError()
}
return "FILE CONTENTS"
}
}
afterEach {
sut = nil
fileManager = nil
}
describe("read") {
var fileContents: String?
beforeEach {
fileContents = try? sut.read(
path: "a-021-293-121-test.txt")
}
afterEach {
fileContents = nil
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
}
}
}
}
Handling exceptions
The final step of building our FileReader
is to handle the exceptions that, as we now know, can happen during the reading:
- missing documents directory URL,
- missing file in the file system.
Let's prepare a ground for building a missing file test case scenario by slightly changing the structure of existing test code. We will add a context
describing that our current test is a successful scenario:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var fileManager: FileManagerStub!
var sut: FileReader!
beforeEach {
fileManager = FileManagerStub()
sut = FileReader(fileManager: fileManager) { url in
let expectedURL =
"/var/data/documents/a-021-293-121-test.txt"
guard URL(string: expectedURL)! == url else {
fatalError()
}
return "FILE CONTENTS"
}
}
afterEach {
sut = nil
fileManager = nil
}
describe("read") {
var fileContents: String?
afterEach {
fileContents = nil
}
// added context
context("with existing file") {
beforeEach {
fileContents = try? sut.read(
path: "a-021-293-121-test.txt")
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
}
}
}
}
}
Next, we can add a failing test for throwing an error on a missing file:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var fileManager: FileManagerStub!
var sut: FileReader!
beforeEach {
fileManager = FileManagerStub()
sut = FileReader(fileManager: fileManager) { url in
let expectedURL =
"/var/data/documents/a-021-293-121-test.txt"
guard URL(string: expectedURL)! == url else {
fatalError()
}
return "FILE CONTENTS"
}
}
afterEach {
sut = nil
fileManager = nil
}
describe("read") {
var fileContents: String?
afterEach {
fileContents = nil
}
context("with existing file") {
beforeEach {
fileContents = try? sut.read(
path: "a-021-293-121-test.txt")
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
}
// added test case
context("with non-existing file") {
it("should throw FileReaderError.missingFile error") {
expect { _ = try sut.read(
path: "non-existing.txt") }
.to(throwError(FileReaderError.missingFile(
path: "/var/data/documents/non-existing.txt")))
}
}
}
}
}
}
enum FileReaderError: Equatable, Error {
case missingFile(path: String)
}
The code will now crash due to the implementation of the urlReader
block. Let's update it to throw an exception on unexpected URL:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var fileManager: FileManagerStub!
var sut: FileReader!
beforeEach {
fileManager = FileManagerStub()
sut = FileReader(fileManager: fileManager) { url in
let expectedURL =
"/var/data/documents/a-021-293-121-test.txt"
guard URL(string: expectedURL)! == url else {
// added exception throwing
throw NSError(domain: "URLReaderError", code: 404,
userInfo: nil)
}
return "FILE CONTENTS"
}
}
afterEach {
sut = nil
fileManager = nil
}
// the remaining test code is unchanged
}
}
}
Now, let's make the test pass by handling a file reading exception:
class FileReader {
init(fileManager: FileManaging = FileManager.default,
urlReader: @escaping (URL) throws -> String
= String.init(contentsOf:)) {
self.fileManager = fileManager
self.urlReader = urlReader
}
func read(path: String) throws -> String {
guard let documentsURL = fileManager.documentsURL?
.appendingPathComponent(path) else {
fatalError()
}
// added exception handling
do {
return try urlReader(documentsURL)
} catch {
throw FileReaderError.missingFile(
path: documentsURL.absoluteString)
}
}
private let fileManager: FileManaging
private let urlReader: (URL) throws -> String
}
The tests should now pass and we got ourselves a nice error on reading a non-existing file! However, there is still a bare fatalError()
call in our FileReader
implementation that is worrying. We need to take care of it, too.
How should we handle that case? It turned out that reading from a file with non-existing URL throws an error. Let's make the error handling consistent by adding a new type of the error to throw when documents URL is missing.
As usual, we start by building the test scenario for the error condition:
class FileReaderSpec: QuickSpec {
override func spec() {
describe("FileReader") {
var fileManager: FileManagerStub!
var sut: FileReader!
beforeEach {
fileManager = FileManagerStub()
sut = FileReader(fileManager: fileManager) { url in
let expectedURL =
"/var/data/documents/a-021-293-121-test.txt"
guard URL(string: expectedURL)! == url else {
throw NSError(domain: "URLReaderError", code: 404,
userInfo: nil)
}
return "FILE CONTENTS"
}
}
afterEach {
sut = nil
fileManager = nil
}
describe("read") {
var fileContents: String?
afterEach {
fileContents = nil
}
context("with existing file") {
beforeEach {
fileContents = try? sut.read(
path: "a-021-293-121-test.txt")
}
it("should return content read from a file") {
expect(fileContents).toNot(beNil())
expect(fileContents) == "FILE CONTENTS"
}
}
context("with non-existing file") {
it("should throw FileReaderError.missingFile error") {
expect { _ = try sut.read(
path: "non-existing.txt") }
.to(throwError(FileReaderError.missingFile(
path: "/var/data/documents/non-existing.txt")))
}
}
// added test case
context("with no documents URL") {
beforeEach {
fileManager.stubbedDocumentsURLs = []
}
it("should throw FileReaderError.missingDocumentsURL") {
expect { _ = try sut.read(path: "file.txt") }
.to(throwError(
FileReaderError.missingDocumentsURL))
}
}
}
}
}
}
enum FileReaderError: Equatable, Error {
case missingFile(path: String)
case missingDocumentsURL
}
To make the Xcode all green and happy about our tests, we add missing implementation part:
class FileReader {
init(fileManager: FileManaging = FileManager.default,
urlReader: @escaping (URL) throws -> String
= String.init(contentsOf:)) {
self.fileManager = fileManager
self.urlReader = urlReader
}
func read(path: String) throws -> String {
guard let documentsURL = fileManager.documentsURL?
.appendingPathComponent(path) else {
// added exception handling
throw FileReaderError.missingDocumentsURL
}
do {
return try urlReader(documentsURL)
} catch {
throw FileReaderError.missingFile(
path: documentsURL.absoluteString)
}
}
private let fileManager: FileManaging
private let urlReader: (URL) throws -> String
}
The test pass. Are we finished yet?
It turns out we are! Our FileReader
has all cases covered and is ready to be moved to a production target of our application.
TDD works not only for maths :)
We have managed to prove that TDD can be easily applied to the real-world application scenarios. By doing small enough steps, TDD allowed us to experiment with the APIs without prior knowledge of them. It also helped us choose a suitable error-handling pattern. Moreover,
By using the test-first approach we were able to simulate scenarios that are not possible to easily simulate using production code dependencies.
The scenarios include:
- Missing documents directory URL.
- Missing file on the drive. Without altering the path in the production code, testing the scenario would require manually deleting the file from the simulator file system.
This article is a part of 101 iOS unit testing series. Other articles in the series are listed here.