iOS Test Driven Development by Tutorials v2.0.0
iOS Test Driven Development by Tutorials v2.0.0
Notice of Rights
All rights reserved. No part of this book or corresponding materials (such as text,
images, or source code) may be reproduced or distributed by any means without
prior written permission of the copyright owner.
Notice of Liability
This book and all corresponding materials (such as source code) are provided on an
“as is” basis, without warranty of any kind, express of implied, including but not
limited to the warranties of merchantability, fitness for a particular purpose, and
noninfringement. In no event shall the authors or copyright holders be liable for any
claim, damages or other liability, whether in action of contract, tort or otherwise,
arising from, out of or in connection with the software or the use of other dealing in
the software.
Trademarks
All trademarks and registered trademarks appearing in this book are the property of
their own respective owners.
raywenderlich.com 2
iOS Test-Driven Development by Tutorials
raywenderlich.com 3
iOS Test-Driven Development by Tutorials
raywenderlich.com 4
iOS Test-Driven Development by Tutorials
raywenderlich.com 5
iOS Test-Driven Development by Tutorials
Challenge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
Key points . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
raywenderlich.com 6
iOS Test-Driven Development by Tutorials
raywenderlich.com 7
iOS Test-Driven Development by Tutorials
raywenderlich.com 8
iOS Test-Driven Development by Tutorials
raywenderlich.com 9
iOS Test-Driven Development by Tutorials
raywenderlich.com 10
L Book License
• You are allowed to use and/or modify the source code in iOS Test-Driven
Development by Tutorials in as many apps as you want, with no attribution
required.
• You are allowed to use and/or modify all art, images and designs that are included
in this book in as many apps as you want, but must include this attribution line
somewhere inside your app: “Artwork/images/designs: from iOS Test-Driven
Development by Tutorials, available at www.raywenderlich.com”.
• The source code included in iOS Test-Driven Development by Tutorials is for your
personal use only. You are NOT allowed to distribute or sell the source code in iOS
Test-Driven Development by Tutorials without prior authorization.
• This book is for your personal use only. You are NOT allowed to sell this book
without prior authorization, or distribute it to friends, coworkers or students; they
would need to purchase their own copies.
All materials provided with this book are provided on an “as is” basis, without
warranty of any kind, express or implied, including but not limited to the warranties
of merchantability, fitness for a particular purpose and noninfringement. In no event
shall the authors or copyright holders be liable for any claim, damages or other
liability, whether in an action or contract, tort or otherwise, arising from, out of or in
connection with the software or the use or other dealings in the software.
All trademarks and registered trademarks appearing in this guide are the properties
of their respective owners.
raywenderlich.com 11
Before You Begin
This section tells you a few things you need to know before you get started, such as
what you’ll need for hardware and software, where to find the project files for this
book, and more.
raywenderlich.com 12
i What You Need
• Xcode 13 or later. Xcode is the main development tool for writing code in Swift.
You need Xcode 13 at a minimum, since that version includes Swift 5.5. You can
download the latest version of Xcode for free from the Mac App Store, here:
apple.co/1FLn51R.
If you haven’t installed the latest version of Xcode, be sure to do that before
continuing with the book. The code covered in this book depends on Swift 5.5 and
Xcode 13 — the code may not compile if you try to work with an older version.
raywenderlich.com 13
ii Book Source Code &
Forums
• https://github.com/raywenderlich/itdd-materials/tree/editions/2.0
Forums
We’ve also set up an official forum for the book at https://
forums.raywenderlich.com/c/books/ios-test-driven-development-by-tutorials. This
is a great place to ask questions about the book or to submit any errors you may find.
raywenderlich.com 14
Dedications
“For my girls. I love you very much.”
— Joshua Greene
— Michael Katz
raywenderlich.com 15
iOS Test-Driven Development by Tutorials About the Team
raywenderlich.com 16
v Introduction
Welcome to iOS Test-Driven Development by Tutorials! This book will teach you all
about test-driven development (TDD) — the art of turning requirements into tests
and tests into production code.
You’ll get hands-on TDD experience by creating three real-world apps in this book:
By the end of this book, you’ll have a strong understanding of TDD and be able to
apply this knowledge to your own apps.
raywenderlich.com 17
iOS Test-Driven Development by Tutorials Introduction
If you’ve worked through our classic beginner books — the Swift Apprentice https://
www.raywenderlich.com/books/swift-apprentice and the UIKit Apprentice https://
www.raywenderlich.com/books/uikit-apprentice — or have similar development
experience, you’re ready to read this book. You’ll also benefit from a working
knowledge of design patterns — such as working through Design Patterns by Tutorials
https://www.raywenderlich.com/books/design-patterns-by-tutorials — but this isn’t
strictly required.
As you work through this book, you’ll progress from beginner topics to more
advanced concepts.
Section introductions
I. Introduction
This is a high-level introduction to TDD, explaining why it’s important and how it
will help you.
You’ll also be introduced to the TDD Cycle in this section. This is the foundation for
how TDD works and guiding principles on the best way to apply it.
The chapters in this section build an example app called Fitness. This is the premier
fitness-coaching app based on the “Loch Ness” workout: You’ll have to outrun,
outswim and outclimb Nessie (or get eaten)!
raywenderlich.com 18
iOS Test-Driven Development by Tutorials Introduction
You’ll create an app called Dog Patch throughout this section. Dog Patch lets dog
lovers everywhere connect with kind breeders to help get the dog of their dreams.
You’ll update an app called MyBiz throughout this section. MyBiz is an enterprise
resource planning (ERP) app for running a business, including employee
management and scheduling, time tracking, payroll and inventory management.
If you already have some experience with TDD, you can skip from chapter to chapter
or use this book as a reference. You’ll always be provided with a starter project in
each chapter to get up and running quickly.
What’s the absolute best way to read this book? Just start reading wherever makes
sense to you!
raywenderlich.com 19
Section I: Hello, TDD!
raywenderlich.com 20
1 Chapter 1: What Is TDD?
By Joshua Greene
3. Refactor
4. Repeat
This is called the TDD Cycle. It ensures you thoroughly and accurately test your
code because your development is… driven by testing!
By writing a test followed by the production code to make it pass, you ensure your
production code is testable and that it meets all requirements during development.
As an added bonus, your tests act as documentation for your production code,
describing how it works.
raywenderlich.com 21
iOS Test-Driven Development by Tutorials Chapter 1: What Is TDD?
On the surface, the TDD process seems pretty simple. Well, I’m sorry to tell you
that… wait, it actually is really simple!
Sure, there are special circumstances for how to implement this cycle at times, but
that’s where this book comes in! Once you get the hang of this process, it will
become second nature. You’ll learn a lot more about this in the next chapter.
It’s hard to argue against testing your code, but you don’t have to follow TDD to do
this. For example, you could write all of your production code and then write all of
your tests. Alternatively, you could skip writing tests altogether and, instead,
manually test your code. Why is TDD better than these options?
Good tests ensure your app works as expected. However, not all tests are “good.”
Writing tests for the sake of having tests isn’t a worthwhile exercise. Rather, good
tests are failable, repeatable, quick to run and maintainable.
• The first step is to write a failing test. By definition, this proves the test can fail.
Tests that can’t fail aren’t useful. Rather, they waste valuable CPU time.
• Before you’re allowed to write a new test, all previous tests must pass. This
ensures that your tests are repeatable: You don’t just run the single test you’re
writing, but rather, you constantly run all of the tests.
• By frequently running every test, you’re incentivized to make sure tests are quick
to run. All of your tests should take seconds to run — preferably, one second or
less.
raywenderlich.com 22
iOS Test-Driven Development by Tutorials Chapter 1: What Is TDD?
A single test that takes a hundred milliseconds is too slow: After only ten tests, your
entire test suite will take one second to run. After fifty tests, it takes five seconds.
After several seconds, no one runs all of the tests because it takes too long.
• When you refactor, you update both your production and test code. This ensures
your tests are maintained: You’re constantly keeping them up-to-date.
• By iteratively writing production code and tests in parallel, you ensure your code is
testable. If you were to write tests after completing the code, it’s likely the
production code would require many changes to fully unit test.
Nonetheless, the devil’s advocate in you may say, “But you could write good tests
without following TDD.” You definitely could, but you may struggle to succeed. You
can definitely do it in the short term, but it’s much more difficult in the long term.
You’d need to be disciplined about writing good tests. Before long, you’d likely create
some sort of system to ensure that you’re writing good tests… you’d likely find
yourself doing a variant of TDD!
• Do write tests for code that can’t be caught in an automated fashion otherwise.
This includes code in your classes’ methods, custom getters and setters and mostly
anything else you write yourself.
• Don’t write tests for generated code. For example, it’s not worthwhile to write
tests for generated getters and setters. Swift does this very well, and you can trust
it works.
• Don’t write tests for issues that can be caught by the compiler. If the tested issue
would generate an error or warning, Xcode will catch it for you.
• Don’t write tests for dependency code, such as first- or third-party frameworks
your app uses. The framework authors are responsible for writing those tests. For
example, you shouldn’t write tests for core Foundation classes because Apple’s
developers are responsible for writing those. However, you should write tests for
your custom subclasses thereof: This is your custom code, so you’re responsible
for writing the tests.
raywenderlich.com 23
iOS Test-Driven Development by Tutorials Chapter 1: What Is TDD?
Another exception is “sanity tests” that prove third-party code works as you expect.
These sort of tests are useful if the library isn’t fully stable, or you don’t trust it
entirely. In either case, you should really scrutinize whether or not you want to use
the library at all — is there a better option that’s more trustworthy?
Fortunately, TDD gets faster once you get used to doing it. However, the truth is that
compared to not writing any tests at all, you’re writing more code ultimately. It likely
will take a little more time to develop initially.
That said, there’s a really big hole in this argument: The real time cost of
development isn’t just writing the initial, first-version production code. It also
includes adding new features over time, modifying existing code, fixing bugs and
more. In the long run, following TDD takes much less time than not following it
because it yields more maintainable code with fewer bugs.
There’s also another cost to consider: customer impact of bugs in production. The
longer an issue goes undiscovered, the more expensive it is. It can result in negative
reviews, lost trust and lost revenue.
If an issue is caught during development, it’s easier to debug and quicker to fix. If
you discovered it weeks later, you’d spend substantially more time getting up to
speed on the code and tracking down the root cause. By following TDD, your tests
ultimately help safeguard and protect your app against bugs.
raywenderlich.com 24
iOS Test-Driven Development by Tutorials Chapter 1: What Is TDD?
However, an important question to ask: Should your project use TDD at all?
As a general rule of thumb, if your app is going to last more than a few months, will
have multiple releases and/or require complex logic, you’re likely better off using
TDD than not.
If you’re creating an app for a hackathon, test project or something else that’s meant
to be temporary, you should evaluate whether TDD makes sense. If there’s really only
going to be one version of the app, you might not follow TDD or might only do TDD
for critical or difficult parts.
Ultimately, TDD is a tool, and it’s up to you to decide when it’s best to use it!
Key points
In this chapter, you learned what TDD is, why you should use it, what to test and
when to use it. Here are the key points to remember:
• Write tests for code that you’re responsible for maintaining. Don’t test code that’s
automatically generated or code within dependencies.
• The real cost of development includes initial coding time, adding new features
over time, modifying existing code, fixing bugs and more. TDD reduces
maintenance costs and quantity of bugs, often making it the most cost effective
approach.
• TDD is most useful for long-term projects lasting more than a few months or
having multiple releases.
raywenderlich.com 25
2 Chapter 2: The TDD Cycle
By Joshua Greene
In the previous chapter, you learned that test-driven development boils down to a
simple process called the TDD Cycle. It has four steps that are often “color coded” as
follows:
2. Green: Write the bare minimum code to make the test pass.
raywenderlich.com 26
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
Why is it color coded? This corresponds to the colors shown in most code editors,
including Xcode:
This chapter provides an introduction to the TDD Cycle, which you’ll use throughout
the rest of this book. However, it doesn’t go into detail about test expressions
(XCTAssert, et al.) or how to set up a test target. Rather, these topics are covered in
later chapters. For now, focus on learning the TDD Cycle, and you’ll learn the rest as
you go along.
Getting started
In this chapter, you’ll create a simple version of a cash register to learn the TDD
Cycle. To keep the focus on TDD instead of Xcode setup, you’ll use a playground.
Open CashRegister.playground in the starter directory, then open the
CashRegister page. You’ll see this page two imports, but otherwise it’s empty.
Naturally, you’ll begin with the first step in the TDD Cycle: red.
raywenderlich.com 27
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
CashRegisterTests.defaultTestSuite.run()
This tells the playground to run the test methods defined within
CashRegisterTests. However, you haven’t actually written any tests yet. Add the
following within CashRegisterTests, which should cause a compiler error:
// 1
func testInit_createsCashRegister() {
// 2
XCTAssertNotNil(CashRegister())
}
• XCTest requires all test methods begin with the keyword test to be run.
• Next, describe what’s being tested. Here, this is init. There’s then an underscore
to seprate it from the next part.
• If special setup is required, it’s written next. For example, you might describe what
setup conditions are necessary for the test. This is optional, however, and this test
doesn’t actually include this part. If it were included, you’d likewise suffix it with
an underscore to separate it from the last part.
raywenderlich.com 28
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
This convention results in test names that are easy to read and provide meaningful
context. If a test ever fails, Xcode will tell you the name of the test’s class and
method. By naming your tests this way, you can quickly determine the problem.
2. You attempt to instantiate a new instance of CashRegister, which you pass into
XCTAssertNil. This is a test expression that asserts whatever passed to it is not
nil. If it actually is nil, the test will be marked as failed.
However, this last line doesn’t compile! This is because you haven’t created a class
for CashRegister just yet… how are you suppose to advance the TDD Cycle, then?
Fortunately, there’s a rule in TDD for this: Compilation failures count as test failures.
So, you’ve completed the red step in the TDD Cycle and can move onto the next step:
green.
class CashRegister {
Press Play to execute the playground, and you should see output similar to the
following in the console:
Awesome, you’ve made the test pass! The next step is to refactor your code.
raywenderlich.com 29
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
• Duplicate logic: Can you pull out any properties, methods or classes to eliminate
duplication?
• Comments: Your comments should explain why something is done, not how it’s
done. Try to eliminate comments that explain how code works. The how should be
conveyed by breaking up large methods into several well-named methods,
renaming properties and methods to be more clear or sometimes simply
structuring your code better.
• Code smells: Sometimes a particular block of code simply seems wrong. Trust
your gut and try to eliminate these “code smells.” For example, you might have
logic that’s making too many assumptions, uses hardcoded strings or has other
issues. The tricks from above apply here, too: Pulling out methods and classes,
renaming and restructuring code can go a long way to fixing these problems.
Right now, CashRegister and CashRegisterTests don’t have much logic in them,
and there isn’t anything to refactor. So, you’re done with this step — that was easy!
The most important step in the TDD Cycle happens next: repeat.
Repeat: Do it again
Use TDD throughout your app’s development to get the most benefit from it. You’ll
accomplish a little bit in each TDD Cycle, and you’ll build up app code backed by
tests. Once you’ve completed all of your app’s features, you’ll have a working, well-
tested system.
You’ve completed your first TDD Cycle, and you now have a class that can be
instantiated: CashRegister. However, there’s still more functionality to add for this
class to be useful. Here’s your to-do list:
raywenderlich.com 30
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
TDDing init(availableFunds:)
Just like every TDD cycle, you first need to write a failing test. Add the following
below the previous test, which should generate a compiler error:
func testInitAvailableFunds_setsAvailableFunds() {
// given
let availableFunds = Decimal(100)
// when
let sut = CashRegister(availableFunds: availableFunds)
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
This test is more complex than the first, so you’ve broken it into three parts: given,
when and then. It’s useful to think of unit tests in this fashion:
In this case, you’re given availableFunds of Decimal(100). When you create the
sut via init(availableFunds:), then you expect sut.availableFunds to equal
availableFunds.
Note: if the given, when and then sections are very simple, you might choose
to omit these comment lines. We’ve included them throughout this chapter for
clarity, but in your own projects, use your own judgement to decide whether
having or omitting them makes the code easier to read.
What’s the name sut about? sut stands for system under test. It’s a very common
name used in TDD that represents whatever you’re testing. This name is used
throughout this book for this very purpose.
raywenderlich.com 31
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
You next need to get this to pass. Add the following code inside CashRegister:
init(availableFunds: Decimal = 0) {
self.availableFunds = availableFunds
}
Press Play to execute all of the tests, and you should see output similar to the
following in the console:
You next need to refactor both your app and test code. First, take a look at the test
code.
What about the app code? Does it make sense to have a default parameter value of 0
for availableFunds? This was useful to get both testInit and
testInitAvailableFunds to compile, but should this class actually have this?
raywenderlich.com 32
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
• If you choose to keep the default parameter, you might consider adding a test for
testInit_setsDefaultAvailableFunds, in which you’d verify availableFunds
is set to the expected default value.
• Alternatively, you might choose to remove the default parameter, if you decide it
doesn’t make sense to have this.
For this example, assume that it doesn’t make sense to have a default parameter. So,
delete the default parameter value of 0. Your initializer should then look like this:
init(availableFunds: Decimal) {
Press Play to execute your remaining test, and verify it still passes.
You’ve now completed the refactor step, and you’re ready to move onto the next
TDD Cycle.
TDDing addItem
You’ll next TDD addItem to add an item’s cost to a transaction. As always, you first
need to write a failing test. Add the following below the previous test, which should
generate compiler errors:
func testAddItem_oneItem_addsCostToTransactionTotal() {
// given
let availableFunds = Decimal(100)
let sut = CashRegister(availableFunds: availableFunds)
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
raywenderlich.com 33
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
To fix this, add the following property right after availableFunds within
CashRegister:
Here, you set transactionTotal to the passed-in cost. But that’s not exactly right,
or is it?
Remember how you’re supposed to write the bare minimum code to get a test to
pass? In this case, the bare minimum code required to add a single transaction is
setting transactionTotal to the passed-in cost of the item, not adding it! Thereby,
this is what you did.
Press Play, and you should see console output indicating all tests have passed. This
is technically correct, for one item. Just because you’ve completed a single TDD Cycle
doesn’t mean that you’re done. Rather, you must implement all of your app’s
features before you’re done!
In this case, the missing “feature” is the ability to add multiple items to a transaction.
Before you do this, you need to finish the current TDD cycle by refactoring what
you’ve written.
Start by looking over your test code. Is there any duplication? There sure is! Check
out these lines:
Add the following right after the opening curly brace for CashRegisterTests:
raywenderlich.com 34
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
Just like production code, you’re free to define whatever properties, methods and
classes you need to refactor your test code. There’s even a pair of special methods to
“set up” and “tear down” your tests, conveniently named setUp() and tearDown().
setUp() is called right before each test method is run, and tearDown() is called
right after each test method finishes.
These methods are the perfect place to move the duplicated logic. Add the following
below your test properties:
// 1
override func setUp() {
super.setUp()
availableFunds = 100
sut = CashRegister(availableFunds: availableFunds)
}
// 2
override func tearDown() {
availableFunds = nil
sut = nil
super.tearDown()
}
1. Within setUp(), you first call super.setUp() to give the superclass a chance to
do its setup. You then set availableFunds and sut.
2. Within tearDown(), you do the opposite. You set availableFunds and sut to
nil, and lastly call super.tearDown().
You should always nil any properties within tearDown() that you set within
setUp(). This is due to the way the XCTest framework works: It instantiates each
XCTestCase subclass within your test target, and it doesn’t release them until all of
the test cases have run. Thereby, if you have a many test cases, and you don’t set
their properties to nil within tearDown, you’ll hold onto the properties’ memory
longer than you need. Given enough test cases, this can even cause memory and
performance issues when running your tests.
raywenderlich.com 35
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
You can now use these instance properties to get rid of the duplicated logic in the
test methods. Replace the contents of testInitAvailableFunds with this single
line:
XCTAssertEqual(sut.availableFunds, availableFunds)
This makes it very easy to read, and this removes the need for the given and when
comments.
// given
let itemCost = Decimal(42)
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
Ah, that’s much simpler too! By moving the initialization code into setup(), you can
clearly see this method is simply exercising addItem(_:). Press Play to confirm all
tests still pass.
This completes the refactoring work, so you’re now ready to move onto the next TDD
Cycle.
raywenderlich.com 36
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
func testAddItem_twoItems_addsCostsToTransactionTotal() {
// given
let itemCost = Decimal(42)
let itemCost2 = Decimal(20)
let expectedTotal = itemCost + itemCost2
// when
sut.addItem(itemCost)
sut.addItem(itemCost2)
// then
XCTAssertEqual(sut.transactionTotal, expectedTotal)
}
This test calls addItem() twice, and validates whether the transactionTotal
accumulates.
Press Play, and you’ll see the console output indicates the test failed:
You next need to get this test to pass. To do so, replace the contents of addItem(_:)
with this:
transactionTotal += cost
raywenderlich.com 37
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
You lastly need to refactor your code. Notice any duplication? How about the
itemCost variable used in both addItem tests? Yep, you should pull this into an
instance property.
Add the following below the instance property for availableFunds within
CashRegisterTests:
Next, add this line right after setting availableFunds within setUp():
itemCost = 42
Since you set this property within setUp(), you also must nil it within tearDown().
Add the following right after setting availableFunds to nil within tearDown():
itemCost = nil
// given
let itemCost = Decimal(42)
When you’re done, the only itemCost to remain should be the instance property
defined on CashRegisterTests. Then, press the Play button to verify that all tests
continue to pass.
See any other duplication within CashRegisterTests? What about this line?
sut.addItem(itemCost)
Remember how setUp() is called before every test method is run? You already have
one test method that doesn’t require this call, testInitAvailableFunds.
raywenderlich.com 38
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
As you continue to TDD CashRegister, you’ll likely write other methods that won’t
need to call addItem(_:). Consequently, you shouldn’t move this call into setUp().
When to refactor code to eliminate duplication is more an art than an exact science.
Do what you think is best while you’re going along, but don’t be afraid to change
your decision later if needed!
Challenge
CashRegister is off to a great start! However, there’s still more work to do.
Specifically, you need a method to accept payment. To keep it simple, you’ll only
accept cash payments — no credit cards or IOUs allowed!
Try to solve this yourself first without help. If you get stuck, see below for hints.
For this challenge, you need to create two test methods within CashRegisterTests.
raywenderlich.com 39
iOS Test-Driven Development by Tutorials Chapter 2: The TDD Cycle
Key points
You learned about the TDD Cycle in this chapter. This has four steps:
Xcode playgrounds are a great way to learn new concepts, just like you learned the
TDD Cycle in this chapter. In real-world development, however, you typically create
unit test targets within your iOS projects, instead of using playgrounds. Fortunately,
TDD works even better with apps than playgrounds!
Continue onto the next section to learn about using TDD in iOS apps.
raywenderlich.com 40
Section II: Beginning TDD
This section will teach you the basics of test-driven development (TDD). You’ll learn
about setting up your app for TDD, test expressions, dependency injection, mocks
and test expectations.
Along the way, you’ll build a fitness app to learn the basics of TDD through hands-on
practice.
raywenderlich.com 41
3 Chapter 3: TDD App Setup
By Michael Katz
Begin with the starter project for Chapter 3. This is a shell app. It comes with some
things already wired to save you some busy work. It’s mostly bare-bones since the
goal is to lead development with writing tests.
raywenderlich.com 42
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
While it’s built alongside the app, it’s not included in the app bundle, which means
your test code can contain code that doesn’t ship to your users. However, just
because your users won’t see this code doesn’t mean you can write lower-quality
code.
The TDD philosophy treats tests as first-class code, meaning they should fit the same
standards as your production code in terms of readability, naming, error handling
and coding conventions.
raywenderlich.com 43
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Scroll down to the Test section and select Unit Testing Bundle. Click Next.
Did you notice the other bundle — UI Testing Bundle? UI testing uses automation
scripting to verify views and app state. This type of testing isn’t necessary for
adherence to TDD methodology and is outside the scope of this book.
Voila! You now have a FitNessTests target. Xcode also added a FitNessTests group
in the Project navigator with FitNessTest.swift and an Info.plist for the target.
raywenderlich.com 44
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Right now, the app does nothing since there’s no business logic. There’s only one
button, and users expect that tapping Start will start the activity. Therefore, you
should start with… Start.
The TDD process requires writing a test first, which means determining the smallest
unit of functionality. This unit is where to start: The smallest thing that does
something.
The App Model directory contains an AppState enum, which, not surprisingly,
represents the app’s different potential states. The AppModel class contains the app’s
current state.
The minimum functionality to start the app is having the Start button put the app
into a started, or in-progress, state. Two statements support this goal:
1. The app should start in .notStarted to let the UI render the welcome
messaging.
2. When the user taps Start, the app should move into .inProgress to track user
activity and display updates.
The statements are actually assertions and what you’ll use to define test cases.
raywenderlich.com 45
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Name the class AppModelTests. A good naming convention takes the name of the
file or class you’re testing and appends the suffix: Tests. In this case, you’re writing
tests for AppModel. Click Next.
Make sure the group is FitNessTests and only the FitNessTests target is checked.
Click Create. If Xcode asks to create an Objective-C bridging header, click Don’t
Create. There’s no Objective-C in this project.
You now have a fresh test class to start adding test cases. Delete the template
methods testExample() and testPerformanceExample(). Ignore
setUpWithError() and tearDownWithError() for now.
raywenderlich.com 46
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Red-Green-Refactor
The name of the game in TDD is red, green, refactor. To iteratively writing tests in
this fashion, you:
2. Write the minimum amount of code to make the test pass (green).
4. Repeat the process until you cover all the logic cases.
func testAppModel_whenInitialized_isInNotStartedState() {
let sut = AppModel()
let initialState = sut.appState
XCTAssertEqual(initialState, AppState.notStarted)
}
This method creates an app model and gets its appState. The third line of the test
performs the assertion that the state matches the expected value— more on that in a
little bit.
• Click the diamond next to an individual test in the line number bar. This method
runs just that test.
raywenderlich.com 47
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
• Click the diamond next to the class definition to run all the tests in the file.
• Click Play to the right of a test or test class in the Test navigator. This process
runs an individual test, a whole test file or all the tests in a test target.
• Use the Product ▸ Test menu action, or Command + U, to run all the tests in the
scheme. Right now, there’s one test target, so it would only run all the tests in
FitNessTests.
• Press Control + Option + Command + U. This method runs the test function if
the editor cursor is within a test function or the whole test file if the cursor is in a
test file but outside a specific test function.
That’s a lot of ways to run a test! Choose whichever one you prefer to run your one
test.
Before the test executes, you’ll receive two compilation errors, which means this is a
failing test! Congratulations! A failing test is the first step of TDD! Remember that
red isn’t just good, but necessary at this stage. If the test were to pass without any
code written, then it’s not a worthwhile test.
import FitNess
In Xcode, although application targets aren’t frameworks, they are modules, and test
targets can import them as if they were a framework. Like frameworks, you have to
import them in each Swift file, so the compiler is aware of what the app contains.
If the compile error cannot find 'AppModel' in scope doesn’t resolve itself, you
can make Xcode rebuild the test target via the Product ▸ Build For ▸ Testing menu
or the default keyboard shortcut Shift + Command + U.
You’re not done fixing compiler errors yet. Now, the compiler will complain about
Value of type 'AppModel' has no member 'appState'.
Open AppModel.swift and add the following variable to the class directly above
init():
Run the test again. You’ll get a green checkmark next to the test since it passes.
raywenderlich.com 48
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Notice how the only app code you wrote was to make that one pass.
Congrats, you now have a green test! This test is trivial: You’re testing the default
state of an enum variable as the result of an initializer. That means, in this case,
there’s nothing to refactor. You’re done!
Add the following test to the end of your class before the closing bracket:
func testAppModel_whenStarted_isInInProgressState() {
// 1 given app in not started
let sut = AppModel()
// 2 when started
sut.start()
// 3 then it is in inProgress
let observedState = sut.appState
XCTAssertEqual(observedState, .inProgress)
}
1. The first line creates an AppModel. The previous test ensures the model
initializes to .notStarted.
3. The last two lines verify the state should then be equal to .inProgress.
Run the tests. Once again, you have a red test that doesn’t compile. Next, you’ll fix
the compiler errors.
raywenderlich.com 49
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
The test fails! Obviously, it fails because start() has no code. Add the minimum
code to start(), so the test passes:
appState = .inProgress
Note: It’s straightforward logic that an empty start() fails the test. TDD is
about discipline, and it’s good practice to strictly follow the process while
learning. With more experience, it’s OK to skip the literal build and test step
after getting the test to compile. However, you can’t skip writing the minimum
amount of code so the test passes. It’s essential to the TDD process and is
what ensures adequate coverage.
raywenderlich.com 50
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Test nomenclature
These tests follow some TDD nomenclature and naming best practices. Take another
look at the second test, line-by-line:
1. func testAppModel_whenStarted_isInInProgressState() {
The test function name should describe the test. The test name shows up in the test
navigator and test logs. With a large test suite that runs in a continuous integration
rig, you can just look at the test failures and see the problem. Avoid creating tests
with non-descript names like test1 and test2.
ii. AppModel This says an AppModel is the system under test (sut).
iii. whenStarted is the condition or state change that is the catalyst for the test.
iv. isInInProgressState is the assertion about what the sut’s state should be after
the when happens.
This naming convention also helps keep the test code focused on a specific
condition. Any code that doesn’t flow naturally from the test name belongs in
another test.
3. sut.start()
This is the behavior to test. In this case, the test covers what happens when you call
start().
5. XCTAssertEqual(observedSate, .inProgress)
raywenderlich.com 51
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
The last part is the assertion about what happens to sut when it starts. The stated
logical assertions correspond directly in XCTest to XCTAssert functions.
• The first part of a test is the things that are given. That’s the initial state of the
system.
• The second part is the when, which is the action, event or state change that acts
on the system.
• The third part, or then, is testing the expected state after the when.
TDD is a process, not a naming convention. This book uses the convention outlined
here, but you can still follow TDD on your own using whatever naming conventions
you’d like. What’s important is your write failing tests, add the code that makes the
test pass, then refactor and repeat until the application is complete.
With XUnit, tests are methods whose names start with test and are part of a test
case class. You can group test cases into a test suite. Test runner is a program that
knows how to find test cases in the suite, run them and gather and display results.
It’s Xcode’s test runner that executes when you run the test phase of a scheme.
Each test case class has a setUpWithError() and tearDownWithError() used to set
up global and class state before and after each test method runs. Unlike other XUnit
implementations, XCTest doesn’t have lifecycle methods that run only once for a
whole test class or the test target.
raywenderlich.com 52
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
These methods are essential because of a few subtle but extremely important
gotchas:
• You manage XCTestCase subclass lifecycles outside the test execution, and any
class-level state persists between test methods.
• Because it isn’t explicitly defined, you can’t rely on the order in which test classes
and test methods run.
Setting up a test
Both tests need an AppModel() to test. It’s common for test cases to use a common
sut object.
This variable sets aside storage for an AppModel to use in the tests. It’s force-
unwrapped in this case because you don’t have access to the class initializer. Instead,
you have to set up variables later, for example, in setUpWithError().
The second test modifies the appState of sut. Without the setup code, the test order
could matter because the first test asserts the initial state of sut. But for now, the
order doesn’t matter since sut is re-instantiated for each test.
raywenderlich.com 53
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Update tearDownWithError():
So far, it’s a pretty simple test case, and the only persistent state is in sut, so clearing
it in tearDown is good practice. It helps ensure that any new global behavior you add
in the future won’t affect previous tests.
Hold up! This is test-driven development, and that means writing the test first.
Since StepCountController contains the logic for the main screen, create a new
Unit Test Case Class named StepCountControllerTests in the FitNessTests
target.
raywenderlich.com 54
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Test Target
⌊ Cases
⌊ Group 1
⌊ Tests 1
⌊ Tests 2
⌊ Group 2
⌊ Tests
⌊ Mocks
⌊ Helper Classes
⌊ Helper Extensions
• Cases: The group for the test cases. These are organized in a parallel structure to
the app code, making it easy to navigate between the app class and its tests.
• Mocks: For code that stands in for functional code, letting you separate
functionality from implementation. For example, developers commonly mock
network requests. You’ll build these in later chapters.
• Helper classes and extensions: For additional code you write to make the test
code easier to write, but which doesn’t directly test or mock functionality.
Put the two classes already in the target in a group named Cases.
When it’s all done, your target structure will look like this:
raywenderlich.com 55
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Build the test class now. You’ll see an error, cannot find type
'StepCountController' in scope, because the class is specified as internal since
it doesn’t explicitly define access control.
There are two ways to fix this error. The first is to declare StepCountController as
public, making that class available outside the FitNess module and usable by the
test class. However, this would violate SOLID principles by making the view visible
outside of the app.
Fortunately, Xcode provides a way to expose data types for testing without making
them available for general use through the @testable attribute.
Add the following to the top of the file, under import XCTest:
This code makes symbols that are open, public, and internal available to the test
case. Note that this attribute is only available in test targets and will not work in app
or framework code. Now, the test can successfully build.
raywenderlich.com 56
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
func testController_whenStartTapped_appIsInProgress() {
// when
sut.startStopPause(nil)
// then
let state = AppModel.instance.appState
XCTAssertEqual(state, AppState.inProgress)
}
This method tests if the app state is inProgress when you call startStopPause().
Build and test. You’ll get a test failure because you haven’t implemented
startStopPause yet. Remember, test failures at this point are good!
AppModel.instance.start()
Testing UI updates
UI testing with UI Automation is a different type of testing and is outside the scope
of this book. However, there are plenty of UI aspects that can, and should, be unit
tested. SwiftUI’s declarative nature means a view’s internal structure is deliberately
difficult to access. UI Automation makes that task significantly easier, but you can
also write valuable unit tests by separating the state-controlling logic from the view
hierarchy.
raywenderlich.com 57
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
func testController_whenStartTapped_buttonLabelIsPause() {
// when
sut.startStopPause(nil)
// then
let text = sut.startButton.title(for: .normal)
XCTAssertEqual(text, AppState.inProgress.nextStateButtonLabel)
}
Like the previous tests, this test performs startStopPause(). However, this time the
test checks if the button text updates.
You may have noticed that this test is almost the same as the previous one. It has the
same initial conditions and “when” action. The critical difference is that this tests a
different state change.
TDD best practice is to have one assert per test. With well-named test methods,
when the test fails, you’ll know exactly where the issue is because there’s no
ambiguity between multiple conditions. You’ll tackle cleaning up this kind of
redundancy in later chapters.
Since this is TDD, the test will fail if you run it. Fix the test by adding the appropriate
code to the end of startStopPause(_:):
Now, build and test again for a green test. You can also build and run to try out the
functionality.
raywenderlich.com 58
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
func testController_whenCreated_buttonLabelIsStart() {
let text = sut.startButton.title(for: .normal)
XCTAssertEqual(text, AppState.notStarted.nextStateButtonLabel)
}
// MARK: - In Progress
This test checks the button’s label after it’s created to make sure it reflects
the .notStarted state.
This also adds some MARKs to the file to help divide the test case up into sections. As
the classes get more complicated, the test files will grow quite large, so it’s important
to keep them well organized.
Build and test. Hurray, another failure! Go ahead and fix the test.
The last two tests rely on certain initial conditions for their states. For example, in
testView_whenStartTapped_buttonLabelIsPause, the desire is to test for the
transition from .notStarted to .inProgress. But the test could also pass if the
view started in .inProgress.
The test is not quite ready yet to pass. Go back to the tests, and add at the top of
testController_whenCreated_buttonLabelIsStart() the following lines:
// given
sut.viewDidLoad()
Now, build and test and the tests will pass. The call to viewDidLoad() is needed
because the sut is not actually loaded from the xib and put into a view hierarchy, so
the view lifecycle methods do not get called. You’ll see in Chapter 4, “Test
Expressions,” how to get a properly loaded view controller for testing.
raywenderlich.com 59
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Refactoring
If you look at StepCountController.swift, the code that sets the button text is
awfully redundant. When building an app using TDD, after you get all the tests to
pass, you can then refactor the code to make it more efficient, readable,
maintainable, etc. You can feel free to modify the both the app code and test code at
will, resting easy because you have a complete set of tests to catch any issues if you
break it.
This helper method will be used in multiple places in the file — whenever the button
needs to reflect a change in app state. This can be private as this is an internal
implementation detail of the class. The behavioral methods remain internal and
can still be tested.
In viewDidLoad() and startStopPause(_:) replace the two lines that update the
title with a call to updateButton().
Build and test. The tests will all still pass. Code was changed, but behavior was kept
constant. Hooray refactoring! This type of refactoring is called Extract Method.
There is a menu item to do it available in the Editor ▸ Refactor menu in Xcode.
You’re still a long way from a complete app with a full test suite, but you are on your
way.
Challenge
There are a few things left to do with the two test classes you already made. For
example, AppModel is public when it should be internal. Update its access modifier
and use @testable import in AppModelTests.
raywenderlich.com 60
iOS Test-Driven Development by Tutorials Chapter 3: TDD App Setup
Key points
• TDD is about writing tests before writing app logic.
• Each test should fail upon its first execution. Not compiling counts as a failure.
For more information specific to how Xcode works with tests and test targets, see the
developer documentation (https://developer.apple.com/library/archive/
documentation/DeveloperTools/Conceptual/testing_with_xcode/chapters/04-
writing_tests.html). For a jam-packed overview on iOS testing, try out this free iOS
Unit Testing and UI Testing tutorial (https://www.raywenderlich.com/21020457-ios-
unit-testing-and-ui-testing-tutorial).
In the next chapter, you’ll learn more about XCTAssert functions, testing view
controllers, code coverage and debugging unit tests.
raywenderlich.com 61
4 Chapter 4: Test
Expressions
By Michael Katz
The TDD process is straightforward, but writing good tests may not always be.
Fortunately, each year, Xcode and Swift have become more capable. This means you
have many features at your disposal that help with both writing and running tests.
This chapter covers how to use the XCTAssert functions. These are the primary
actors of the test infrastructure. You’ll go through gathering code coverage to verify
the minimum amount of testing. Finally, you’ll use the test debugger to find and fix
test errors.
• XCTAssert functions
• UIViewController testing
• Code Coverage
• Test debugging
Note: Be sure to use the Chapter 4 starter project rather than continuing with
the Chapter 3 final project. It has a few new things added to it, including
placeholders for the code to add in this tutorial.
raywenderlich.com 62
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Assert methods
In Chapter 3, “TDD App Setup,” you used XCTAssertEqual exclusively. There are
several other assert functions in XCTest:
Ultimately, any test case can be boiled down to a conditional: (does it meet an
expectation or not) so any test assert can be re-composed into a XCTAssertTrue.
Note: With XCTest, a test is marked as passed as long as there are no failures.
This means that it does not require a positive XCTAssert assertion. A test with
no asserts will be marked as success, even though it does not test anything!
App state
In the previous chapter, you built out the functionality to move the app from a not
started state to an in-progress one. Now is a good time to think about about the
whole app lifecycle.
Here are the possible app states, as represented by the AppState enum:
• inProgress: The app is actively monitoring the activity of the user and Nessie.
• paused: The app was paused by the user. Nessie is put to sleep and the activity
tracking stops.
• completed: The user has reached their activity goal before Nessie caught up.
raywenderlich.com 63
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
The solid lines represent user action on the UI, and the dotted lines happen
automatically due to time or activity events. The user-based transitions will be
covered in this chapter project, and the automatic transitions will be covered in
Chapter 5: “Test Expectations.”
Add a new unit test case class to the test target, in the Data Model group. Name it
DataModelTests. Once again, and like always, remove testExample() and
testPerformanceExample().
Now, you have a red test case class. To fix it, open DataModel.swift and add the
following, the minimum to get the test to compile:
class DataModel {
This creates a stub class to fix the compiler error. You’ll build upon this piece-by-
piece.
raywenderlich.com 64
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
These create a new DataModel for each test, and then cleans it up afterwards.
// MARK: - Goal
func testModel_whenStarted_goalIsNotReached() {
XCTAssertFalse(
sut.goalReached,
"goalReached should be false when the model is created")
}
This test introduces XCTAssertFalse, which checks that the expected value is
false. Each XCTAssert function can also take an optional String message. This
message is displayed in the standard editor and report navigator’s error log when the
test fails. If you follow the test naming convention and only use one XCTAssert per
test, then you won’t normally need to supply an error message. While test name will
usually be descriptive enough to inform you why a failure occurred, it can be useful
to add a message if the assertion isn’t obvious.
The initial state is the boring state. Next build out the business logic.
raywenderlich.com 65
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
func testModel_whenStepsReachGoal_goalIsReached() {
// given
sut.goal = 1000
// when
sut.steps = 1000
// then
XCTAssertTrue(sut.goalReached)
}
This tests the logic “the goal is reached when the number of steps equals or exceeds
the goal.”
Now, you need a goal and steps for it to compile. Open DataModel.swift and add the
following below goalReached:
Now, the test will build, but fail since you previously hard coded the value of
goalReached to false.
Run the test again. It’s a little tricky on the fingers, but you can use Product ▸
Perform Action ▸ Test Again (^⌥⌘G) to re-run the last test from anywhere in
Xcode. Now, the test passes, and you’ve seen true and false asserts.
Pretty much every assert is just a Boolean test and can be rewritten as such. That
means you can write your own helper methods that look like XCTAssert’s. These just
have to eventually evaluate to a Boolean that is passed to XCTAssertTrue().
raywenderlich.com 66
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Testing Errors
If the optional goal property isn’t set, it doesn’t make sense for the app to enter the
inProgress state. Therefore starting the app without a goal is an error!
Make it a real error. Open AppModel.swift, then add the throws keyword to the
function signature of start():
updateUI()
}
Once you’re all done, tapping the Start button without setting a goal will display an
alert. Don’t worry about writing a test first for this right now.
func testModelWithNoGoal_whenStarted_throwsError() {
XCTAssertThrowsError(try sut.start())
}
Using XCTAssertThrowsError, you can verify that an error is thrown if the model is
started in its initial state without a goal set.
This test fails since there is no error thrown yet. To fix that, open AppModel.swift
and add the following instance variable:
raywenderlich.com 67
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
The app model will be the container for the data model, since the app’s data is a
subset of the app’s state. The data model’s goal is needed to check for an error.
Next, verify that setting a goal means that start() will not throw an error. Open
AppModelTests.swift and add the following under // MARK: - Given:
func givenGoalSet() {
sut.dataModel.goal = 1000
}
func testStart_withGoalSet_doesNotThrow() {
// given
givenGoalSet()
// then
XCTAssertNoThrow(try sut.start())
}
This test should go right to green, since the app logic was already written. Even
though no code had to be added or changed for this test, it’s still TDD since the tests
are leading the way. This test just completes checking all the cases of the logical
flow.
Finally, it’s time to fix all the other tests that started failing due to this change.
// given
givenGoalSet()
raywenderlich.com 68
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
func givenGoalSet() {
AppModel.instance.dataModel.goal = 1000
}
Finally, in the two tests under // MARK: - In Progress, add the following to the
top of each:
// given
givenGoalSet()
Build and run all the tests. They all pass! Changing these existing tests to pass again
after changing the app logic is another aspect of the refactor phase of the TDD
cycle.
If you build and run the app, there will now be an alert when Start is tapped and the
app won’t move into the inProgress state. In the next section you will update the
app with the ability to save the goal.
raywenderlich.com 69
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Functional testing is done by using separate methods for interacting with the UI
(callbacks, delegate methods, etc.) from logic methods (updating state).
Note: If you have experience with other app architectures, using something
like MVVM or VIPER makes it cleaner to test this type of logic. Separating a
ViewModel from the controller takes the unit-testable logic out of the
controller. For the purposes of this section, you’ll continue to build the app
using the traditional Apple MVC model. This is what’s covered in most of the
documentation and the traditional place to start developing iOS applications.
func testDataModel_whenGoalUpdate_updatesToNewGoal() {
// when
sut.updateGoal(newGoal: 50)
// then
XCTAssertEqual(AppModel.instance.dataModel.goal, 50)
}
This test calls updateGoal(newGoal:) and verifies the data model has been properly
updated.
AppModel.instance.dataModel.goal = nil
raywenderlich.com 70
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
As expected, the test will fail. Let’s turn the test green. Open
StepCountController.swift and replace updateGoal(newGoal:) with the following:
func testChaseView_whenLoaded_isNotStarted() {
// when loaded, then
let chaseView = sut.chaseView
XCTAssertEqual(chaseView?.state, .notStarted)
}
The test builds, but does not pass, because chaseView is nil. What gives?
Well, there is a cheat in the code to allow the existing tests to pass. Under normal
app flow, a StepCountController is created and populated by the storyboard. It’s
already loaded by the time any app code gets to execute.
In this test the sut is initialized directly, which means its starting state is not the
same as when the app runs. Fortunately, there is a clean way to handle this.
When unit tests are run as part of the Test action in an app scheme, Xcode uses a
Host Application as specified in the target settings.
Open the General tab of the Project editor for the FitNessTests target. You’ll see
that FitNess is selected as the Host Application.
This means that running the test action, will launch the host app on the specified
destination (simulator or device). The test runner waits for the app to load before
starting the tests, and the tests are run in the app’s context.
raywenderlich.com 71
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
As a consequence, you have access to the UIApplication object and the whole View
hierarchy in the tests.
In the Project navigator, under FitNessTests target, add a new group: Test Classes.
Next, create a new Swift File, ViewControllers.swift, in that group
import UIKit
@testable import FitNess
This function navigates the app’s window to retrieve the root view controller, which
is of type RootViewController. This helper function will be used to obtain other
view controllers.
Next, create another new group, Test Extensions under FitNessTests. In that group,
add a new Swift file: RootViewController+Tests.swift.
Replace the contents of this file with the following RootViewController extension:
import UIKit
@testable import FitNess
extension RootViewController {
var stepController: StepCountController {
return children.first { $0 is StepCountController }
as! StepCountController
}
}
Now, you have all the pieces to get the StepCountController from the host app.
raywenderlich.com 72
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
func givenInProgress() {
givenGoalSet()
sut.startStopPause(nil)
}
This sets the app into the inProgressState. It’s ensured by the test
testController_whenStartTapped_appIsInProgress().
func testChaseView_whenInProgress_viewIsInProgress() {
// given
givenInProgress()
// then
let chaseView = sut.chaseView
XCTAssertEqual(chaseView?.state, .inProgress)
}
This test will fail since the chaseView is not yet updated. Open
StepCountController.swift and replace updateChaseView() at the bottom with the
following:
raywenderlich.com 73
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Note: One alternate way of retrieving and testing a view controller can be
done as follows: First, get a reference to the storyboard:
let storyboard = UIStoryboard(name: "Main", bundle: nil)
Following this pattern allows you to instantiate a fresh view controller for
each test, and it affords the option to set up and tear down the view controller
for each test.
Open the report navigator and look at the result for when you last ran all the tests.
raywenderlich.com 74
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
This message tells you not only that the button text is not what’s expected, but
specifically that the button text is “Pause.” That’s what the button should say when
the app is inProgress. This violates the assumption that the test is starting with a
fresh StepCountController.
The previous change to using the host app’s StepCountController meant that a
new controller is not created every setUpWithError() and the app state is persisted.
In order to have clean tests, you need to reset the state in tearDownWithError().
To help with this, you can create a new function on AppModel to reset the state. But,
first, write the tests.
func givenInProgress() {
givenGoalSet()
try! sut.start()
}
This puts the app in an inProgress state, allowing for the state restart test to
actually test a change.
Next, add the following to the bottom of the test case class:
// MARK: - Restart
func testAppModel_whenReset_isInNotStartedState() {
// given
givenInProgress()
// when
sut.restart()
// then
XCTAssertEqual(sut.appState, .notStarted)
}
This tests that the not-yet-added restart() puts the model back into notStarted.
To get the test to pass open AppModel.swift and add the following to AppModel:
func restart() {
appState = .notStarted
}
This function will be used as a test helper for now, but eventually will be part of the
whole app’s state cycle.
raywenderlich.com 75
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Randomized order
There is also an option in the Test action of the scheme to randomize the test order.
Edit the FitNess scheme. Select the Test action. In the center pane, next to
FitNessTests is an Options… button. Click that and, in the pop-up, check
Randomize execution order. This will cause the tests to run in a random order each
time.
This can expose hidden inter-test dependencies that you wouldn’t catch with the
default ordering. The downside is that the ordering is not guaranteed, meaning you
might have missed the previous issue. Also, if an ordering issue does come up, it
might be hard to reproduce if it was very specific. Sporadic and hard-to-diagnose test
failures are one symptom that the random ordering uncovered an issue.
raywenderlich.com 76
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Code coverage
While on the subject of the scheme editor, open up the Test Action again. This time
select the Options tab. There is a checkbox for Code Coverage. Check it.
Run the tests again. After the tests succeed, open the Report navigator. Under the
latest test, there will be three reports: Build, Coverage and Log. Select Coverage to
display the coverage report.
Code coverage is the measure of how many lines of app code are executed during
tests. There will be a list of each file in the target along with the percentage of the
code lines that were executed. Having 100% or close for a file means you’re following
TDD closely. When the tests are written first, only the code needed to pass the test
gets added.
raywenderlich.com 77
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
You’ll see a coverage annotation on the right side of the editor. The number shown
represents the number of times that line was executed. Lines with a red coloring or a
“0” indicate opportunities to add additional tests.
Lines with a striped red annotation mean that only part of that line was run.
Hovering over the stripe in the annotation bar will show you in green which part was
run and in red what was not.
The problem with testing that condition is that, when there’s an error, an alert
controller is shown. You could write a test that checks for that alert controller, but
that is really the domain of UI automation testing. You could refactor
StepCountController so that a variable is set or a callback is called in that error
case, but then you would be modifying app code just to add a test. The test would
then be testing itself and not app functionality, which does not provide any value.
The goal should be to get as close to 100% as possible. Coverage doesn’t mean the
code works, but lack of coverage means that it’s not tested. For views and view
controllers, it’s not expected to get to 100% coverage because TDD does not include
UI testing. When you combine unit tests with UI automation tests, then you should
expect to be able to cover most if not all of these files.
raywenderlich.com 78
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Debugging tests
When it comes to debugging tests, you’ve already practiced the first line of defense.
That is: “Am I testing the right thing?”
Make sure:
If nothing obvious in the test code appears, next check the test execution order for
preserved state. Also use code coverage to make sure the right code paths are taken.
After trying that, you can use some other tools in Xcode’s arsenal. To try them out,
it’s time to think about the other important actor in the app: Nessie.
• When Nessie’s distance is greater than or equal to the user’s, Nessie wins (the user
is caught). The user cannot be caught when the distance is at 0, which is the start
condition.
// MARK: - Nessie
func testModel_whenStarted_userIsNotCaught() {
XCTAssertFalse(sut.caught)
}
This tests that with a fresh DataModel, the user is not caught. This test does not yet
compile.
raywenderlich.com 79
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
// MARK: - Nessie
This adds a Nessie to the data model, a variable to track user distance, and a
computed variable to compare the distances. A separate variable for distance is
used instead of steps to keep the calculations cleaner later on.
Even with the updated code, the test still fails. There are several ways to go about
diagnosing the problem. As you’ve already seen there are a few things to check:
• The test itself is correct, the given is a fresh DataModel as created in startUp().
The then is also correct, caught should be false.
A good next step is to try out the debugger. In the Breakpoint navigator, click the +
all the way at the bottom. Select Test Failure Breakpoint.
This creates a special breakpoint that halts execution when a unit test fails. Run the
test again, and the debugger will stop at the test failure.
raywenderlich.com 80
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Open the variables view, and expand self and then sut.
Here, you’ll see that both distance and steps are 0. So the app logic is doing the
right thing, Nessie is tied with the user, which should be the caught state. However,
this is a special case in which the starting condition cannot result in a capture.
To fix this, open DataModel.swift and replace caught with the following:
Now, the test will pass. This might have been an obvious example, but it illustrates
that you have all your normal debugging techniques available when running tests.
Completing coverage
If you take a look at the code coverage for DataModel.swift, it is no longer 100%. If
you look at the file, notice the striped annotation in the updated caught. Hovering
over the stripe shows that not of all the conditions were checked. The 0 tells you
there is more test.
func testModel_whenUserAheadOfNessie_isNotCaught() {
// given
sut.distance = 1000
sut.nessie.distance = 100
// then
XCTAssertFalse(sut.caught)
}
raywenderlich.com 81
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
func testModel_whenNessieAheadofUser_isCaught() {
// given
sut.nessie.distance = 1000
sut.distance = 100
// then
XCTAssertTrue(sut.caught)
}
func testGoal_whenUserCaught_cannotBeReached() {
//given goal should be reached
sut.goal = 1000
sut.steps = 1000
// then
XCTAssertFalse(sut.goalReached)
}
raywenderlich.com 82
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
Challenge
In StepCountControllerTests.tearDownWithError(), there are separate calls to
reset the AppModel and the DataModel. Since the data model is a property of the app
model, refactor the data model reset into AppModel.restart(), along with the
appropriate tests.
For an extra challenge, use some of the other XCTAssert functions not yet used, like
XCTAssertNil or XCTAssertLessThanOrEqual.
A second challenge is to add the pause functionality to the app so the user can move
back and forth between .paused and .inProgress. The pause doesn’t have to do
anything else at this point, since the direct functionality will be covered in later
chapters.
Key points
• Test methods require calling a XCTAssert function.
• View controller logic can be separated in to data/state functions, which can be unit
tested and view setup and response functions, which should be tested by UI
automation.
• The code coverage reports can be used to make sure all branches have a minimum
level of testing.
• Test failure breakpoints are a tool on top of regular debugging tools for fixing
tests.
raywenderlich.com 83
iOS Test-Driven Development by Tutorials Chapter 4: Test Expressions
In the next chapter, you’ll learn about testing asynchronous functions using
XCTestExpectation.
raywenderlich.com 84
5 Chapter 5: Test
Expectations
By Michael Katz
In the previous chapters you built out the app’s state based upon what the user can
do with the Start button. The main part of the app relies on responding to changes as
the user moves around and records steps. These actions create events outside the
program’s control. XCTestExpectation is the tool for testing things that happen
outside the direct flow.
• Notification expectations
Use this chapter’s starter project instead of continuing on from the previous’ final, as
it has some additions to help you out.
Using an expectation
XCTest expectations have two parts: the expectation and a waiter. An expectation
is an object that you can later fulfill. The wait method of XCTestCase tells the
test execution to wait until the expectation is fulfilled or a specified amount of time
passes.
In the last chapter you built out the app states corresponding to direct user action:
in progress, paused, and not started. In this chapter you’ll add support for caught
and completed.
raywenderlich.com 85
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
These state transitions occur in response to asynchronous events outside the user’s
control.
The red-shaded states have already been built. You’ll be adding the grey states.
Open AppModelTests.swift and add the following test under // MARK: - State
Changes:
func testAppModel_whenStateChanges_executesCallback() {
// given
givenInProgress()
var observedState = AppState.notStarted
// 1
let expected = expectation(description: "callback happened")
sut.stateChangedCallback = { model in
observedState = model.appState
// 2
expected.fulfill()
}
// when
sut.pause()
// then
raywenderlich.com 86
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
// 3
wait(for: [expected], timeout: 1)
XCTAssertEqual(observedState, .paused)
}
This test updates the appState using sut.pause then checks that
stateChangedCallback gets triggered and sets observedState to the new value.
You are using a few new things in this test:
3. wait(for:timeout:) causes the test runner to pause until all expectations are
fulfilled or the timeout time (in seconds) passes. The assertion will not be called
until the wait completes.
The test won’t compile, because stateChangedCallback doesn’t yet exist. Open
AppModel.swift, add the following to the class:
Adding this property allows the test to build. Now run it, and you’ll see the following
failure in the console:
The expectation never got fulfilled, so the test failed after the 1 second wait timeout.
raywenderlich.com 87
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
sut.stateChangedCallback = nil
Note: It is best practice to always call fulfill in the completion block, then
test for errors or other negative conditions using XCTAssert after the wait.
Timeout should not be used to signal a test failure, as it adds significant time
to the test.
func testController_whenCaught_buttonLabelIsTryAgain() {
// given
givenInProgress()
let exp = expectation(description: "button title change")
let observer = ButtonObserver()
observer.observe(sut.startButton, expectation: exp)
// when
whenCaught()
// then
waitForExpectations(timeout: 1)
let text = sut.startButton.title(for: .normal)
XCTAssertEqual(text, AppState.caught.nextStateButtonLabel)
}
func testController_whenComplete_buttonLabelIsStartOver() {
// given
givenInProgress()
let exp = expectation(description: "button title change")
let observer = ButtonObserver()
observer.observe(sut.startButton, expectation: exp)
// when
whenCompleted()
raywenderlich.com 88
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
// then
waitForExpectations(timeout: 1)
let text = sut.startButton.title(for: .normal)
XCTAssertEqual(text, AppState.completed.nextStateButtonLabel)
}
These tests observe the startButton title to confirm it properly updates after model
state changes.
Add a new Swift File to the Test Classes group and name it ButtonObserver.swift.
Replace the contents of the file with the following:
import XCTest
class ButtonObserver {
var token: NSKeyValueObservation?
deinit {
token?.invalidate()
}
}
func whenCaught() {
AppModel.instance.setToCaught()
}
func whenCompleted() {
AppModel.instance.setToComplete()
}
raywenderlich.com 89
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Build and run the StepCountControllerTests tests, and you’ll see a couple failures
in the console:
The button titles aren’t updating when whenCaught() and whenCompleted() are
called in your test, because there aren’t yet any hooks in the production code to do
this. Fix that by adding the following to viewDidLoad in
StepCountController.swift:
AppModel.instance.stateChangedCallback = { model in
DispatchQueue.main.async {
self.updateUI()
}
}
Note: Stopping execution in the debugger doesn’t pause the wait timeout.
You just added a bunch of code, and if there was a mistake you might go back
and debug the problem. This is common when writing tests, especially when
they do not behave as expected. When the debugger pauses at a breakpoint
and you explore for the logic error, be mindful that the test will probably fail
due to timeout. Simply disable or remove the breakpoint and re-run once the
issue is corrected.
raywenderlich.com 90
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
To test out the notification behavior, open AlertCenterTests.swift and add the
following test:
func testPostOne_generatesANotification() {
// given
let exp = expectation(
forNotification: AlertNotification.name,
object: sut,
handler: nil)
let alert = Alert("this is an alert")
// when
sut.postAlert(alert: alert)
// then
wait(for: [exp], timeout: 1)
}
raywenderlich.com 91
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Note that it’s not generally a good idea to use a wait as the test assertion. It’s better
to use an explicit assert call. wait only tests that an expectation was fulfilled and
does not make any claims about the app’s logic. You’ll test the contents of the
notification a little later in this chapter.
Build and test, and this test will fail. If you look at the error in the console, you’ll see
a timeout failure:
Time to implement the application code to fix this! Open AlertCenter.swift, replace
the stub implementation of postAlert(alert:) with the following:
This creates and posts the Notification your test is listening for. Note that the passed
alert isn’t used currently, but you’ll circle back to this later.
func testPostingTwoAlerts_generatesTwoNotifications() {
//given
let exp1 = expectation(
forNotification: AlertNotification.name,
object: sut,
handler: nil)
let exp2 = expectation(
forNotification: AlertNotification.name,
object: sut,
handler: nil)
let alert1 = Alert("this is the first alert")
let alert2 = Alert("this is the second alert")
// when
sut.postAlert(alert: alert1)
raywenderlich.com 92
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
sut.postAlert(alert: alert2)
// then
wait(for: [exp1, exp2], timeout: 1)
}
Build and test, and it will pass. However, this test is a little naïve. To see how, delete
this line:
sut.postAlert(alert: alert2)
Now you’re only posting one of the two alerts tied to expectations the wait requires.
Test again, and it will still pass! This is because the two expectations are expecting
the same thing. They run in parallel—they don’t stack. So as soon as one alert is
posted, both expectations are fulfilled.
func testPostingTwoAlerts_generatesTwoNotifications() {
//given
let exp = expectation(
forNotification: AlertNotification.name,
object: sut,
handler: nil)
exp.expectedFulfillmentCount = 2
let alert1 = Alert("this is the first alert")
let alert2 = Alert("this is the second alert")
// when
sut.postAlert(alert: alert1)
// then
wait(for: [exp], timeout: 1)
}
raywenderlich.com 93
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Run the test, and you’ll see it fails because you only called postAlert once. This is
good proof your test is working as expected!
In the when section, add back the second postAlert under sut.postAlert(alert:
alert1):
sut.postAlert(alert: alert2)
And of course, you can test for this scenario. Add the following test:
func testPostDouble_generatesOnlyOneNotification() {
//given
let exp = expectation(
forNotification: AlertNotification.name,
object: sut,
handler: nil)
exp.expectedFulfillmentCount = 2
exp.isInverted = true
let alert = Alert("this is an alert")
// when
sut.postAlert(alert: alert)
sut.postAlert(alert: alert)
// then
wait(for: [exp], timeout: 1)
}
This is almost exactly like the last one, except for this line:
exp.isInverted = true
raywenderlich.com 94
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Right now, the test fails because the application code currently allows multiple alerts
to post.
alertQueue.append(alert)
If the same alert is passed to postAlert(alert:) twice, the second one will be
ignored.
Be sure to run all the tests from time to time to make sure fixes for one test don’t
break another.
Create a new Unit Test Case Class file in the App Layer folder, under Cases. Name
it RootViewControllerTests.swift.
raywenderlich.com 95
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Finally, add a test for the base condition: that is, when the view controller is loaded,
there are no alerts showing:
func testWhenLoaded_noAlertsAreShown() {
XCTAssertTrue(sut.alertContainer.isHidden)
}
Next, add the following to test that the alert container is shown when there is an
alert:
func testWhenAlertsPosted_alertContainerIsShown() {
// given
let exp = expectation(
forNotification: AlertNotification.name,
object: nil,
handler: nil)
let alert = Alert("show the container")
// when
AlertCenter.instance.postAlert(alert: alert)
// then
wait(for: [exp], timeout: 1)
XCTAssertFalse(sut.alertContainer.isHidden)
}
raywenderlich.com 96
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Now it’s time to get the test to pass by adding the code to show the alert. Go back to
RootViewController.swift and add the following at the bottom of viewDidLoad:
AlertCenter.listenForAlerts { center in
self.alertContainer.isHidden = false
}
Open AlertCenter.swift, find the “class helpers” extension and add the following:
Build and run your new test and it should now pass.
Continuous refactoring
When you only run testWhenLoaded_noAlertsAreShown(), it will pass. If you run
all the tests in RootViewControllerTests, then
testWhenLoaded_noAlertsAreShown() may fail.
That is because the sut state is tied to the running UIApplication and is preserved
between runs. If testWhenAlertsPosted_alertContainerIsShown() runs first and
displays the alert, it will still be there when testWhenLoaded_noAlertsAreShown()
checks if any are displayed.
raywenderlich.com 97
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
To resolve this issue, you’ll refactor the code and build a way to clear out all the
alerts and reset the view between tests.
First, you need an interface to the state of AlertCenter. Add the following test to
AlertCenterTests.swift:
This means that AlertCenter needs an alertCount variable for the test to compile.
Add the following property to the class in AlertCenter.swift:
When adding new functionality, it’s important to cover the basic conditions as well.
Add the following to AlertCenterTests.swift:
func testWhenAlertPosted_CountIsIncreased() {
// given
let alert = Alert("An alert")
// when
sut.postAlert(alert: alert)
// then
XCTAssertEqual(sut.alertCount, 1)
}
func testWhenCleared_CountIsZero() {
// given
let alert = Alert("An alert")
sut.postAlert(alert: alert)
// when
sut.clearAlerts()
// then
XCTAssertEqual(sut.alertCount, 0)
}
raywenderlich.com 98
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
AlertCenter.instance.clearAlerts()
Because AppModelTests indirectly mess with DataModel state, they can also trigger
alerts that need to be cleared. Open AppModelTests.swift, add the following to the
top of tearDownWithError:
AlertCenter.instance.clearAlerts()
This ensures the state of AlertCenter is reset after each test that modifies it. Open
AlertCenter.swift, add the following to AlertCenter:
func clearAlerts() {
alertQueue.removeAll()
}
This allows you to remove all alerts from alertQueue, which can be used to solve
your issues with persisted alerts between tests. But first, there is one more place you
need to use your new alertCount.
self.alertContainer.isHidden = center.alertCount == 0
Now when an alert is triggered, you display alertContainer only if more than one
alert is currently present. Are you dizzy yet? With TDD, adding functionality requires
looping back and forth between the application and tests code.
AlertCenter.instance.clearAlerts()
Now alertQueue will clear after each test, preventing tests that modify the queue
from impacting each other.
raywenderlich.com 99
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
With the count reset, you just need to clear any existing alerts at the start of each
test to avoid the persistence issue you observed in
testWhenLoaded_noAlertsAreShown(). Add the following to the bottom of
setUpWithError:
sut.reset()
Now it’ll display an alert for any state change. Build and run. When the app loads tap
Start.
For now undo those changes and move on for more expectation testing.
raywenderlich.com 100
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
// when
sut.postAlert(alert: alert)
// then
wait(for: [exp], timeout: 1)
XCTAssertNotNil(postedAlert, "should have sent an alert")
XCTAssertEqual(
alert,
postedAlert,
"should have sent the original alert")
}
raywenderlich.com 101
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Note: While you should strive for a single assert per test, it’s OK to have more
than one if they both confirm the same truth. In this case, you’re trying to
validate that the notification contains the same Alert object that was posted.
Checking that the notification’s alert isn’t nil is part of that validation, as is
comparing it to the posted alert.
To get this test to pass, you have to add the alert object to the notification. Open
AlertCenter.swift change the let notification = ... line in
postAlert(alert:) to:
This adds the posted alert object to the notification so it can be observed in the test’s
closure. Now run testNotification_whenPosted_containsAlertObject() and
you should see another green test.
To start off on a positive note, encourage the user by giving them alerts at certain
milestones. When they reach 25%, 50%, and 75% of the goal, they should see an
encouragement alert, and at 100% a congratulations alert.
There are already some hard coded values for these in an Alert extension.
Before writing the next set of tests, create a new helper file. Under the Test
Extensions group add a new group, Alerts. Then add a new Swift file named
Notification+Tests.swift.
raywenderlich.com 102
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Add the following code to the new file, below the Foundation import:
extension Notification {
var alert: Alert? {
return userInfo?[AlertNotification.Keys.alert] as? Alert
}
}
This helper extension will make it easier to get the Alert object out of the
notification. You can be fairly confident this works because
testNotification_whenPosted_containsAlertObject() tested similarly built
userInfo. You could also go back and update that test to use this new helper. TDD
For The Win!
Now you can start writing tests to check that milestone notifications are generated.
// MARK: - Alerts
func testWhenStepsHit25Percent_milestoneNotificationGenerated()
{
// given
sut.goal = 400
let exp = expectation(
forNotification: AlertNotification.name,
object: nil) { notification -> Bool in
return notification.alert == Alert.milestone25Percent
}
// when
sut.steps = 100
// then
wait(for: [exp], timeout: 1)
}
In this test, the optional handler closure is used when setting up the expectation.
The closure takes the Notification as input and returns a Bool indicating whether
or not the expectation should be fulfilled. Here you only fulfill the expectation when
the alert is a .milestone25Percent. With the goal set to 400, setting steps to 100
should trigger that alert and fulfill your expectation.
To make this pass, you’ll need to update DataModel to trigger the 25 percent alert
when appropriate.
raywenderlich.com 103
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
First open DataModel.swift. Next, replace the steps var with the following:
Now changes in the step count will trigger updateForSteps(), which will post
necessary milestone alerts.
Now when steps hit 25% of the goal, you post Alert.milestone25Percent. Build
and test testWhenStepsHit25Percent_milestoneNotificationGenerated() and
it will pass when the alert is generated.
Previous tests let you know that because the alert is generated it will be shown to the
user. You’ll have to wait for the next chapter to see the actual step counter in action.
On your own, add three more tests: one each for 50%, 75%, and 100% of completion
with a goal of 400:
• 50%: Use Alert.milestone50Percent and steps = 200 for the when condition.
• 75%: Use Alert.milestone75Percent and steps = 300 for the when condition.
• 100%: Use Alert.goalComplete and steps = 400 for the when condition.
raywenderlich.com 104
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
func givenExpectationForNotification(
alert: Alert
) -> XCTestExpectation {
let exp = expectation(
forNotification: AlertNotification.name,
object: nil) { notification -> Bool in
return notification.alert == alert
}
return exp
}
This helper method creates an expectation that waits for a notification containing
the passed alert. Next, refactor
testWhenStepsHit25Percent_milestoneNotificationGenerated() to use this
helper. Replace the expectation definition with the following:
let exp =
givenExpectationForNotification(alert: .milestone25Percent)
Now you can write a test that checks that all of these alerts are generated, each in
order.
func testWhenGoalReached_allMilestoneNotificationsSent() {
// given
sut.goal = 400
let expectations = [
givenExpectationForNotification(alert: .milestone25Percent),
givenExpectationForNotification(alert: .milestone50Percent),
givenExpectationForNotification(alert: .milestone75Percent),
givenExpectationForNotification(alert: .goalComplete)
]
// when
sut.steps = 400
// then
wait(for: expectations, timeout: 1, enforceOrder: true)
}
raywenderlich.com 105
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
So far you’ve been using wait(for:timeout:) with an array of just one expectation.
Here you can see why accepting an array is useful. It allows you to provide multiple
expectations and wait for all of them to be fulfilled.
Also shown here is the optional enforceOrder parameter. This makes sure not only
that all the expectations are fulfilled but that those fulfillments happen in the order
specified by the input array.
The ordering check allows for sophisticated tests. For example, you could use this
when writing a test for a multi-step process like image filtering or a network login
that requires multiple API calls (like OAuth or SAML). These tests not only ensure all
the steps happen in the necessary order in production code, but also validate that
your test code isn’t going through a different flow than expected.
Refining Requirements
The previous set of unit tests have one flaw when it comes to validating the app.
They test a snapshot of the app’s state and do not consider that the app is dynamic.
When in progress, the app will continually update the step count, and it’s important
to not spam the user at each step, but instead only alert them when a threshold is
first crossed. In addition, the user has the option to clear the alerts, so the guard
added to postAlert(alert:) won’t prevent a repeat alert if an earlier alert was
cleared by the user.
Always testing first, open AlertCenterTests.swift and add this to the bottom of
AlertCenterTests:
// when
sut.clear(alert: alert)
// then
XCTAssertEqual(sut.alertCount, 0)
}
This tests that if an alert is added and then cleared, there are no alerts left in the
AlertCenter.
raywenderlich.com 106
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
To pass the test, add the following method to the “Alert Handling” section of
AlertCenter.swift:
This removes the passed alert from the alertQueue. Run your tests and they
should all pass again.
func testWhenStepsIncreased_onlyOneMilestoneNotificationSent() {
// given
sut.goal = 10
let expectations = [
givenExpectationForNotification(alert: .milestone25Percent),
givenExpectationForNotification(alert: .milestone50Percent),
givenExpectationForNotification(alert: .milestone75Percent),
givenExpectationForNotification(alert: .goalComplete)
]
// when
for step in 1...10 {
self.sut.steps = step
sleep(1)
}
// then
wait(for: expectations, timeout: 20, enforceOrder: true)
AlertCenter.instance.notificationCenter
.removeObserver(alertObserver)
}
raywenderlich.com 107
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
• A separate observer watches for alerts and clears them from the AlertCenter.
This ensures that repeated notifications don’t get ignored because they haven’t
yet been dismissed by the user.
• The when section increments steps to generate the alerts by crossing a series of
the milestones individually. Using sleep or equivalent in tests should only be
done sparingly as this drastically increases the test time. It’s necessary here to give
time for the notifications to post and be cleared.
• The then section uses wait to test that the expectations are fulfilled as expected.
At the end of the test, you remove alertObserver to prevent it from impacting
other tests.
Right now the test will pass, which violates the TDD step of writing a failing test
first. That’s because right now it’s not enforcing that there should be a single
notification per milestone. That has to be done in the expectation itself.
func givenExpectationForNotification(
alert: Alert) -> XCTestExpectation {
Now the test will fail as a single alert is repeated for multiple steps. To get the test to
pass, DataModel has to be modified to keep track of sent alerts.
raywenderlich.com 108
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Open DataModel.swift and add the following to the top of the class:
// MARK: - Alerts
var sentAlerts: [Alert] = []
func updateForSteps() {
checkThreshold(percent: 0.25, alert: .milestone25Percent)
checkThreshold(percent: 0.50, alert: .milestone50Percent)
checkThreshold(percent: 0.75, alert: .milestone75Percent)
checkThreshold(percent: 1.00, alert: .goalComplete)
}
This cleans up the code a little bit and now checks not just that the threshold was
crossed but also that an alert wasn’t already sent. This way if a user crosses a
threshold and dismisses the alert, they won’t see that same alert again.
sentAlerts.removeAll()
This ensures that a restart clears out your alerts. Build and run, and the tests should
all pass!
These look for their eponymous conditions: KVO expectations observe changes to a
keyPath and predicate expectations wait for their predicate to be true.
raywenderlich.com 109
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
There’s one place where you’ve already used KVO for an expectation, and that’s with
the ButtonObserver found in StepCountControllerTests.swift. You can replace
that helper class completely using a KVO based XCTestExpectation. Rather than
using the more fully featured XCTKVOExpectation, you’ll use a special
XCTestExpectation initializer that provides KVO capabilities.
Build and test and the tests will pass as if nothing happened!
raywenderlich.com 110
iOS Test-Driven Development by Tutorials Chapter 5: Test Expectations
Challenge
This tutorial only scratched the surface of testing asynchronous functions. Here are
some things to add to the app with test coverage:
• Add AlertCenter tests addressing edge cases for clearing alerts such as clearing
an empty queue and clearing the same alert multiple times.
• Create tests for AlertViewController. Test that the text used for alertLabel’s
updates to reflect a new alert, and that it uses the proper color for the given
severity. This requires adding the ability to get the first alert out of the
AlertCenter, and updating tests around that as well.
• It wouldn’t be fair to the user if they didn’t get a warning of Nessie’s progress. Add
tests in DataModelTests for Nessie catching up to 50% and then to 90%.
Key points
• Use XCTestExpectation and its subclasses to make tests wait for asynchronous
process completion.
• Test expectations help test properties of the asynchronicity, like order and number
of occurrences, but XCTAssert functions should still be used to test state.
expect(alerts).toEventually(contain(alert1, alert2))
raywenderlich.com 111
6 Chapter 6: Dependency
Injection & Mocks
By Michael Katz
So far, you’ve built and tested a fair amount of the app. There is one gigantic hole
that you may have noticed… this “step-counting app” doesn’t yet count any steps!
In this chapter, you’ll learn how to use mocks to test code that depends on system or
external services without needing to call services — the services may not be
available, usable or reliable. These techniques allow you to test error conditions, like
a failed save, and to isolate logic from SDKs, like Core Motion and HealthKit.
Don’t have an iPhone handy? Don’t worry; you’ll dip into functional testing using
the Simulator to handle mock data.
raywenderlich.com 112
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
The way to isolate the SUT and circumvent these issues is to use test doubles:
objects that stands in for real code. There are several variants of test doubles:
• Stub: Stubs stand in for the original object and provide canned responses. These
are often used to implement one method of a protocol and have empty or nil
returning implementations for the others.
• Fake: Fakes often have logic, but instead of providing real or production data, they
provide test data. For example, a fake network manager might read/write from
local JSON files instead of connecting over a network.
• Mock: Mocks are used to verify behavior, that is they should have an expectation
that a certain method of the mock gets called or that its state was set to an
expected value. Mocks are generally expected to provide test values or behaviors.
Understanding CMPedometer
There’s a few ways of gathering activity data from the user, but the CMPedometer API
in Core Motion is by far the easiest.
1. Check that the pedometer is available and the user has granted permission.
3. Gather step and distance updates until the user pauses, completes the goal or
loses to Nessie.
raywenderlich.com 113
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
Here’s the thing… you’re using TDD so using a CMPedometer is tricky, even if you
have the host app run on a physical device. CMPedometer depends on the device
state, which is too variable for consistent unit tests.
Give it a try. First, navigate to and open the starter project. Next, open
PedometerTests.swift which has been added to the Data Model test case group.
Next add the following below tearDownWithError():
func testCMPedometer_whenQueries_loadsHistoricalData() {
// given
var error: Error?
var data: CMPedometerData?
let exp = expectation(description: "pedometer query returns")
// when
let now = Date()
let then = now.addingTimeInterval(-1000)
sut.queryPedometerData(
from: then,
to: now) { pedometerData, pedometerError in
error = pedometerError
data = pedometerData
exp.fulfill()
}
// then
wait(for: [exp], timeout: 1)
XCTAssertNil(error)
XCTAssertNotNil(data)
if let steps = data?.numberOfSteps {
XCTAssertGreaterThan(steps.intValue, 0)
} else {
XCTFail("no step data")
}
}
raywenderlich.com 114
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
Although this test compiles, it crashes on launch. Apple requires permission to use
Core Motion. Strike #1 against using a real CMPedometer object in the tests. In order
to ask for permission, a usage description is required. Open the app’s Info.plist.
Add a new row, use the key Privacy - Motion Usage Description and set the value
to “Pedometer access is required to gather step and distance information.”
Build and test, and it may fail depending on if you run the app on device or
Simulator, and if you’ve accepted the permission pop-up or not. The unpredictability
caused by lack of control over CMPedometer makes this a pretty poor test. This
sounds like a job for a mock!
Mocking
Restating the problem
Open AppModelTests.swift, and add the following test below // MARK: -
Pedometer:
func testAppModel_whenStarted_startsPedometer() {
//given
givenGoalSet()
let predicate = NSPredicate { model, _ -> Bool in
(model as? AppModel)?.pedometerStarted ?? false
}
let exp = expectation(
for: predicate,
evaluatedWith: sut,
handler: nil)
raywenderlich.com 115
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
// when
try! sut.start()
// then
wait(for: [exp], timeout: 1)
XCTAssertTrue(sut.pedometerStarted)
}
This test intends to verify that starting the app model will also start the pedometer.
If you read the previous chapter, you’ll recognize the elusive
XCTNSPredicateExpectation used to wait for the status change.
This test is subtly different from the previous one: It doesn’t test the pedometer
object directly. Instead, the test verifies the behavior of the SUT by measuring the
effect on the pedometer (as exposed through pedometerStarted).
To get this compiling, you’ll need to modify AppModel. Open AppModel.swift, add
the following two vars:
startPedometer()
// MARK: - Pedometer
extension AppModel {
func startPedometer() {
pedometer.startEventUpdates { _, error in
if error == nil {
self.pedometerStarted = true
}
}
}
}
raywenderlich.com 116
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
This uses the pedometer event handler callback to determine if the pedometer has
started. With a CMPedometer, you can’t write a simple test to check if it’s started as
that state isn’t exposed in the API. However, this callback will be called soon after
starting event updates. If step counting is available, then there won’t be an error, and
you’ll know it’s started.
Build and test, and this will pass if you run it on a device and have granted
permission to motion data. If you run on Simulator or device without this permission
granted, it’ll fail.
To do that, you’ll make use of two classic patterns: Facade and Bridge.
First, create a new group in the app, named Pedometer. In that group, create a new
Swift file, Pedometer.swift.
protocol Pedometer {
func start()
}
This is the start of the Bridge protocol that will allow you to substitute any
pedometer implementation for the real one.
import CoreMotion
This declares conformance to the new protocol and migrates the start behavior you
implemented in startPedometer. It doesn’t do anything much yet, but will soon.
raywenderlich.com 117
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
func startPedometer() {
pedometer.start()
}
The optional init parameter is where you’ll be able to replace the default
CMPedometer with the mock object. The reduction of code in startPedometer is the
advantage of using a Facade: You can hide the specific complexity of the
CMPedometer behind a simplified interface.
import CoreMotion
@testable import FitNess
func start() {
started = true
}
raywenderlich.com 118
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
Now, go back to AppModelTests.swift and add the following property up top and
update setUpWithError:
This creates a mock pedometer and uses it when creating the sut.
func testAppModel_whenStarted_startsPedometer() {
//given
givenGoalSet()
// when
try! sut.start()
// then
XCTAssertTrue(mockPedometer.started)
}
This simplified test now tests the side effect of start on the mock object. In addition
to being a simpler test, it’s guaranteed to pass regardless of the device state. Build
and test, and you’ll see that it passes.
raywenderlich.com 119
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
func testPedometerNotAvailable_whenStarted_doesNotStart() {
// given
givenGoalSet()
mockPedometer.pedometerAvailable = false
// when
try! sut.start()
// then
XCTAssertEqual(sut.appState, .notStarted)
}
This simple check just makes sure the app state doesn’t proceed to inProgress
when the pedometer isn’t available.
Next, open Pedometer.swift and add the following to the protocol definition:
And for the real implementation — to be used by your app code — open
CMPedometer+Pedometer.swift and add the following:
You can see that the “real” implementation is a lot more interesting, but not
controllable.
raywenderlich.com 120
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
Open AppModel.swift, find start() and add the following before appState
= .inProgress:
Unlike the other guard statement, this condition doesn’t raise an exception; instead,
it uses the new AlertCenter way of communicating with the user. The resulting
error handling, where start() is called, will be a little different, and refactoring it is
out of scope of this chapter.
Build and test, and it will pass now, as the new guard prevents the appState from
progressing to inProgress when the pedometer isn’t available. Note that, if you run
the entire suite, some other tests will now fail — you’ll circle back to those in a
moment.
func testPedometerNotAvailable_whenStarted_generatesAlert() {
// given
givenGoalSet()
mockPedometer.pedometerAvailable = false
let exp = expectation(
forNotification: AlertNotification.name,
object: nil,
handler: alertHandler(.noPedometer))
// when
try! sut.start()
// then
wait(for: [exp], timeout: 1)
}
This sets pedometerAvailable to false and waits for the corresponding alert. The
test will pass out of the gate due to the code previously added to AppModel for
displaying this alert.
raywenderlich.com 121
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
Injecting dependencies
Re-run all the tests, and you will see failures in StepCountControllerTests. That’s
because this new pedometerAvailable guard in AppModel is still dependent on the
production CMPedometer in other tests.
One way to fix that this to make the pedometer into a variable so it can be modified
for testing.
AppModel.instance.pedometer = MockPedometer()
This sets the mock pedometer when the root view controller is fetched for tests,
which means any view controller test will get a mock pedometer.
Build and run all the tests, and they will now pass.
Open AppModelTests.swift and add the following to the end of the class:
func testPedometerNotAuthorized_whenStarted_doesNotStart() {
// given
givenGoalSet()
mockPedometer.permissionDeclined = true
// when
try! sut.start()
// then
XCTAssertEqual(sut.appState, .notStarted)
}
func testPedometerNotAuthorized_whenStarted_generatesAlert() {
// given
givenGoalSet()
mockPedometer.permissionDeclined = true
let exp = expectation(
raywenderlich.com 122
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
forNotification: AlertNotification.name,
object: nil,
handler: alertHandler(.notAuthorized))
// when
try! sut.start()
// then
wait(for: [exp], timeout: 1)
}
These test handling of a permissionDeclined error. The first test checks that the
app state stays in .notStarted and the second checks for a user alert.
First, open Pedometer.swift, and add the following to the protocol definition:
raywenderlich.com 123
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
Mocking a callback
There is another important error situation to handle. This occurs the very first time
the user taps Start on a pedometer-capable device. In that case, the start flow goes
ahead, but the user can decline in the permission pop-up. If the user declines, there
is an error in the eventUpdates callback.
Let’s test that condition. Open AppModelTests.swift and add the following to the
end of the class definition:
func testAppModel_whenDeniedAuthAfterStart_generatesAlert() {
// given
givenGoalSet()
mockPedometer.error = MockPedometer.notAuthorizedError
let exp = expectation(
forNotification: AlertNotification.name,
object: nil,
handler: alertHandler(.notAuthorized))
// when
try! sut.start()
// then
wait(for: [exp], timeout: 1)
}
Unlike the previous tests, this doesn’t explicitly set permissionDeclined, so the
model can attempt to start the pedometer. Instead, the test relies on passing an error
to the mock to generate the alert while the pedometer is starting.
The next step is to build a way to get that error back to the SUT.
raywenderlich.com 124
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
func startPedometer() {
pedometer.start { error in
if let error = error {
let alert = error.is(CMErrorMotionActivityNotAuthorized)
? .notAuthorized
: Alert(error.localizedDescription)
AlertCenter.instance.postAlert(alert: alert)
}
}
}
The closure checks if an error was returned when starting the pedometer. If it’s a
CMErrorMotionActivityNotAuthorized, then it posts a notAuthorized alert;
otherwise, a generic alert with the error’s message is posted.
This takes care of the production code, but you also need to update the
MockPedometer.
This update will call the completion, passing its error property. For convenience,
the static notAuthorizedError creates an error object that matches what is
returned by Core Motion when unauthorized. This is what you used in
testAppModel_whenDeniedAuthAfterStart_generatesAlert.
raywenderlich.com 125
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
protocol PedometerData {
var steps: Int { get }
var distanceTravelled: Double { get }
}
This adds an abstraction around CMPedometerData so that the step and distance data
can be mocked. Do that by creating a new .swift file in the Mocks group of the test
target: MockData.swift and replacing its contents with the following:
With this in place, open AppModelTests.swift and add the following test at the end
of the class definition:
func testModel_whenPedometerUpdates_updatesDataModel() {
// given
givenInProgress()
let data = MockData(steps: 100, distanceTravelled: 10)
// when
mockPedometer.sendData(data)
// then
XCTAssertEqual(sut.dataModel.steps, 100)
XCTAssertEqual(sut.dataModel.distance, 10)
}
The test verifies that the supplied data is applied to the data model. This requires an
update to MockPedometer to pass the data. First, think about how that data will
eventually be passed to AppModel.
raywenderlich.com 126
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
func start(
dataUpdates: @escaping (PedometerData?, Error?) -> Void,
eventUpdates: @escaping (Error?) -> Void)
The dataUpdates block will provide a means of returning PedometerData from the
pedometer. eventUpdates will return events, as the old completion block did.
func start(
dataUpdates: @escaping (PedometerData?, Error?) -> Void,
eventUpdates: @escaping (Error?) -> Void
) {
started = true
updateBlock = eventUpdates
dataBlock = dataUpdates
DispatchQueue.global(qos: .default).async {
self.updateBlock?(self.error)
}
}
The two blocks are saved for later use, but the updateBlock is still called as part of
this method, as completion was previously. You won’t have to update any previous
tests for this one, as the behavior is the same. Also added is sendData(_:), which is
used by the test to call the dataBlock with the mock data.
raywenderlich.com 127
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
You also need to update the CMPedometer extension for this new logic. Open
CMPedometer+Pedometer.swift and change start(completion:) to the
following:
func start(
dataUpdates: @escaping (PedometerData?, Error?) -> Void,
eventUpdates: @escaping (Error?) -> Void) {
startEventUpdates { _, error in
eventUpdates(error)
}
This preserves the previous startEventUpdates behavior, plus adds a new call to
startUpdates to forward the data updates.
You also need to wrap CMPedometerData with the new PedometerData protocol. Add
the following extension to bottom of the file:
func startPedometer() {
pedometer.start(
dataUpdates: handleData,
eventUpdates: handleEvents)
}
raywenderlich.com 128
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
This moves the previous event handling to its own method and creates a new one to
update dataModel when there is new data. You’ll notice that data update errors are
not handled here. That’s left as a Challenge for you after this chapter is complete!
Enter the fake pedometer: You’ve already done the work to abstract the app from a
real CMPedometer, so it’s straightforward to build a fake pedometer that speeds up
time or makes up movement.
import Foundation
raywenderlich.com 129
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
func start(
dataUpdates: @escaping (PedometerData?, Error?) -> Void,
eventUpdates: @escaping (Error?) -> Void
) {
updateBlock = eventUpdates
dataBlock = dataUpdates
timer = Timer(
timeInterval: 1,
repeats: true
) { _ in
self.distance += 1
print("updated distance: \(self.distance)")
let data = Data(
steps: 10,
distanceTravelled: self.distance)
self.dataBlock?(data, nil)
}
RunLoop.main.add(timer!, forMode: RunLoop.Mode.default)
updateBlock?(nil)
}
func stop() {
timer?.invalidate()
updateBlock?(nil)
updateBlock = nil
dataBlock = nil
}
}
This giant block of code implements the Pedometer and PedometerData protocols. It
sets up a Timer object that, once start is called, adds ten steps every second. Each
time it updates, it calls dataBlock with the new data.
You’ve also added a stop method that stops the timer and cleans up. This will be
used when you add the ability to pause the pedometer by tapping the Pause button.
To use the simulated pedometer in the app, open AppModel.swift, and add the
following static var:
raywenderlich.com 130
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
Now build and run in Simulator. Tap the settings cog in the lower-right and enter a
goal of 100 steps.
In order to test that it will accurately reflect the user’s state, you can use a partial
mock. By partially mocking the chase view, you can add a little extra test
functionality without interrupting its main logic. This is instead of a full mock,
which replaces all functionality.
raywenderlich.com 131
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
mockChaseView = ChaseViewPartialMock()
sut.chaseView = mockChaseView
Finally, add a test that verifies that the view gets updated:
func testChaseView_whenDataSent_isUpdated() {
// given
givenInProgress()
// when
let data = MockData(steps: 500, distanceTravelled: 10)
(AppModel.instance.pedometer as! MockPedometer).sendData(data)
// then
XCTAssertTrue(mockChaseView.updateStateCalled)
XCTAssertEqual(mockChaseView.lastRunner, 0.5)
}
raywenderlich.com 132
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
This uses the mocked pedometer to send data and verifies the state on the partial
mock chase view. The value for Nessie’s position isn’t checked since the code for
Nessie isn’t part of the project yet.
Build and test, and you’ll see neither assert passes, because the chase view isn’t yet
being updated.
NotificationCenter.default
.addObserver(
forName: DataModel.UpdateNotification,
object: nil,
queue: nil) { _ in
self.updateUI()
}
This listens for data model updates and calls updateUI when there is a data update.
This gathers the distance of the user and Nessie from the data model, computes a
percent completion, and presents it to the chase view so that the avatars can be
placed accordingly.
raywenderlich.com 133
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
Build and test to see the test pass! Build and run to see the view in action:
Time dependencies
The final major piece missing is Nessie. She should be chasing after the user while
the app is in progress. Her progress will be measured at a constant velocity.
Measuring something over time? Sounds like a Timer is the answer.
Timers are notoriously hard to test: They require using expectations along with
having a potentially long wait. There are few common solutions:
1. During tests, use a very short timer (e.g., one millisecond instead of one second).
2. Swap the timer for a mock that executes the callback immediately.
3. Use the callback directly, and save the timing for app or user-acceptance testing.
Any of these are reasonable solutions, but you’re going to go with option #3. In
NessieTests.swift, add this test:
func testNessie_whenUpdated_incrementsDistance() {
// when
sut.incrementDistance()
// then
XCTAssertEqual(sut.distance, sut.velocity)
}
raywenderlich.com 134
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
This calls incrementDistance directly, just as the Timer callback does in the Nessie
class. It asserts that after distance increments it is equal to the velocity.
The test doesn’t yet pass, because incrementDistance is stubbed out. Open
Nessie.swift, and add the following line to incrementDistance():
distance += velocity
Challenge
You’ve reached the end of the chapter, but not the end of the app. You should be able
to take the testing tools you’ve learned and finish the app. Your challenge is to add
the following tests and features to complete the app:
• Complete the Pause functionality to be able to pause and resume the pedometer.
• Wire up Nessie to app state so it can start, pause and reset appropriately. You’ll
also have to give the user a little bit of a head start since both the user and Nessie
will start at 0.
• Complete the handling of data errors from the pedometer (use the Alert Center).
Key points
• Test doubles let you test code in isolation from other systems, especially those
that are part of system SDKs, rely on networking or timers.
• Mocks let you swap in a test implementation of a class, and partial mocks let you
just substitute part of a class.
raywenderlich.com 135
iOS Test-Driven Development by Tutorials Chapter 6: Dependency Injection & Mocks
This chapter covered using mocks to separate the test subjects from external code
and events. This just scratches the surface of what’s possible. The next section will
be all about using external services like network requests.
If you want to learn more about the use and history of doubles, read this excellent
Martin Fowler article, “Mocks Aren’t Stubs”: https://martinfowler.com/articles/
mocksArentStubs.html.
raywenderlich.com 136
Section III: TDD With
Networking
You’ll get hands-on experience creating a puppy-buying app that interacts with a
backend service. You’ll learn how to do TDD for RESTful networking, using network
clients and downloading images throughout this section.
raywenderlich.com 137
7 Chapter 7: Introducing
Dog Patch
By Joshua Greene
You’ve learned the basics of TDD, and you should be starting to feel comfortable
with it. However, you haven’t learned how to do TDD for a very critical part of most
apps: networking!
Over the next several chapters, you’ll learn the ins-and-outs of writing networking
code in a test-driven fashion. The goal of this chapter is to introduce you to this
section’s sample project and highlight what work remains to be completed.
raywenderlich.com 138
iOS Test-Driven Development by Tutorials Chapter 7: Introducing Dog Patch
Getting started
You’ll complete a puppy-adoption app called Dog Patch throughout this section.
This app connects dog lovers with kind, professional breeders to find the puppy of
their dreams.
Networking client
In Chapter 8, you’ll learn how to start TDD for RESTful networking. You’ll first
explore the starter project and find that ListingsViewController always shows an
error:
This is because the app isn’t actually doing any networking yet! You’ll create a
networking client and make a GET request to fetch Dog models from a remote server
as the first steps to fix this.
raywenderlich.com 139
iOS Test-Driven Development by Tutorials Chapter 7: Introducing Dog Patch
Image client
In chapter 10, you’ll create an image client and update ListingsViewController to
use it to display images:
raywenderlich.com 140
iOS Test-Driven Development by Tutorials Chapter 7: Introducing Dog Patch
Especially for networking-heavy apps like Dog Patch, it makes sense to separate
networking into its own type. If you didn’t do this, where would the networking code
go, after all? In pure MVC-architecture apps, developers tend to lump networking
into each view controller.
The problem here is that a lot of networking code is interrelated. For example, URL
and content serialization, authentication headers and more require exactly the same
logic. If networking code is directly in each view controller, you quickly wind up with
a lot of duplication. This quickly becomes an unmanageable mess as a result.
Fortunately, MVC-N allows you to avoid this issue altogether by putting your
networking code into a networking client. This client is then passed into whatever
view controllers need it, and this effectively eliminates the duplication across view
controllers.
It’s OK if you haven’t heard of MVC-N before. You’ll learn all about it over the course
of the next few chapters!
raywenderlich.com 141
8 Chapter 8: RESTful
Networking
By Joshua Greene
In this chapter, you’ll learn how to TDD a RESTful networking client. Specifically,
you’ll:
Getting started
Navigate to the starter directory for this chapter. You’ll find it has a DogPatch
subdirectory containing DogPatch.xcodeproj. Open this project file in Xcode and
take a look.
You’ll see a few files waiting for you. The important ones for this chapter are:
• Networking is an empty folder for now. You’ll add the networking client and
related types here.
raywenderlich.com 142
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Build and run the app, and the following error-message screen will greet you:
If you pull down to refresh, the activity indicator will animate but won’t ever finish.
Your job is now clear! You need to write the logic to make networking calls. While
you could make a one-off network call directly within ListingsViewController,
this view controller would quickly become very large.
A better option is to create a separate networking client that handles all of the
networking logic, which happens to be the focus of this chapter!
raywenderlich.com 143
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Here, you create a new test class for DogPatchClientTests with a single property
for sut of type DogPatchClient. However, since you haven’t created
DogPatchClient, this code doesn’t compile. Compiler errors count as test failures,
so you can now write production code.
import Foundation
class DogPatchClient {
Here, you declare a new class for DogPatchClient and thereby resolve the compiler
error. There’s nothing to refactor, so you simply move on to your first test.
Open DogPatchClientTests.swift and add the following below the declaration for
sut, again ignoring the compiler error:
func test_init_sets_baseURL() {
// given
let baseURL = URL(string: "https://example.com/api/v1/")!
// when
sut = DogPatchClient(baseURL: baseURL)
}
Ultimately, you want to test that the baseURL passed into the initializer matches
sut.baseURL. However, you haven’t created this initializer, so this doesn’t compile.
raywenderlich.com 144
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
init(baseURL: URL) {
You declare baseURL, set it to an arbitrary value for now and then create
init(baseURL:), which is enough to get the test to compile. But you haven’t
asserted anything yet.
// then
XCTAssertEqual(sut.baseURL, baseURL)
You assert sut.baseURL equals baseURL passed to the initializer. Build and run the
unit tests. As expected, this test fails.
To get this to pass, replace the line for let baseURL = within DogPatchClient with:
self.baseURL = baseURL
You set baseURL from the passed-in argument into the initializer. Build and run your
tests, and this now passes. There isn’t anything to refactor, so simply continue.
You also need a property for URLSession, which you’ll use to make the networking
calls.
Add the following test right after the previous one, again ignoring the compiler
error:
func test_init_sets_session() {
// given
let baseURL = URL(string: "https://example.com/api/v1/")!
let session = URLSession.shared
// when
sut = DogPatchClient(baseURL: baseURL, session: session)
}
The purpose of this test is to expand the initializer to accept a session argument.
raywenderlich.com 145
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Your tests now compile again, but you haven’t added an assertion to
test_init_sets_session(). Add the following to the end of that test method:
// then
XCTAssertEqual(sut.session, session)
Build and run your tests. As expected, this test fails. To make it pass, change the
property declaration for session on DogPatchClient to:
self.session = session
Build and run the tests, and see they both now pass. This time, you do have some
refactoring to do.
raywenderlich.com 146
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
You set each of the properties within setUp and nil each of the properties within
tearDown, which helps you eliminate the duplication in your tests.
XCTAssertEqual(sut.baseURL, baseURL)
XCTAssertEqual(sut.session, session)
Build and run the tests. You’ll see they all pass.
OK, maybe it’s not that exciting… However, these properties are essential to making
networking calls, and now you can write that code!
raywenderlich.com 147
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
1. Mocking URLSesssion.
Mocking URLSession
To keep your tests fast and repeatable, don’t make real networking calls in them.
Instead of using a real URLSession, you’ll create a MockURLSession that will let you
verify behavior but won’t make network calls. You’ll pass this into the initializer for
DogPatchClient and use it like you’d use a real URLSession.
OK, you’ve got the theory down! Time to write the code.
import Foundation
func makeDataTask(
raywenderlich.com 148
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
But wait, aren’t you missing unit tests? Nope! Protocols don’t have concrete
behavior, so there’s nothing to test.
raywenderlich.com 149
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
func test_URLSessionTask_conformsTo_URLSessionTaskProtocol() {
// given
let url = URL(string: "https://example.com")!
// when
let task = session.dataTask(with: url)
// then
XCTAssertTrue((task as AnyObject) is URLSessionTaskProtocol)
}
}
You create a new test case for URLSessionProtocolTests with a single property,
session, and a single unit test that verifies URLSessionTask conforms to
URLSessionTaskProtocol. Since URLSessionTask doesn’t have any non-deprecated
initializers, you can’t create it directly. Instead, you call session.dataTask(with:)
to create one.
Build and run the tests, and you’ll see this test fails as intended. To make it pass, add
the following to the end of URLSessionProtocol.swift:
Build and rerun the tests, and see them pass. There’s nothing to refactor, so just
continue.
Technical note: If you’re familiar with the ins and outs of URLSession, you
know that dataTask(with:) returns a URLSessionDataTask, and
URLSessionTask is its superclass.
How could you have figured this out by yourself? By using TDD and trial-and-
error! However, this process isn’t essential to the core concepts in this chapter,
so I omitted it for brevity’s sake.
raywenderlich.com 150
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
func test_URLSession_conformsTo_URLSessionProtocol() {
XCTAssertTrue((session as AnyObject) is URLSessionProtocol)
}
func makeDataTask(
with url: URL,
completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void)
-> URLSessionTaskProtocol {
Next, you need to verify makeDataTask calls dataTask with the passed-in url. Add
this test after the last one:
func test_URLSession_makeDataTask_createsTaskWithPassedInURL() {
// given
let url = URL(string: "https://example.com")!
// when
let task = session.makeDataTask(
with: url,
completionHandler: { _, _, _ in })
as! URLSessionTask
// then
XCTAssertEqual(task.originalRequest?.url, url)
}
You assert the passed-in url matches the url on task.originalRequest. Build and
run the tests to confirm this indeed fails.
raywenderlich.com 151
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Here, you update the return statement to use the passed-in url. Build and run the
tests. The last one will now pass.
Now there’s duplicated code in URLSessionProtocolTests: You create the same url
in two different tests. To fix this, declare this new property, right below session:
In setUp(), add this line right after setting the session, to create the url:
Then in tearDown(), add this right after setting session, to nil out the url:
url = nil
func
test_URLSession_makeDataTask_createsTaskWithPassedInCompletion()
{
// given
let expectation =
expectation(description: "Completion should be called")
// when
let task = session.makeDataTask(
with: url,
completionHandler: { _, _, _ in expectation.fulfill() })
as! URLSessionTask
task.cancel()
// then
waitForExpectations(timeout: 0.2, handler: nil)
}
raywenderlich.com 152
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
This test verifies the completionHandler is set correctly. As such, you create an
expectation and fulfill it when the completionHandler is called. By calling
task.cancel(), you cause the completionHandler to execute. You verify the
expectation is fulfilled via waitForExpecations.
Build and run the tests, and you’ll see this test fails. To make it pass, update the
contents of makeDataTask with:
You update the return statement to use the passed-in completionHandler. Build
and run the tests, and see them all pass.
// 1
class MockURLSession: URLSessionProtocol {
func makeDataTask(
with url: URL,
completionHandler: @escaping (Data?, URLResponse?, Error?)
-> Void)
-> URLSessionTaskProtocol {
return MockURLSessionTask(
completionHandler: completionHandler,
url: url)
}
}
// 2
class MockURLSessionTask: URLSessionTaskProtocol {
init(completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void,
url: URL) {
self.completionHandler = completionHandler
self.url = url
raywenderlich.com 153
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
// 3
func resume() {
}
}
This code changes the mockSession variables type to MockURLSession, but it breaks
the tests in a few places. First, you need to update where you set this property within
setUp().
mockSession = MockURLSession()
Next, within DogPatchClient.swift, you need to update the type of session. First,
replace the let session line with:
raywenderlich.com 154
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
You use the identity operator === to assert that sut.session and mockSession are
the same instance.
These changes resolve all of the compiler errors. Build and run the tests and verify
they all pass.
Still in DogPatchClientTests, add the following test after the last one, ignoring the
compiler error:
func test_getDogs_callsExpectedURL() {
// given
let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!
// when
let mockTask = sut.getDogs() { _, _ in }
as! MockURLSessionTask
}
This test verifies getDogs calls a specific URL. However, it doesn’t compile because
you haven’t declared getDogs yet in the production code. Add this after the other
methods in DogPatchClient:
// then
XCTAssertEqual(mockTask.url, getDogsURL)
raywenderlich.com 155
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Build and run the tests, and you’ll see this fails. You can now write the code to call
the correct URL.
Build and run the tests, and see them all pass.
URLSession doesn’t start a networking task after it’s created. Instead, you must call
resume on the task. You need a test that verifies that you called this.
You declare a new property for calledResume, which defaults to false, and set it to
true within resume(). Now, you can write a test that uses this.
Open DogPatchClientTests.swift, and add the following after the last test:
func test_getDogs_callsResumeOnTask() {
// when
let mockTask = sut.getDogs() { _, _ in }
as! MockURLSessionTask
// then
XCTAssertTrue(mockTask.calledResume)
}
Build and run, and you’ll see this test fails as expected. To make it pass, in
DogPatchClientTests.swift, replace the return line within getDogs(completion:)
on DogPatchClient with:
Build and run your tests. You’ll see they pass now. There’s nothing to refactor so you
can continue!
raywenderlich.com 156
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
1. The server returns an HTTP status code besides 200. This endpoint always
returns 200 on success. If another status code returns, the request failed.
2. The request may never reach the server, may timeout or another error condition
may happen at the networking layer. In this case, the error will be set.
Start by writing a test that checks for the first scenario. Add the following test after
the last one:
func test_getDogs_givenResponseStatusCode500_callsCompletion() {
// given
let getDogsURL = URL(string: "dogs", relativeTo: baseURL)!
let response = HTTPURLResponse(url: getDogsURL,
statusCode: 500,
httpVersion: nil,
headerFields: nil)
// when
var calledCompletion = false
var receivedDogs: [Dog]? = nil
var receivedError: Error? = nil
// then
XCTAssertTrue(calledCompletion)
XCTAssertNil(receivedDogs)
XCTAssertNil(receivedError)
}
raywenderlich.com 157
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
• Within given, you create response using getDogsURL and an HTTP status of 500
indicating a failure.
• Within when, you create variables to hold whether the completion closure was
called and the return values. Then you call the completionHandler on the
mockTask.
• Within then, you assert the completion handler was called, and the received
values for dogs and error are nil.
Build and run your tests. As expected, this test will fail because the completion
handler isn’t called.
To fix this, add the following inside the closure for session.dataTask(with: url)
within getDogs on DogPatchClient:
This guard statement checks that the status code is the expected 200 result and calls
the completion handler if it isn’t.
Build and run your tests. Your test now passes. Do you see anything to refactor? Yep,
getDogsURL is precisely the same in two tests.
To remove this duplication, add the following computed property right after the sut
declaration in DogPatchClientTests:
raywenderlich.com 158
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Next, you’ll deal with the other error scenario, when an error returns. Add the
following test case to check for this:
// when
var calledCompletion = false
var receivedDogs: [Dog]? = nil
var receivedError: Error? = nil
// then
XCTAssertTrue(calledCompletion)
XCTAssertNil(receivedDogs)
• Within when, you set variables to check whether the completion was called and
what values were received. Then, you call the completionHandler on the
mockTask with the response and expectedError from before.
• Within then, you assert the completion is called, the received dogs are nil and the
error matches what you expect.
raywenderlich.com 159
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Build and run your tests. The assertions for calledCompletion and unwrapping
receivedError fail, which makes sense since you haven’t written this code yet.
You can also temporarily change the assignment of receivedDogs to an empty array
to prove that XCTAssertNil(receivedDogs) fails, but be sure to set this property
back to nil before continuing.
To make all the asserts pass, replace the entire guard line within getDogs on
DogPatchClient with:
Build and run your tests. They’ll all pass now. However, now there’s a lot of code
duplication between this test and the previous one.
To fix this, pull out a helper method for the common code. Add the following method
right after tearDown, since it’s called from several tests:
func whenGetDogs(
data: Data? = nil,
statusCode: Int = 200,
error: Error? = nil) ->
(calledCompletion: Bool, dogs: [Dog]?, error: Error?) {
raywenderlich.com 160
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
• This method accepts inputs for data, statusCode and error. As a convenience,
you also provide appropriate default values for each. It returns a tuple with values
for calledCompletion, dogs and error.
• Then, it creates local variables, calls getDogs on sut and calls the
completionHandler on mockTask, as the previous tests did.
• Finally, the method returns the tuple created from the local variables for
calledCompletion, receivedDogs and receivedError.
You can use this method to remove the duplicate code from your tests. First, replace
the contents of test_getDogs_givenResponseStatusCode500_callsCompletion
with:
// when
let result = whenGetDogs(statusCode: 500)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
XCTAssertNil(result.error)
This method is simplified because the bulk of the work now happens within
whenGetDogs.
// given
let expectedError = NSError(domain: "com.DogPatchTests",
code: 42)
// when
let result = whenGetDogs(error: expectedError)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
raywenderlich.com 161
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
This method is also simplified and now only handles the parts unique to setting up
the expectedError and testing that it returns correctly.
Build and run the tests, and they’ll all continue to pass. That was a great refactor,
and your upcoming tests will make good use of this helper method!
Before you do, there’s a convenience extension already in the project that you should
know about. Under DogPatchTests/Test Types/Extensions, open
Data+JSONFile.swift. You’ll see fromJSON(fileName:file:line:), a static method
for getting Data from a file.
This method is for tests. If the file can’t be found, then it will fail an assertion and
throw an exception. For example, this might happen if the file wasn’t added or the
wrong file name is input into the method.
Further, a kind developer colleague already provided a test data file called
GET_Dogs_Response.json for you within DogPatchTests/Data. You’re welcome. ;]
Armed with this infomation, you’re ready to write the happy-path test! Add the
following right after the previous test in DogPatchClientTests:
func test_getDogs_givenValidJSON_callsCompletionWithDogs()
throws {
// given
let data =
try Data.fromJSON(fileName: "GET_Dogs_Response")
// when
let result = whenGetDogs(data: data)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertEqual(result.dogs, dogs)
XCTAssertNil(result.error)
}
raywenderlich.com 162
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
• First, you create data by calling Data.fromJSON with the given JSON filename.
• You create a new decoder of type JSONDecoder and use it to decode the data. This
is possible because Dog already conforms to Decodable and has tests verifying it
works in DogTests.swift.
• Then, you call whenGetDogs like the other tests, but this time, you pass data into
it.
• Finally, you assert that the completion is called, dogs is equal to the result.dogs
and the result.error is nil.
Build and run your tests and, as expected, you’ll see that this test fails. To make it
pass, replace the guard statement within getDogs(completion:) in
DogPatchClient with:
The difference here is that you add let data as the condition for the guard to pass.
Then add the following after the guard block’s closing curly brace:
The try! statement here looks dangerous, and it definitely is! However, this is the
minimum amount of code to make the test pass, and it’s an indicator that you need
another test.
Build and run the unit tests, and they’ll all pass. There isn’t any refactoring to do, but
you need to get rid of that try!.
Under what condition would this try! be a problem? If the server returned a 200
response, but the JSON couldn’t be parsed into Dogs, this would cause the app to
crash.
raywenderlich.com 163
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Fortunately, this is exactly the type of problem that unit tests can catch and help you
prevent. Add the following test after the previous test to produce this exact scenario:
func test_getDogs_givenInvalidJSON_callsCompletionWithError()
throws {
// given
let data = try Data.fromJSON(
fileName: "GET_Dogs_MissingValuesResponse")
// when
let result = whenGetDogs(data: data)
// then
XCTAssertTrue(result.calledCompletion)
XCTAssertNil(result.dogs)
• Then, you create a decoder of type JSONDecoder and attempt to deserialize the
data. You capture the error that’s thrown as expectedError.
• You call whenGetDogs, and assert that the completion was called, the returned
dogs are nil and the error has the same domain and code as the expectedError.
You must cast to NSError because Error objects aren’t directly comparable. By
casting to NSError, you can compare the domain and code for the errors to one
another, which is “good enough” to show it’s the same error.
Build and run the tests. Not only does this test fail, it crashes! Well, it’s good you
caught this doing TDD rather than after the code shipped to production, right?
raywenderlich.com 164
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
do {
let dogs = try decoder.decode([Dog].self, from: data)
completion(dogs, nil)
} catch {
completion(nil, error)
}
Build and rerun your unit tests. See that they all now pass.
While you could leave it to the caller to dispatch to the main queue, this only pushes
the problem down the line and makes the networking client harder to use. A better
design is to have DogPatchClient accept a responseQueue and handle dispatching.
You can even do this without breaking your existing unit tests by making the
responseQueue optional.
func test_init_sets_responseQueue() {
// given
let responseQueue = DispatchQueue.main
// when
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: responseQueue)
}
raywenderlich.com 165
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
To fix the error, add the following property to DogPatchClient after the others:
Then replace the signature for init with this, ignoring the resulting compiler error
in the tests:
init(baseURL: URL,
session: URLSessionProtocol,
responseQueue: DispatchQueue?)
The tests don’t compile because you need to update setting sut in setUp. Replace
that line with:
// then
XCTAssertEqual(sut.responseQueue, responseQueue)
Build and run the tests, and as expected, this test fails. To fix it, replace the let
responseQueue line within DogPatchClient with:
self.responseQueue = responseQueue
raywenderlich.com 166
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
func givenDispatchQueue() {
queue = DispatchQueue(label: "com.DogPatchTests.MockSession")
}
The existing tests don’t need this queue, so you only call this for the new tests you’ll
add next.
You also need to change the initializer for MockURLSessionTask. Replace it with the
following, ignoring the compiler error for now:
init(completionHandler:
@escaping (Data?, URLResponse?, Error?) -> Void,
url: URL,
queue: DispatchQueue?)
self.completionHandler = completionHandler
raywenderlich.com 167
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
To fix the compiler error, replace the return statement within makeDataTask on
MockURLSession with:
return MockURLSessionTask(
completionHandler: completionHandler,
url: url,
queue: queue)
This code passes the queue into the new initializer on MockURLSessionTask.
Build and run your unit tests. Since none of the tests depend on which queue the
completion handler is called, they all continue to pass.
For the first case, in DogPatchClientTests.swift, add the following test after the
existing ones:
func
test_getDogs_givenHTTPStatusError_dispatchesToResponseQueue() {
// given
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
raywenderlich.com 168
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
httpVersion: nil,
headerFields: nil)
mockTask.completionHandler(nil, response, nil)
// then
waitForExpectations(timeout: 0.1) { _ in
XCTAssertTrue(thread.isMainThread)
}
}
Technically, you could use any responseQueue. Pragmatically, you need to dispatch
the completion handler to the main queue. Sadly, iOS makes it difficult to check
which queue the code is currently running on… Oh, Apple! Don’t you know we need
this for unit tests?!
Fortunately, it’s easy to validate that the current Thread is the main thread, and the
main dispatch queue always runs on the main thread. Hence, your tests rely on this
fact to validate the code was “dispatched to the main queue.”
In reality, of course, you’re technically checking that the code runs on the main
Thread. However, short of Apple making it easier to test and validate which dispatch
queue is in use, this is “good enough.”
• In when, you first create a local variable for thread and then call sut.getDogs().
In its completion handler, you set thread and fulfill the expectation.
You then create a response variable with an error status code of 500 and use this to
call the completionHandler.
Whoo, that was a bit of a whopper test! Build and run the unit tests, and you’ll see
that this test fails because you aren’t currently dispatching to the responseQueue on
DogPatchClient.
raywenderlich.com 169
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
By using [weak self] and guard let self in this manner, you avoid creating the
strong reference cycle that’s possible if you instead reference self directly.
Next, inside the guard let response closure, replace the first instance of this code,
leaving the other two completion sites unchanged:
completion(nil, error)
This code checks if the responseQueue is set and dispatches the call to the
completion if so.
Build and run the unit tests, and watch them all pass. There’s nothing to refactor yet,
so you can move on to testing the next scenario: ensuring an HTTP error dispatches
on the response queue.
func test_getDogs_givenError_dispatchesToResponseQueue() {
// given
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
raywenderlich.com 170
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
// then
waitForExpectations(timeout: 0.2) { _ in
XCTAssertTrue(thread.isMainThread)
}
}
This test is very similar to the previous one. However, in the when section, you pass
an error into the mockTask.completionHandler.
Build and run your tests, and, surprisingly, this test actually passes! What’s up with
that?
In getDogs, you’ll see that the check for an error and the HTTP status code are part
of the same guard statement, which looks like this:
Does this mean this test isn’t useful? No, it’s still useful. If you refactor this code
later, and this check isn’t combined in the same guard like it currently is, you still
want to ensure that the error dispatches on the responseQueue. So, leave this test
as is and move on to refactoring.
raywenderlich.com 171
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
There’s a huge amount of duplicated code between these two tests. To fix this, add
the following helper method towards the top of the file, right after
whenGetDogs(...):
mockSession.givenDispatchQueue()
sut = DogPatchClient(baseURL: baseURL,
session: mockSession,
responseQueue: .main)
// when
var thread: Thread!
let mockTask = sut.getDogs() { dogs, error in
thread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
// then
waitForExpectations(timeout: 0.2) { _ in
XCTAssertTrue(thread.isMainThread, line: line)
}
}
This method accepts inputs for data, statusCode and error. These will vary
depending on the actual behavior the test wants to verify. It also accepts an input for
line, which ensures XCTAssertTrue attributes a failure to the test method’s line
number instead of this helper method itself.
Now you can use this helper method to get rid of the duplicated code. Replace the
contents of test_getDogs_givenHTTPStatusError_dispatchesToResponseQueue
with:
verifyGetDogsDispatchedToMain(statusCode: 500)
raywenderlich.com 172
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
// given
let error = NSError(domain: "com.DogPatchTests", code: 42)
// then
verifyGetDogsDispatchedToMain(error: error)
That’s much more readable and compact! Build and run the unit tests, and see they
continue to pass.
The next test scenario you need to cover is ensuring a valid response dispatches to
the response queue. Add the following test after the last one:
func test_getDogs_givenGoodResponse_dispatchesToResponseQueue()
throws {
// given
let data = try Data.fromJSON(
fileName: "GET_Dogs_Response")
// then
verifyGetDogsDispatchedToMain(data: data)
}
Nice! You make great use of your helper methods to write compact tests. Build and
run the tests, and you’ll see this test fails.
completion(dogs, nil)
Similar to how you handled the error, this code checks for a responseQueue and
dispatches dogs to it if so.
raywenderlich.com 173
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Build and run the tests. They now pass. However, now there’s duplicate logic in
DogPatchClient that you need to eliminate. To do so, add the following helper
method right after getDogs:
You can use this method with any model because it uses a generic Type and accepts
inputs for models, error and completion. Regardless of inputs, it always checks if
there’s a responseQueue and dispatches the completion to it. If there’s no
responeQueue, it merely calls the completion with the inputs.
You can use this code to get rid of the duplicate code now. First, replace this code:
raywenderlich.com 174
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Finally, you need to verify that if an invalid JSON response is received, it’s also
dispatched to the response queue. Add the following test:
func
test_getDogs_givenInvalidResponse_dispatchesToResponseQueue()
throws {
// given
let data = try Data.fromJSON(
fileName: "GET_Dogs_MissingValuesResponse")
// then
verifyGetDogsDispatchedToMain(data: data)
}
Build and run your tests, and you’ll see this fails as anticipated. To fix this, replace
this line within getDogs in DogPatchClient:
completion(nil, error)
Build and run your tests, and see them all pass. You did a great job refactoring
already, so there’s also nothing left to do refactor-wise here.
And guess what else? You just completed TDDing your networking client! Great job!
raywenderlich.com 175
iOS Test-Driven Development by Tutorials Chapter 8: RESTful Networking
Key points
In this chapter, you learned how to do TDD for a networking client. Here’s a recap of
what you learned:
• Avoid making real networking calls in your unit tests by mocking URLSession and
URLSessionTask.
• Do TDD for GET requests easily by breaking them into several smaller tasks:
Calling the right URL, handling HTTP status errors, handling valid and invalid
responses.
You’re one step closer to displaying those cute pups onscreen! In the next chapter,
you’ll learn how to do TDD for consuming the networking client in your view
controller.
raywenderlich.com 176
9 Chapter 9: Using the
Network Client
By Joshua Greene
In the last chapter, you identified that ListingsViewController isn’t actually doing
any networking. Rather, it has a // TODO comment in refreshData(). In response,
you created DogPatchClient to handle networking logic. However, you haven’t used
it yet.
Getting started
Feel free to use your project from the last chapter. If you want a fresh start, navigate
to this chapter’s starter directory, open the DogPatch subdirectory and then open
DogPatch.xcodeproj.
Once your project is ready, it’s time to jump in and set DogPatchClient up for
networking by adding a shared instance.
raywenderlich.com 177
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
1. You’d have to duplicate creation data, including the baseURL, session and
responseQueue, anywhere you instantiate DogPatchClient.
2. You’d make more network calls in parallel. As a result, you could use more
network data or harm battery life.
Before you can write app code, you first need to write a failing test. Open
DogPatchClientTests.swift and add this test right before
test_init_sets_baseURL(), ignoring the compiler error like usual:
func test_shared_setsBaseURL() {
// given
let baseURL = URL(
string: "https://dogpatchserver.herokuapp.com/api/v1/")!
// then
XCTAssertEqual(DogPatchClient.shared.baseURL, baseURL)
}
You first create an expected baseURL; this address corresponds to the real server URL
that you’ll be calling. You then assert DogPatchClient.shared.baseURL equals this
baseURL. Since you haven’t defined shared on DogPatchClient, however, this
doesn’t compile.
A compiler error counts as a failing test, so you’re allowed to write app code to fix it.
Here you’ve defined a static shared property with dummy values for its inputs. This
is enough to fix the compiler error in the unit tests.
raywenderlich.com 178
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
Build and run the unit tests and, as expected, this last test fails. That’s because the
baseURL set on DogPatchClient.shared is not equal to the expected baseURL. To
make it pass, replace the baseURL value on shared with the following:
baseURL: URL(
string:"https://dogpatchserver.herokuapp.com/api/v1/")!
Build and run the unit tests, and they should all pass now. However, you still need a
couple more tests to ensure you’ve set the correct values for
DogPatchClient.shared.
func test_shared_setsSession() {
XCTAssertTrue(
DogPatchClient.shared.session === URLSession.shared)
}
func test_shared_setsResponseQueue() {
XCTAssertEqual(DogPatchClient.shared.responseQueue, .main)
}
This test checks the final property on the shared instance, responseQueue. Build and
run this test, and you’ll see DogPatchClient.shared.responseQueue is currently
set to nil. To fix this, update the input parameter argument for responseQueue
to .main. Build and run the tests again to verify they all pass.
raywenderlich.com 179
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
Ultimately, your static shared property on DogPatchClient should look like the
following:
func test_networkClient_setToDogPatchClient() {
XCTAssertTrue(sut.networkClient === DogPatchClient.shared)
}
To fix this, open ListingsViewController.swift and add the following property right
after // MARK: - Instance Properties:
var networkClient =
DogPatchClient(baseURL: URL(string: "http://example.com")!,
session: URLSession.shared,
responseQueue: nil)
You declare this as a var to allow your tests to replace it with a mock object later on.
By defining this property, you’ve also fixed the compiler error.
Build and run the unit tests and you’ll see this test fails because networkClient
isn’t set to DogPatchClient .shared. To make it pass, replace the declaration for
var networkClient inside ListingsViewController.swift with the following:
Build and run your tests again to verify they all pass.
raywenderlich.com 180
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
• The tests would fail if an internet connection wasn’t available or the server was
down.
• You wouldn’t be able to predict the network response in advance, so you couldn’t
verify the values are what you expected.
• Your unit tests would be slow to run because each would need to wait for a
network response.
Fortunately, there’s a better option: Use a mock network client. This lets you avoid
making real network calls while completely controlling the response results.
There are two ways you can create a mock network client in Swift:
1. You can create a mock by subclassing DogPatchClient and overriding each of its
methods. This works, but you may accidentally make real network calls if you
forget to override a method. You may also cause side effects, such as caching fake
network responses.
2. Similar to how you mocked URLSession, you can create a network client protocol
and use this instead of DogPatchClient directly. As a result, you’d eliminate the
possibility of making real network calls or causing side effects. Nice! The only
downside is that you need to create an extra protocol, but this is pretty quick and
easy to do.
In general, you should prefer to create a mock network client using a protocol over
subclassing-and-overriding.
raywenderlich.com 181
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
Okay, that’s enough theory! You’re ready to TDD the protocol now.
As always, you’ll write a test first. Open DogPatchClientTests.swift and add the
following method, right before test_shared_setsBaseURL(), ignoring the resulting
compiler error:
func test_conformsTo_DogPatchService() {
XCTAssertTrue((sut as AnyObject) is DogPatchService)
}
You cast sut as AnyObject to prevent a compiler warning, and you then assert sut
is DogPathcService. However, this causes a compiler error because you haven’t
defined DogPatchService yet.
To fix this, open DogPatchClient.swift and add the following, right before the class
declaration:
protocol DogPatchService {
Build and run the unit tests and, as expected, the last test will fail. To make it pass,
add the following to the end of DogPatchClient.swift, after the closing class curly
brace:
Build and run the tests again, and verify they all pass.
This protocol isn’t very useful yet because it doesn’t have any methods. For this, add
the following test below test_conformsTo_DogPatchService():
func test_dogPatchService_declaresGetDogs() {
// given
let service = sut as DogPatchService
// then
_ = service.getDogs() { _, _ in }
}
raywenderlich.com 182
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
This test won’t compile because DogPatchService doesn’t know anything about
getDogs. To fix this, adding the following inside the DogPatchService protocol:
func getDogs(completion:
@escaping ([Dog]?, Error?) -> Void) -> URLSessionTaskProtocol
Build and run your tests now, and they should all pass.
But wait! Won’t this test result in real networking calls being made? No, if you check
the setUp(), you’ll recall that you passed an instance of MockSession to create
DogPatchClient and set this to sut. Since MockSession doesn’t make any real
networking calls, you can freely call methods on DogPatchClient without worry.
Are there any other properties or methods you should add to DogPatchService? For
example, what about init(baseURL:session:responseQueue:) or the shared
property?
No, you don’t need to add these because they are implementation details. A
consumer doesn’t need to know how you constructed its dependency. Rather, it only
need to know what behavior the dependency provides. This, in turn, defines which
methods and properties go into the protocol.
Just like MockURLSession, your mock network client won’t be part of your app code.
Instead, it enables you to write unit tests, and this in turn enables you to write app
code. Okay, carry on then…!
// 1
class MockDogPatchService: DogPatchService {
// 2
var baseURL = URL(string: "https://example.com/api/")!
var getDogsCallCount = 0
raywenderlich.com 183
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
// 3
func getDogs(completion: @escaping ([Dog]?, Error?) -> Void)
-> URLSessionTaskProtocol {
getDogsCallCount += 1
getDogsCompletion = completion
return getDogsDataTask
}
}
Fantastic, you implemented this mock like a pro! It mirrors how DogPatchClient
works, but it allows you to fully control the response that’s returned, doesn’t require
a network connection and doesn’t have any network delay. So now, it’s time to put it
to work.
raywenderlich.com 184
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
Your first test will assert that the view controller holds onto the returned data task.
To do this, add the following code right after
test_viewWillAppear_calls_refreshData():
func test_refreshData_setsRequest() {
// given
let mockNetworkClient = MockDogPatchService()
sut.networkClient = mockNetworkClient
}
raywenderlich.com 185
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
Next, add this next code within test_refreshData_setsRequest(), right before its
closing method brace:
// when
sut.refreshData()
// then
XCTAssertTrue(sut.dataTask ===
mockNetworkClient.getDogsDataTask)
This fixes the compiler error, so build and run the tests and verify that it fails. To
make it pass, you need to set dataTask whenever refreshData() is called.
Build and run the tests again, and they’ll all pass.
It’s possible refreshData could be called more than once in quick succession. For
example, this could happen if the user “pulls to refresh” when a network call is
already in progress.
If dataTask is already set, you don’t want to call getDogs multiple times. Add the
following test after test_refreshData_setsRequest(); it ensure you’re only
calling getDogs once, even if refreshData is called in quick succession:
func test_refreshData_ifAlreadyRefreshing_doesntCallAgain() {
// given
let mockNetworkClient = MockDogPatchService()
sut.networkClient = mockNetworkClient
// when
sut.refreshData()
sut.refreshData()
// then
XCTAssertEqual(mockNetworkClient.getDogsCallCount, 1)
}
raywenderlich.com 186
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
Build and run the unit tests to verify this one fails. To make it pass, open
ListingsViewController.swift and add the following code just after the opening
curly brace for refreshData():
This guard returns early if dataTask is not nil. Build and run your unit tests, and
they should all now pass.
Do you see anything that needs to be refactored? The app code looks fine, but what
about the unit tests? Yep, you’ve duplicated the code for setting sut.networkClient
to mockNetworkClient.
To eliminate this duplication, first add this new property right after the var sut
line:
Next, add the following method right after the givenDogs(count:) method:
func givenMockNetworkClient() {
mockNetworkClient = MockDogPatchService()
sut.networkClient = mockNetworkClient
}
You won’t need a MockDogPatchService for every test, so you add this helper
method to create and set one. You’ll call this only from the tests that require a mock.
Then, add the following within tearDown(), right after its opening method brace:
mockNetworkClient = nil
This ensures mockNetworkClient is set to nil after each test run completes.
givenMockNetworkClient()
raywenderlich.com 187
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
This gets rid of the duplicate code. Now, build and run the tests to verify they still
pass.
For the next test, you need to ensure that dataTask is set back to nil after the
completion is called for getDogs. Add the following test below
test_refreshData_ifAlreadyRefreshing_doesntCallAgain():
func test_refreshData_completionNilsDataTask() {
// given
givenMockNetworkClient()
let dogs = givenDogs()
// when
sut.refreshData()
mockNetworkClient.getDogsCompletion(dogs, nil)
// then
XCTAssertNil(sut.dataTask)
}
1. Within the given section, you make excellent use of your helper methods to
create mockNetworkClient and dogs.
2. Within when, you first call sut.refreshData() to set the dataTask. You then
pass dogs to the getDogsCompletion closure on the mockNetworkClient. This
executes the passed-in closure from ListingsViewController, and it should set
the dataTask to nil.
3. Within then, you assert that the sut.dataTask is set back to nil.
Build and run this test, and you’ll see it fails. Of course, that’s because you haven’t
actually set dataTask to nil within the getDogs completion closure.
To make this pass, add this line right inside the completion closure within
refreshData on ListingsViewController:
self.dataTask = nil
Build and run the tests to verify the last one now passes.
raywenderlich.com 188
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
You’re now ready to test the “happy path”, which returns dogs successfully and sets
it on the ListingsViewController. Add the following test below
test_refreshData_completionNilsDataTask():
func test_refreshData_givenDogsResponse_setsViewModels() {
// given
givenMockNetworkClient()
let dogs = givenDogs()
let viewModels = dogs.map { DogViewModel(dog: $0) }
// when
sut.refreshData()
mockNetworkClient.getDogsCompletion(dogs, nil)
// then
XCTAssertEqual(sut.viewModels, viewModels)
}
1. Within given, you use your helper methods to create mockNetworkClient and
dogs, then you create viewModels by mapping each dog to a DogViewModel.
2. For the when section, you call sut.refreshData() and execute the
getDogsCompletion with the given dogs.
Build and run this test to verify it fails. You need to set viewModels on
ListingsViewController to make it pass.
Add the following right after dataTask = nil within the refreshData() on
ListingsViewController:
This likewise calls map to turn dogs into a DogViewModel array. If there’s an error,
dogs might be nil so you use the optional unwrap operator ? and provide the
default value as an empty array.
Build and run your tests, and you’ll see this last one now passes.
raywenderlich.com 189
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
While you could try to refactor these tests further, you’d likely make them harder to
read. Consequently, it’s okay to leave them as is! There’s always a balancing act
between refactoring as much as possible and inline readability. If you’re ever in
doubt, try refactoring! If it turns out the code is too difficult to read, you can always
undo the change.
For the next test, you’ll verify you reload the tableView after the viewModels are
set. Add the following test below
test_refreshData_givenDogsResponse_setsViewModels():
func test_refreshData_givenDogsResponse_reloadsTableView() {
// given
givenMockNetworkClient()
let dogs = givenDogs()
// 1
class MockTableView: UITableView {
var calledReloadData = false
override func reloadData() {
calledReloadData = true
}
}
// 2
let mockTableView = MockTableView()
sut.tableView = mockTableView
// when
sut.refreshData()
mockNetworkClient.getDogsCompletion(dogs, nil)
// then
// 3
XCTAssertTrue(mockTableView.calledReloadData)
}
2. Next, you create a new instance for mockTableView and set this as
sut.tableView to ensure it’s used.
raywenderlich.com 190
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
Build and run the unit tests, and you’ll see this test fails because you don’t currently
call reloadData on the tableView. To get this to pass, add the following line, right
after setting viewModels within refreshData() on ListingsViewController:
self.tableView.reloadData()
Build and run the tests again, and this last one will now pass.
Okay, you’re finally ready to check out the app. Build and run it! You’ll see that the
dogs… don’t show?!
Instead, the view controller shows an error screen, and if you “pull down to refresh,”
you’ll see the “loading indicator” never disappears.
raywenderlich.com 191
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
To fix this, you need to begin and end refreshing on the table view’s
refreshControl. Add the following test below
test_refreshData_givenDogsResponse_reloadsTableView():
func test_refreshData_beginsRefreshing() {
// given
givenMockNetworkClient()
// when
sut.refreshData()
// then
XCTAssertTrue(sut.tableView.refreshControl!.isRefreshing)
}
This test verifies that isRefreshing on the refreshControl is true after calling
refreshData(). Build and run this test, and it will fail because you haven’t started
refreshing yet.
To fix this, add the following line right after the guard statement within
refreshData() on ListingsViewController:
tableView.refreshControl?.beginRefreshing()
Build and run the test again, and they’ll all pass.
Lastly, you need to end refreshing whenever your code calls the completion closure.
Add the following test to verify this below
test_refreshData_beginsRefreshing():
func test_refreshData_givenDogsResponse_endsRefreshing() {
// given
givenMockNetworkClient()
let dogs = givenDogs()
// when
sut.refreshData()
mockNetworkClient.getDogsCompletion(dogs, nil)
// then
XCTAssertFalse(sut.tableView.refreshControl!.isRefreshing)
}
This test calls refreshData(), executes the getDogsCompletion closure and asserts
that isRefreshing on the refreshControl is false. Build and run this test, and it
will fail because you haven’t actually finished refreshing yet.
raywenderlich.com 192
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
To make it pass, add the following line right after setting viewModels within
refreshData() on ListingsViewController:
self.tableView.refreshControl?.endRefreshing()
Build and run your tests again, and they should all pass.
Great job! You’ve done TDD for the entire refreshData() implementation. Build
and run the app to see it in action.
raywenderlich.com 193
iOS Test-Driven Development by Tutorials Chapter 9: Using the Network Client
Key points
In this chapter, you learned how to TDD using a network client. Here are the key
points you covered:
• You created a shared instance for the network client to avoid having multiple
instances throughout the app.
• You avoided using the real network client directly in your unit tests since that
would require an internet connection, which would cause them to be slower and
make testing responses harder. You used a mock network client instead.
• You learned why it’s better to create a mock network client by implementing a
protocol, instead of subclassing and overriding. By doing so, you avoided
accidentally making real network calls and side effects such as caching.
You’re now able to display network results on screen! Wouldn’t it be nice if you could
also see the images of the pups instead of just a placeholder image? You bet it would!
In the next chapter, you’ll learn how to create an image client to help you do just
that.
raywenderlich.com 194
10 Chapter 10: ImageClient
By Joshua Greene
In the last chapter, you used DogPatchClient to download and display dogs. Each
Dog has an imageURL, but you haven’t used it so far. While you could download
images by making network requests directly within ListingsViewController, you
wouldn’t be able to use that logic anywhere else.
Instead, you’ll do TDD to create an ImageClient for handling images. You can use
ImageClient anywhere you need it in the app.
Getting started
Feel free to use your project from the last chapter. If you want a fresh start, navigate
to this chapter’s starter directory, open the DogPatch subdirectory and then open
DogPatch.xcodeproj.
raywenderlich.com 195
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Your first step is going to be to get everything set up for your image client. Here’s
how.
// 1
import UIKit
class ImageClient {
self.cachedImageForURL = [:]
self.cachedTaskForImageView = [:]
self.responseQueue = responseQueue
self.session = session
}
}
raywenderlich.com 196
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
1. You first import UIKit to access UIImage and UIImageView, next you create a
new class for ImageClient.
2. You next declare a static property for shared. You’ll use this in your app code,
but you’ll create one-off instances in your unit tests. This is just like
DogPatchClient.
You also need to add the tests for this class. Under DogPatchTests/Cases/
Networking, create a new Swift File called ImageClientTests.swift and replace its
contents with the following:
// 1
@testable import DogPatch
import XCTest
// 2
var mockSession: MockURLSession!
var sut: ImageClient!
raywenderlich.com 197
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
func test_shared_setsSession() {
XCTAssertTrue(ImageClient.shared.session ===
URLSession.shared)
}
func test_init_setsCachedTaskForImageView() {
XCTAssertTrue(sut.cachedTaskForImageView.isEmpty)
}
func test_init_setsResponseQueue() {
XCTAssertTrue(sut.responseQueue === nil)
}
func test_init_setsSession() {
XCTAssertTrue(sut.session === mockSession)
}
}
1. You import both DogPatch and XCTest and create a test class for
ImageClientTests.
3. You set each instance property within setUp() and nil them within tearDown().
4. You create tests that validate that the shared instance has expected values.
5. Lastly, you create tests to validate that the initializer sets properties as expected.
Build and run your tests to verify that they all pass.
Wow, you covered a lot in short amount of time! While this code is definitely
important, you learned how to do TDD for this in previous chapters. You’re now
ready to dive into new concepts for this chapter!
raywenderlich.com 198
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
As always, you first need to write a failing test. Add the following to
ImageClientTests, right after the last test method:
You cast sut as AnyObject to prevent a compiler warning and then assert this
conforms to ImageService. However, this doesn’t compile because you haven’t
declared ImageService.
To fix this, add the following to the top of ImageClient.swift after the imports:
protocol ImageService {
Build and run the tests to validate the last one fails.
To make it pass, add the following after the class closing curly brace for
ImageClient:
// MARK: - ImageService
extension ImageClient: ImageService {
Build and run the tests again to verify the last one now passes. There’s nothing to
refactor, so you can simply continue.
You next need a test to define the downloadImage method signature. Open
ImageClientTests.swift and add the following right after the last test:
func test_imageService_declaresDownloadImage() {
// given
let url = URL(string: "https://example.com/image")!
let service = sut as ImageService
// then
_ = service.downloadImage(fromURL: url) { _, _ in }
}
raywenderlich.com 199
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Since you’ve yet to declare this method, this causes a compiler error. Open
ImageClient.swift and add the following code within ImageService to fix this:
func downloadImage(
fromURL url: URL,
completion: @escaping (UIImage?, Error?) -> Void)
-> URLSessionTaskProtocol
You also need to make ImageClient implement this method to make it conform to
ImageService. Replace the extension on ImageClient with the following:
Build and run the tests again to verify they all pass. Lastly, you need one more
method, to set an image onto an image view from a URL. Open
ImageClientTests.swift and add this test at the end:
func test_imageService_declaresSetImageOnImageView() {
// given
let service = sut as ImageService
let imageView = UIImageView()
let url = URL(string: "https://example.com/image")!
let placeholder = UIImage(named: "image_placeholder")!
// then
service.setImage(on: imageView,
fromURL: url,
withPlaceholder: placeholder)
}
raywenderlich.com 200
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
You also need to add this method to ImageClient to make it compile. Add this to
ImageClient after downloadImage:
You create setImage as an empty method because that’s the easiest way to
implement it.
Build and run the tests to confirm they all compile and pass.
Is there anything to refactor? Yes, you duplicated service and url within the last
two tests. To fix this, add the following after the sut property:
You also need to set url before each test run. Add this line to setUp, right before
setting sut:
After each test run, you need to reset url. Add this line to tearDown, again before
setting sut:
url = nil
You can now use these properties within your tests. Delete the entire given section
from test_imageService_declaresDownloadImage and delete the lines for
service and url from test_imageService_declaresSetImageOnImageView.
Finally, build and run the tests to ensure they still pass.
raywenderlich.com 201
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Downloading an image
You next need to implement downloadImage(fromURL:completion:).
For the first test, you’ll validate that session creates the a
URLSessionTaskProtocol using the passed-in url. Add this code after the last test
in ImageClientTests:
func test_downloadImage_createsExpectedTask() {
// when
let dataTask = sut.downloadImage(fromURL: url) { _, _ in }
as? MockURLSessionTask
// then
XCTAssertEqual(dataTask?.url, url)
}
Remember how you used mockSession to create ImageClient in this test’s setup?
MockURLSession always returns a MockURLSessionTask whenever its
dataTask(with:completionHandler:) is called.
Build and run this test and you’ll see it fails. This is because you aren’t using the
passed-in url when you call makeDataTask on the ImageClient.
To fix this, open ImageClient.swift and replace the contents of downloadImage with
the following:
}
return task
Here you call makeDataTask and provide named arguments for the closure
parameters; you’ll use these soon. Similarly, you name the returned value from
makeDataTask simply as task.
Build and run the tests now, and the last one should pass.
raywenderlich.com 202
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
You also need to call resume on the task to start it. Open ImageClientTests.swift
and add the following test to the end of class:
func test_downloadImage_callsResumeOnTask() {
// when
let dataTask =
sut.downloadImage(fromURL: url) { _, _ in }
as? MockURLSessionTask
// then
XCTAssertTrue(dataTask?.calledResume ?? false)
}
This time, you call downloadImage and verify calledResume is set to true;
calledResume is a property you added to MockURLSessionTask in a previous
chapter.
Build and run to ensure this test fails. To make it pass, you actually need to call
resume() on the task. Add the following right before the return statement within
downloadImage on ImageClient:
task.resume()
Build and run the tests again to verify the last one passes.
Do you see anything to refactor? Yep, you’ve duplicated the when code in the last two
tests. You’re going to call downloadImage a lot, so it’s best to pull this into a helper
method.
Before you do, you first need to add a few properties. Add the following right after
the other properties on ImageClientTests:
You also need to ensure you release these after each test. Add these lines within
tearDown() right after setting sut:
receivedTask = nil
receivedError = nil
receivedImage = nil
raywenderlich.com 203
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
You can now write the helper method. Add the following right after tearDown():
// MARK: - When
// 1
func whenDownloadImage(
image: UIImage? = nil, error: Error? = nil) {
// 2
receivedTask = sut.downloadImage(
fromURL: url) { image, error in
// 3
self.receivedImage = image
self.receivedError = error
} as? MockURLSessionTask
// 4
guard let receivedTask = receivedTask else {
return
}
if let image = image {
receivedTask.completionHandler(
image.pngData(), nil, nil)
1. You declare a new method for whenDownloadImage. It takes two inputs, image
and error.
4. Lastly, you guard that receivedTask is set. If so, you then check if image is set
and call completionHandler with it. Otherwise, you check if error is set and call
completionHandler with it instead.
raywenderlich.com 204
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
You’re now ready to use this helper method to refactor your tests! Replace the
contents of test_downloadImage_createsExpectedTask with the following:
// when
whenDownloadImage()
// then
XCTAssertEqual(receivedTask?.url, url)
This is much nicer to read! You simply call whenDownloadImage and then assert
receivedTask?.url equals the expected url.
// when
whenDownloadImage()
// then
XCTAssertTrue(receivedTask?.calledResume ?? false)
func test_downloadImage_givenImage_callsCompletionWithImage() {
// given
let expectedImage = UIImage(named: "happy_dog")!
// when
whenDownloadImage(image: expectedImage)
// then
XCTAssertEqual(expectedImage.pngData(),
receivedImage?.pngData())
}
Here, you create an expectedImage, call whenDownloadImage with it and then assert
that expectedImage and receivedImage have the same pngData(). Since UIImage
uses object equality, you cannot compare images directly. However, you can compare
their underlying data to verify they’re the same.
raywenderlich.com 205
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Build and run the test to verify it fails. To make it pass, you need to actually create an
image from the passed-in data and call the completion with it.
Here, you verify that data is set and try to create an image from it. If this succeeds,
you call completion with it.
Build and run the tests to verify the test now passes.
func test_downloadImage_givenError_callsCompletionWithError() {
// given
let expectedError = NSError(domain: "com.example",
code: 42,
userInfo: nil)
// when
whenDownloadImage(error: expectedError)
// then
XCTAssertEqual(expectedError, receivedError as NSError?)
}
This is similar to the previous test, except this time you’re passing an
expectedError into whenDownloadImage and asserting receivedError equals
expectedError.
Build and run this test to confirm it fails. To get it to pass, add the following code
inside the completion closure within downloadImage on ImageClient, right after
the closing curly brace for if let data:
else {
completion(nil, error)
}
Build and run the tests again, and they should all pass now.
raywenderlich.com 206
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Dispatching an image
Next, you need to ensure that completion dispatches to the responseQueue
whenever your app successfully downloads an image. Add this test to verify this:
func test_downloadImage_givenImage_dispatchesToResponseQueue() {
// given
mockSession.givenDispatchQueue()
sut = ImageClient(responseQueue: .main,
session: mockSession)
let expectedImage = UIImage(named: "happy_dog")!
var receivedThread: Thread!
let expectation = self.expectation(
description: "Completion wasn't called")
// when
let dataTask = sut.downloadImage(fromURL: url) { _, _ in
receivedThread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
dataTask.completionHandler(expectedImage.pngData(), nil, nil)
// then
waitForExpectations(timeout: 0.2)
XCTAssertTrue(receivedThread.isMainThread)
}
• Within when, you call sut.downloadImage. Inside its completion, you set
receivedThread and fulfill the expectation. You then execute
dataTask.completionHandler with image.pngData().
• Within then, you wait until the expectation is fulfilled. Afterwards, you assert
receivedThread.isMainThread.
raywenderlich.com 207
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Build and run this test to verify it fails. To make it pass, replace this code within
downloadImage on ImageClient:
with the following code instead, being careful not to change any code before or after
this:
let task =
session.makeDataTask(with: url) {
// 1
[weak self] data, response, error in
guard let self = self else { return }
// 3
} else {
completion(image, nil)
}
}
1. You first declare [weak self] and then immediately call guard let self within
the closure. This prevents a strong reference cycle due to capturing self.
2. If you’re able to create an image, you check if responseQueue is set and dispatch
completion to it.
raywenderlich.com 208
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Build and run the tests, and the last one will now pass.
For the refactor step, you’ll move expectedImage into a property to get rid of the
duplicated code. Add this line after the other properties:
You need to release this in tearDown(), so add this right before calling
super.tearDown():
expectedImage = nil
// MARK: - Given
func givenExpectedImage() {
expectedImage = UIImage(named: "happy_dog")!
}
Great! You can now use this helper method to get rid of the duplication in the tests.
Replace the lines for let expectedImage = everywhere in ImageClientTests with
the following:
givenExpectedImage()
Build and run the tests, and they should all continue to pass.
Dispatching an error
You also need to verify errors are dispatched to the responseQueue. Add this test
right after the last one:
func test_downloadImage_givenError_dispatchesToResponseQueue() {
// given
mockSession.givenDispatchQueue()
sut = ImageClient(responseQueue: .main,
session: mockSession)
// when
let dataTask = sut.downloadImage(fromURL: url) { _, _ in
raywenderlich.com 209
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
receivedThread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
dataTask.completionHandler(nil, nil, error)
// then
waitForExpectations(timeout: 0.2)
XCTAssertTrue(receivedThread.isMainThread)
}
This test is very similar to the success case. The main difference is that you’re
passing an error to the dataTask.completionHandler instead of an image.
Build and run the tests to verify this fails. Then, replace this code within
downloadImage onImageClient:
completion(nil, error)
} else {
completion(nil, error)
}
Build and run the tests again to verify they all pass.
You’ve duplicated logic in both the app and test code, so you need to refactor it!
You’ll first update the app code. Specifically, you need a new method to handle
dispatching to the responseQueue. Add the following right after downloadImage:
raywenderlich.com 210
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
You can now use this to remove the duplicate app logic. Replace these lines in
downloadImage on ImageClient:
} else {
completion(image, nil)
}
} else {
completion(nil, error)
}
Build and run the tests to verify they all still pass.
Next, you need to refactor the tests. Specifically, there’s a lot of duplicated code for
verifying that you are dispatching to the responseQueue.
Add this right after whenDownloadImage, towards the top of the file:
// MARK: - Then
func verifyDownloadImageDispatched(image: UIImage? = nil,
error: Error? = nil,
line: UInt = #line) {
mockSession.givenDispatchQueue()
sut = ImageClient(responseQueue: .main,
session: mockSession)
// when
let dataTask =
sut.downloadImage(fromURL: url) { _, _ in
raywenderlich.com 211
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
receivedThread = Thread.current
expectation.fulfill()
} as! MockURLSessionTask
dataTask.completionHandler(image?.pngData(), nil, error)
// then
waitForExpectations(timeout: 0.2)
XCTAssertTrue(receivedThread.isMainThread, line: line)
}
This code is very similar to how the last two unit tests validate
receivedThread.isMainThread. However, it accepts an image, error and line as
inputs. It uses these to call dataTask.completionHandler and then XCTAssert.
You’ve also duplicated expectedError in a couple of places, so you’ll move this into
a property. Add this line after the other properties:
Just like the others, you also need to ensure expectedError is reset after each test
run. Add this line to tearDown right before super.tearDown():
expectedError = nil
You also need a helper method to set expectedError. Add this right after
givenExpectedImage:
func givenExpectedError() {
expectedError = NSError(domain: "com.example",
code: 42,
userInfo: nil)
}
You can now update the unit tests to make use of these methods. First, replace the
line for let expectedError = within
test_downloadImage_givenError_callsCompletionWithError with this:
givenExpectedError()
// given
givenExpectedImage()
// then
verifyDownloadImageDispatched(image: expectedImage)
raywenderlich.com 212
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
// given
givenExpectedError()
// then
verifyDownloadImageDispatched(error: expectedError)
Very nice! You’ve greatly simplified these tests by using your helper methods.
Build and run the unit tests to verify they all pass.
Caching
Your ImageClient is really coming along, but it’s still missing a critical piece of
functionality: Caching. Specifically, you need to cache images that the user has
already downloaded.
func test_downloadImage_givenImage_cachesImage() {
// given
givenExpectedImage()
// when
whenDownloadImage(image: expectedImage)
// then
XCTAssertEqual(sut.cachedImageForURL[url]?.pngData(),
expectedImage.pngData())
}
This test asserts that the expected image is cached. Build and run the tests to
validate this fails. To make it pass, add the following right after the if let data =
line within downloadImage on ImageClient:
self.cachedImageForURL[url] = image
If there’s already a cached image, you don’t want to start another task. Instead, you
should immediately call the completion with it and return nil from
downloadImage.
raywenderlich.com 213
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
func test_downloadImage_givenCachedImage_returnsNilDataTask() {
// given
givenExpectedImage()
// when
whenDownloadImage(image: expectedImage)
whenDownloadImage(image: expectedImage)
// then
XCTAssertNil(receivedTask)
}
You pass expectedImage into whenDownloadImage, which caches the image. You
then call this method a second time and assert that receivedTask is nil.
Build and run this test to ensure it fails. To make it pass, change the return type for
downloadImage on ImageClient from URLSessionTaskProtocol to
URLSessionTaskProtocol?.
Then, add these lines to downloadImage on ImageClient, right after the method’s
opening curly brace:
You check if an image already exists in the cachedImageForURL; if so, you return nil
for the task.
Build and run the unit tests to verify they all now pass.
If there’s a cached image, you also need to immediately call completion with it. Add
this test to verify this behavior happens:
func
test_downloadImage_givenCachedImage_callsCompletionWithImage() {
// given
givenExpectedImage()
// when
whenDownloadImage(image: expectedImage)
receivedImage = nil
raywenderlich.com 214
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
whenDownloadImage(image: expectedImage)
// then
XCTAssertEqual(expectedImage.pngData(),
receivedImage?.pngData())
}
Build and run to ensure this test fails. To make it pass, add the following right after
if let image = cachedImageForURL[url] {:
completion(image, nil)
Build and run the tests again, and they should all now pass.
You could, but you’d need to handle caching logic: What happens if you’re already
downloading an image for the image view? For example, what happens if you’re
displaying the image view in a table view… which is exactly what
ListingsViewController does?
1. Cancel the cached task for the image view, if one exists.
3. Call downloadImage and cache the task for the image view.
raywenderlich.com 215
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
func test_setImageOnImageView_cancelsExistingDataTask() {
// given
let task = MockURLSessionTask(
completionHandler: { _, _, _ in },
url: url,
queue: nil)
let imageView = UIImageView()
sut.cachedTaskForImageView[imageView] = task
// when
sut.setImage(on: imageView,
fromURL: url,
withPlaceholder: nil)
// then
XCTAssertTrue(task.calledCancel)
}
func cancel()
raywenderlich.com 216
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
You add a property for calledCancel with a default value of false, and you set this
to true whenever cancel is called.
Build and run the tests to verify this one fails. To get it to pass, add the following
code within setImage on ImageClient:
cachedTaskForImageView[imageView]?.cancel()
Build and run the tests again, and they’ll now pass.
func test_setImageOnImageView_setsPlaceholderOnImageView() {
// given
givenExpectedImage()
let imageView = UIImageView()
// when
sut.setImage(on: imageView,
fromURL: url,
withPlaceholder: expectedImage)
// then
XCTAssertEqual(imageView.image?.pngData(),
expectedImage.pngData())
}
Build and run the tests, and you’ll see this last one fails. To make it pass, you want to
set the image on the imageView to the placeholder. To do it, add this to setImage
on ImageClient, right before the closing method brace:
imageView.image = placeholder
raywenderlich.com 217
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Build and run the tests again to ensure they all pass.
Then, add this line within tearDown to reset imageView after each run, right before
calling super.tearDown():
imageView = nil
While you could create a helper method for givenImageView(), you’ll be using
imageView in several tests. Hence, you’ll set it before each test run instead. Add this
line to setUp(), right before setting sut:
imageView = UIImageView()
func test_setImageOnImageView_cachesTask() {
// when
sut.setImage(on: imageView,
fromURL: url,
withPlaceholder: nil)
// then
receivedTask = sut.cachedTaskForImageView[imageView]
as? MockURLSessionTask
XCTAssertEqual(receivedTask?.url, url)
}
raywenderlich.com 218
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Build and run the tests to verify this fails. To make it pass, add the following to
setImage on ImageClient, immediately before the method’s closing brace:
cachedTaskForImageView[imageView] =
downloadImage(fromURL: url) { [weak self] image, error in
guard let self = self else { return }
Build and run the tests to verify the last one now passes.
func test_setImageOnImageView_onCompletionRemovesCachedTask() {
// given
givenExpectedImage()
// when
sut.setImage(on: imageView,
fromURL: url,
withPlaceholder: nil)
receivedTask = sut.cachedTaskForImageView[imageView]
as? MockURLSessionTask
receivedTask?.completionHandler(
expectedImage.pngData(), nil, nil)
// then
XCTAssertNil(sut.cachedTaskForImageView[imageView])
}
You call setImage and unwrap receivedTask. You then call completionHandler on
receivedTask and finally assert the task is removed from the cache.
Build and run this test to verify it falls. To make it pass, add this line within
setImage, immediately after the guard statement you added before:
self.cachedTaskForImageView[imageView] = nil
Build and run the tests again to verify they all pass.
raywenderlich.com 219
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
func test_setImageOnImageView_onCompletionSetsImage() {
// given
givenExpectedImage()
// when
sut.setImage(on: imageView,
fromURL: url,
withPlaceholder: nil)
receivedTask = sut.cachedTaskForImageView[imageView]
as? MockURLSessionTask
receivedTask?.completionHandler(
expectedImage.pngData(), nil, nil)
// then
XCTAssertEqual(imageView.image?.pngData(),
expectedImage.pngData())
}
This test is very similar to the last one; the difference is you assert that the image
data from the imageView equals the data from the expectedImage.
Build and run this test, and you’ll see it fails. To make it succeed, add this code
within downloadImage on ImageClient after the previous line you added to set the
image on the imageView:
imageView.image = image
Build and run to verify the test now passes. However, you now have duplicated code
that needs refactoring.
func whenSetImage() {
givenExpectedImage()
sut.setImage(on: imageView,
fromURL: url,
withPlaceholder: nil)
receivedTask = sut.cachedTaskForImageView[imageView]
as? MockURLSessionTask
receivedTask?.completionHandler(
expectedImage.pngData(), nil, nil)
}
raywenderlich.com 220
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
You’ve moved the common code into this method. Hence, you’ll need to replace the
contents of test_setImageOnImageView_onCompletionRemovesCachedTask with
the following:
// when
whenSetImage()
// then
XCTAssertNil(sut.cachedTaskForImageView[imageView])
// when
whenSetImage()
// then
XCTAssertEqual(imageView.image?.pngData(),
expectedImage.pngData())
func test_setImageOnImageView_givenError_doesnSetImage() {
// given
givenExpectedImage()
givenExpectedError()
// when
sut.setImage(on: imageView,
fromURL: url,
withPlaceholder: expectedImage)
receivedTask = sut.cachedTaskForImageView[imageView]
as? MockURLSessionTask
receivedTask?.completionHandler(nil, nil, expectedError)
// then
XCTAssertEqual(imageView.image?.pngData(),
expectedImage.pngData())
}
raywenderlich.com 221
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
• Within when, you call setImage, unwrap the task and execute its
completionHandler with the expectedError. As a consequence, this sets the
expectedImage on the imageView because it’s passed as the placeholder image.
• Within then, you assert that the image on the imageView is still set to the
expectedImage.
Build and run this test to verify that it fails. To make it pass, replace this line within
downloadImage on ImageClient:
imageView.image = image
You guard that image is actually set here. If it is not, you print the error to the
console. If it is, you set it on the imageView.
Before you do, you need to create a MockNetworkClient. Create a new Swift File in
DogPatchTests/Test Types/Mocks named MockImageService.swift. Replace its
contents with the following:
// 1
class MockImageService: ImageService {
raywenderlich.com 222
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
// 2
func downloadImage(
fromURL url: URL,
completion: @escaping (UIImage?, Error?) -> Void)
-> URLSessionTaskProtocol? {
return nil
}
// 3
var setImageCallCount = 0
var receivedImageView: UIImageView!
var receivedURL: URL!
var receivedPlaceholder: UIImage!
// 4
func setImage(on imageView: UIImageView,
fromURL url: URL,
withPlaceholder placeholder: UIImage?) {
setImageCallCount += 1
receivedImageView = imageView
receivedURL = url
receivedPlaceholder = placeholder
}
}
You can now put this mock to good use! Open ListingsViewControllerTests.swift
and add the following right before // MARK: - View Life Cycle - Tests:
func test_imageClient_isImageService() {
XCTAssertTrue((sut.imageClient as AnyObject) is ImageService)
}
You here cast sut.imageClient as AnyObject to silence a warning and then assert it
is an ImageService. This test doesn’t compile, however, because you haven’t
declared imageClient on ListingsViewController yet.
raywenderlich.com 223
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Build and run the tests, and the last one should now succeed.
func test_imageClient_setToSharedImageClient() {
// given
let expected = ImageClient.shared
// then
XCTAssertTrue((sut.imageClient as? ImageClient) === expected)
}
Build and run this test to ensure it fails. To make it pass, update the var
imageClient declaration in ListingsViewController with the following:
Before you can do this, you first need a new property for mockImageClient. Add this
right before var mockNetworkClient on ListingsViewControllerTests:
Then add the following, right after setting sut within setUp():
mockImageClient = MockImageService()
sut.imageClient = mockImageClient
mockImageClient = nil
raywenderlich.com 224
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
If you build and run the tests, however, you’ll see that
test_imageClient_setToSharedImageClient now fails! This is because you set
sut.imageClient within setUp to mockImageClient, and hence, it’s never going to
be equal to ImageClient.shared.
sut = ListingsViewController.instanceFromStoryboard()
Build and run the tests again, and they’ll all pass now.
You’re now ready to use mockImageClient in a unit test. Add this test after the very
last one:
func
test_tableViewCellForRowAt_callsImageClientSetImageWithDogImageV
iew() {
// given
givenMockViewModels()
// when
let indexPath = IndexPath(row: 0, section: 0)
let cell = sut.tableView(sut.tableView,
cellForRowAt: indexPath)
as? ListingTableViewCell
// then
XCTAssertEqual(mockImageClient.receivedImageView,
cell?.dogImageView)
}
• Within when, you dequeue the cell for the first IndexPath and cast this to
ListingTableViewCell.
raywenderlich.com 225
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Build and run this test to verify it fails. To make it pass, you need to actually pass
cell.dogImageView into imageClient.setImage. Add this code within
listingCell(_:_:) on ListingsViewController, right before the return line:
imageClient.setImage(
on: cell.dogImageView,
fromURL: URL(string: "http://example.com")!,
withPlaceholder: nil)
Build and run the tests again, and the last one will now pass.
You also need to ensure that you’re passing the correct URL into
imageClient.setImage. Add this test after the last one:
func
test_tableViewCellForRowAt_callsImageClientSetImageWithURL() {
// given
givenMockViewModels()
let viewModel = sut.viewModels.first!
// when
let indexPath = IndexPath(row: 0, section: 0)
_ = sut.tableView(sut.tableView, cellForRowAt: indexPath)
// then
XCTAssertEqual(mockImageClient.receivedURL,
viewModel.imageURL)
}
Build and run the tests, and you’ll see this one fails. To make it succeed, replace this
argument within ListingsViewController:
URL(string: "http://example.com")!
viewModel.imageURL
This passes the imageURL from viewModel into the imageClient.setImage call.
raywenderlich.com 226
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
For the refactor step, you now have similar code in the last two tests. To get rid of the
duplication, add the following method after whenDequeueTableViewCells:
@discardableResult
func whenDequeueFirstListingsCell()
-> ListingTableViewCell? {
let indexPath = IndexPath(row: 0, section: 0)
return sut.tableView(sut.tableView,
cellForRowAt: indexPath)
as? ListingTableViewCell
}
You here dequeue the first table view cell and then cast it as
ListingTableViewCell.
// when
let cell = whenDequeueFirstListingsCell()
whenDequeueFirstListingsCell()
Lastly, you need a test to confirm that you’re passing the placeholder image into
setImage. Add this test after the last one:
func
test_tableViewCellForRowAt_callsImageClientWithPlaceholder() {
// given
givenMockViewModels()
let placeholder = UIImage(named: "image_placeholder")!
// when
whenDequeueFirstListingsCell()
// then
XCTAssertEqual(
mockImageClient.receivedPlaceholder.pngData(),
placeholder.pngData())
}
raywenderlich.com 227
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
This test is similar to the previous ones. However, you here declare an expected
placeholder and assert that the underlying data on
mockImageClient.receivedPlaceholder is the same as it.
Build and run the tests to confirm this one fails. To make it pass, replace the
following in ListingsViewController:
withPlaceholder: nil
withPlaceholder:
UIImage(named: "image_placeholder")
Build and run your tests again and they should all pass.
Now for the fun part – you’ve done TDD to create and even use the ImageClient, but
you haven’t seen your hard work pay off yet. You’re finally ready to use it!
Build and run the app to check and see how ImageClient loads and displays images
onscreen.
raywenderlich.com 228
iOS Test-Driven Development by Tutorials Chapter 10: ImageClient
Key points
In this chapter, you learned how to do TDD for an image client. Here are the key
points:
• You created a service protocol to make mocking easy, just like with a network
client.
• Remember to refactor as you go! For example, you can pull out helper methods
and properties for turning asynchronous “download” calls into synchronous tests.
You’ve created the core functionality for DogPatch and learned a lot about
networking along the way! There’s still more functionality you could add, including
• Authentication
• Messaging
• Ratings
• User preferences
Some of these would require back-end support, but you can add many local features
too. Of course, remember to do TDD for networking and local features alike! :]
Feel free to tinker with DogPatch as much as you’d like. When you’re ready, move
onto the next section to learn about TDD on a legacy app.
raywenderlich.com 229
Section IV: TDD in Legacy
Apps
This section will show you how to start test-driven development in a legacy app that
lacks sufficient unit tests. You’ll learn strategies for introducing TDD into existing
apps, methods for visualizing and splitting up dependencies, ways to add features
safely alongside existing code and how to refactor large classes.
Throughout this section, you’ll introduce TDD into an app for managing a business.
The app is feature-rich with spaghetti code and ready for a TDD clean up!
Several techniques and concepts in this section were inspired by Michael Feather’s
book Working Effectively with Legacy Code. Reading that book isn’t a strict
requirement for working through these chapters. However, you’ll likely benefit by
having some familiarity with the topics herein if you already have read it!
raywenderlich.com 230
11 Chapter 11: Legacy
Problems
By Michael Katz
Beginning TDD on a “legacy” project is much different than starting TDD on a new
project. For example, the project may have few (if any) unit tests, lack
documentation and be slow to build. This chapter will introduce you to strategies for
tackling these problems.
You may think, “If only this project were created using TDD, it wouldn’t be this bad.”
Making the code more testable while adding unit tests is a great way to address these
issues. Unfortunately, there isn’t a silver-bullet, sure-fire way to fix all of these issues
overnight.
However, there are great strategies you can use to introduce TDD to legacy projects
over time. In this chapter, you’ll be introduced to the Legacy Code Change
Algorithm, which was originally introduced by Michael Feathers in his book Working
Effectively with Legacy Code. Here are the high-level steps:
3. Break dependencies
4. Write tests
raywenderlich.com 231
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
Introducing MyBiz
MyBiz is the sample app for this section. It’s a very lightweight ERP app but will be
illustrative of the kinds of issues you may encounter working with legacy apps. Don’t
worry if ERP is a meaningless acronym to you. It stands for Enterprise Resource
Planning, which is a four-dollar expression for “kitchen sink of business crap.”
In our TDD-world, “legacy app” most importantly means an app without adequate
(or any) unit tests. And if “legacy” means code without any tests, then this app is
capital-L Legacy.
Bloated, convoluted apps are common in large enterprises, such as where MyBiz
would be used; however, these issues occur in all kinds of apps in organizations of
different sizes and maturities. As soon as that first feature is added to an app that
wasn’t architected to support it, these “legacy (anti-) patterns” start cropping up.
Introducing TDD in your legacy app while adding features is a great way to avoid
this.
One challenge working with MyBiz is that it does not use a modern architecture like
MVVM or VIPER. Instead, a lot of the business logic exists in monolithic view
controllers. It gets the job done, but, as you’ll see, it’s hard to add new things.
raywenderlich.com 232
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
Note: To learn more about Vapor, you can read the documentation at https://
vapor.codes/ or check out our book Server-Side Swift With Vapor, which you can
find at https://www.raywenderlich.com/books/server-side-swift-with-vapor.
2. Run the following command to create your project file and open the Xcode
project.
vapor xcode -y
You should see the terminal pop up at the bottom of the screen with the following
text:
This means that the server is up and running. To check it out, open your web browser
and visit localhost:8080/hello. You should see the following:
Welcome to MyBiz!
raywenderlich.com 233
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
With the backend ready to go, open the starter project. Build and run. Click “Sign in”
to use the hard coded credentials: username agent@shield.org and password
"hailHydra". If the backend is set up properly, you’ll see a few tabs filled out with
sample data.
raywenderlich.com 234
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
You can distill the HR Director’s ask into the following statement:
Populate the user calendar with birthday events, one for each person in the
organization’s contact book.
There are a lot of ways this can be done. For this tutorial, you’ll take the following
approach:
For legacy code, you’ll write characterization tests. These are tests that make
explicit the current behavior of the code based on what the code does. With a big
legacy app, especially in an enterprise, it’s important to understand and preserve the
code’s behavior – ever hear the phrase, “That’s not a bug, that’s a feature”? The
current users expect the app to behave a certain way, even if it isn’t what’s intended
by the product manager, or what was written out in the spec.
Characterization tests are written for the code you plan to change and for that
change’s broader context (such as its class or callers). If the change includes moving
code or refactoring code, these tests should cover that code as well.
raywenderlich.com 235
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
There’s a TDD-like formula for writing a characterization test. It’s a little like TDD
except the code is already written:
The main difference from TDD is in the last step above. You’ll change the test to
match the code, rather than change the code to pass the test.
1. Add a new iOS Unit Testing Bundle target to the project. Name it
CharacterizationTests.
raywenderlich.com 236
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
A separate unit test target will be used for TDD-based unit tests as you add new
code. It’s not necessary to separate characterization tests from other tests by a
target, but, this way, you’ll have a clear idea of what the goals of these tests are.
When it’s done, the CalendarViewControllerTests group should look like this:
First, add the app module import to the top of the file:
raywenderlich.com 237
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
Breaking dependencies
A logical place to start is where events are loaded into the calendar. If you add
birthdays to the list of events, you want to make sure not to break the existing event
functionality.
func testLoadEvents_getsData() {
The next step is to have the view controller load events, but if you look in
CalendarViewController, you’ll notice this is done by a call made in
viewWillAppear(_:). This method is hard to test since that would mean performing
view lifecycle events and dealing with unknown side effects.
To make testing easier, refactor the view controller so that loading events don’t
require calling viewWillAppear(_:). Select the last two lines of
viewWillAppear(_:) in CalendarViewController.swift. Then, select Editor ▸
Refactor ▸ Extract to Method. Name this new method loadEvents. Delete the
fileprivate modifier so that your tests can access this method.
// when
sut.loadEvents()
This kicks off the events load, but you’re not yet ready to confirm if the data loaded.
raywenderlich.com 238
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
// then
wait(for: [exp], timeout: 2)
print(sut.events)
This waits for the events to load and then prints them out to the console.
Build and test testLoadEvents_getsData(), then take a look at the console. You
should see something similar to the following:
Replace the print() in your test with the following. Update the dates to match the
values you saw in the console — with some extra formatting. For each date copied
from the console, replace the space between the date and time with a T, and remove
the space between the time and timezone:
raywenderlich.com 239
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
"""
let data = Data(eventJson.utf8)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let expectedEvents = try? decoder.decode([Event].self, from:
data)
XCTAssertEqual(sut.events, expectedEvents)
Here, you’ve hard-coded an events JSON payload and decoded it. The assert validates
that your payload matches the one in sut.events, which was loaded by
loadEvents().
Note: The actual date will differ since the sample backend is coded to return
events relative to your current date. This points out an actual problem you’ll
experience connecting to a “live” backend — the data may change and make
your tests unreliable. Fortunately, you won’t stay in this zone for long.
Now, run the test and it will still pass, but this time with an actual assert.
To work around this instability, you need to break dependencies until the test no
longer depends on live API calls. “Restful Networking,” covers the theories and
strategies for how to do this. In this next step, you’ll do a light version of that using a
mock that overrides production code. This way you will to be able to proceed on the
original goal: adding birthdays.
This subtle change from a computed variable to a stored one will allow you to
replace it in the test. You should re-run the test to verify that this change did not
break any of the characterized behavior.
raywenderlich.com 240
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
When you’re done, the CharacterizationTests group will look like this:
MyBiz uses the API class to communicate with its backend. Here, you’ve created an
API subclass that overrides getEvents(), calling eventsLoaded(events:) with
mock data rather than making a service call. This is a baby step towards refactoring
out the networking calls to make stable tests that can cover a range of cases.
mockAPI = MockAPI()
sut.api = mockAPI
mockAPI = nil
raywenderlich.com 241
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
func testLoadEvents_getsData() {
// given
let eventJson = """
[{"name": "Alien invasion", "date":
"2019-04-10T12:00:00+0000",
"type": "Appointment", "duration": 3600.0},
{"name": "Interview with Hydra", "date":
"2019-04-10T17:30:00+0000",
"type": "Appointment", "duration": 1800.0},
{"name": "Panic attack", "date":
"2019-04-17T14:00:00+0000",
"type": "Meeting", "duration": 3600.0}]
"""
let data = Data(eventJson.utf8)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let expectedEvents = try! decoder.decode([Event].self, from:
data)
mockAPI.mockEvents = expectedEvents
// when
let predicate = NSPredicate { _, _ -> Bool in
!self.sut.events.isEmpty
}
let exp = expectation(for: predicate, evaluatedWith: sut,
handler: nil)
sut.loadEvents()
// then
wait(for: [exp], timeout: 1)
XCTAssertEqual(sut.events, expectedEvents)
}
This uses expectedEvents, loaded from hard coded data, to seed the mockAPI. It
then tests that those values come back out when the events are loaded. Now, there is
no more worry about the date of running the test. Run the test, and you should see it
pass, regardless of what day you run it. That’s because the data is frozen forever in
the test JSON.
Over the next few chapters, you’ll further refactor the API class so that the Mock can
implement a protocol rather than override the production code. And then the final
step would be to break up the API protocol into smaller, functional protocols so each
screen only needs to be concerned with its piece.
raywenderlich.com 242
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
It’s important to remember that the goal with this characterization test is not to
ensure correctness, but rather to document what the code actually does. This way,
you’ll be able to identify when later changes modify behavior.
Having tests like this one in place provides confidence that subsequent refactors will
preserve the app’s behavior. Generally, you’ll want to characterize a little more
behavior than this before making changes — for example, capturing error and
boundary conditions.
Writing tests
Now, it’s time to add the birthday feature. Since this will be new code, you’ll use TDD
to make sure there are tests in place and use those tests to guide your code.
1. Add a new iOS Unit Testing Bundle target to the project. Name it MyBizTests.
This target will be for TDD-style tests that cover the new code.
2. Delete MyBizTests.swift.
4. In that group, add a new Unit Test Case Class, named CalendarModelTests.
raywenderlich.com 243
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
To improve the readability, stability and testability of the codebase while also adding
new features, you’ll create a model class that extracts the data logic out of the view
controller; this will be done with a new class, CalendarModel.
import XCTest
@testable import MyBiz
This uses CalendarModel as the SUT, and you’ll get compile errors since it doesn’t
yet exist.
Add a new swift file named CalendarModel.swift to this group and replace its
contents with the following:
class CalendarModel {
init() {}
}
Start with a basic piece — calculating birthday events from the employee list.
raywenderlich.com 244
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
raywenderlich.com 245
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
func testModel_whenGivenEmployeeList_generatesBirthdayEvents() {
// given
let employees = mockEmployees()
// when
let events = sut.convertBirthdays(employees)
// then
let expectedEvents = mockBirthdayEvents()
XCTAssertEqual(events, expectedEvents)
}
You’ll need to add code to get this to compile. In Employee.swift, add the following
below let directReports: [String]:
This adds birthday as a data field and a description of the expected date format. For
this exercise, you can safely assume this format is an iron-clad contract.
This needs the specified raw value to bridge between the lower-case enum
convention and the upper-case server convention.
case .birthday:
return "! "
This will be used in populating the title of the birthday event in the calendar detail
view.
raywenderlich.com 246
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
This method takes an array of employees and returns corresponding events for their
upcoming birthdays.
Still in CalendarModelTests.swift, add the following test to the end of the class:
func testModel_whenBirthdaysLoaded_getsBirthdayEvents() {
// given
let exp = expectation(description: "birthdays loaded")
// when
var loadedEvents: [Event]?
sut.getBirthdays { res in
loadedEvents = try? res.get()
exp.fulfill()
}
// then
wait(for: [exp], timeout: 1)
let expectedEvents = mockBirthdayEvents()
XCTAssertEqual(loadedEvents, expectedEvents)
}
raywenderlich.com 247
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
func getBirthdays(
completion: @escaping (Result<[Event], Error>) -> Void) {
}
But to get it to pass, you’ll need to build out some API-based functionality.
init(api: API) {
self.api = api
}
Also, delete parameterless init() method, since there is no default value for API.
This makes it possible to inject an API object, which will be used to fetch data from
the server. There is also a variable to store a callback that you’ll use next.
birthdayCallback = completion
api.delegate = self
api.getOrgChart()
This stores that completion block and calls into the api to get the employee list.
Next, add the following delegate extension at the bottom of the file:
raywenderlich.com 248
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
You don’t want to rely on this network request for your test. Go back to the test and
use a mock API.
To fix this, open MockAPI.swift, and add it to both test targets in the file inspector:
In setUpWithError() replace:
sut = CalendarModel()
mockAPI = MockAPI()
sut = CalendarModel(api: mockAPI)
raywenderlich.com 249
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
mockAPI = nil
// MARK: - Org
var mockEmployees: [Employee] = []
Finally, open CalendarModelTests.swift and add the following to the given section
of testModel_whenBirthdaysLoaded_getsBirthdayEvents():
mockAPI.mockEmployees = mockEmployees()
This passes the mockEmployees you defined to the MockAPI, so they’ll be returned.
Build and test, and now the test will pass!
Now, you’ve added new code using TDD. This also reuses some of the
characterization test code, which was the mock that was used to break the API
dependency for testing. This is the dual focus of working with legacy code: focus
shifting between adding new code and characterizing and refactoring existing code.
To do that, you’ll need to pull the events functionality into the model class.
func testModel_whenEventsLoaded_getsEvents() {
// given
let expectedEvents = mockEvents()
mockAPI.mockEvents = expectedEvents
let exp = expectation(description: "events loaded")
raywenderlich.com 250
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
// when
var loadedEvents: [Event]?
sut.getEvents { res in
loadedEvents = try? res.get()
exp.fulfill()
}
// then
wait(for: [exp], timeout: 1)
XCTAssertEqual(loadedEvents, expectedEvents)
}
This tests that the main events are loaded into the model as well. There are a few
steps to get this to work.
First, add this helper function to MockAPI.swift, outside of the class, so it can be
easily reused later.
This is a static set of test events that can be used when events are needed.
Next, you need to parallel the work you did for getBirthdays(completion:) in
CalendarModel for regular events.
raywenderlich.com 251
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
func getEvents(
completion: @escaping (Result<[Event], Error>) -> Void) {
eventsCallback = completion
api.delegate = self
api.getEvents()
}
This stores a callback block and uses the api class to get the events.
Now, the model tests will pass, and you’re halfway there. The next step is to update
the view controller with the new model methods.
raywenderlich.com 252
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
mockAPI = MockAPI()
sut.api = mockAPI
sut.loadViewIfNeeded()
}
func testLoadEvents_getsBirthdays () {
// given
mockAPI.mockEmployees = mockEmployees()
let expectedEvents = mockBirthdayEvents()
// when
let predicate = NSPredicate { _, _ -> Bool in
!self.sut.events.isEmpty
}
let exp = expectation(
for: predicate,
evaluatedWith: sut,
handler: nil)
sut.loadEvents()
// then
wait(for: [exp], timeout: 1)
XCTAssertEqual(sut.events, expectedEvents)
}
This is very similar to the characterization test class for this controller, except that
this has a test case for loading birthday events.
raywenderlich.com 253
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
func loadEvents() {
events = []
model.getBirthdays { res in
if let newEvents = try? res.get() {
self.events.append(contentsOf: newEvents)
self.calendarView.reloadData()
}
}
model.getEvents { res in
if let newEvents = try? res.get() {
self.events.append(contentsOf: newEvents)
self.calendarView.reloadData()
}
}
}
Finally, you can delete the APIDelegate extension, as the view controller is no
longer the API delegate.
Now, build and test again, and all should pass. Congratulations! You’ve added
employee birthdays to the app’s calendar without breaking anything. The HR
director will be so happy.
raywenderlich.com 254
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
Challenges
The next few chapters will cover these types of changes in greater detail, so the
challenge here is pretty light:
Key points
In this chapter, you added a “small” feature of placing calendar events for employee
birthdays following the code change algorithm. Here are the key points:
• Characterization tests let you discover the existing behavior and ensure that the
behavior doesn’t break without warning.
• Don’t change any more code than you have to without writing tests first.
raywenderlich.com 255
iOS Test-Driven Development by Tutorials Chapter 11: Legacy Problems
The rest of the chapters in this section expand upon these ideas, giving more
specifics and details about the sticking points when applying the code change
algorithm. Chapter 12, “Dependency Maps,” covers dependency mapping, Chapter
13, “Breaking Up Dependencies,” covers modularization and refactoring code
architecture, Chapter 14, “Modularizing Dependencies”, and Chapter 15, “Adding
Features to Existing Large Classes” are dedicated to making big changes.
It’s also helpful if you skipped the last section on networking to go back over and
skim it. Refactoring a poorly architected, backend-heavy application like MyBiz will
require testing and moving code that calls into the networking layer.
raywenderlich.com 256
12 Chapter 12: Dependency
Maps
By Joshua Greene & Michael Katz
Before you start making changes in a large project, you first need to understand how
the system works and how its classes are related. This chapter will help you visualize
this using a tool called dependency maps. You’ll learn:
Feel free to continue using your project from the last chapter, or start fresh from this
chapter’s starter project. For the best hands-on experience, you’ll need a pencil, red
marker, green marker and paper. This is going to get… analog!
raywenderlich.com 257
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
Getting started
You may be wondering, “What exactly is a dependency map?” Great question!
Dependency maps are a way to illustrate dependencies between types. Its primary
purpose is to help you understand how a change will affect an entire system. You can
use dependency maps to identify change points, test points and places where you
can pull out types to make your app more modular.
Before making a code change, your first step is to identify what the new behavior
should be. In this case, your job is to move MyBiz’s login functionality into a separate
module. Long-term, the plan is to use the login module in multiple apps.
Moving login into a separate module also has side benefits: Faster incremental
compile times, separation of unit tests and more.
Consequently, you’ll need to break up dependencies to make this possible. This is the
perfect problem a dependency map can help you solve.
Since this is about login, the LoginViewController is a good starting point. Open
MyBiz.xcodeproj and select LoginViewController.swift from the File Hierarchy.
Do you have that pencil and paper handy? (Or a drawing program?) Write
LoginViewController inside a box in the middle of the paper like this:
raywenderlich.com 258
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
This was very easy to do when iOS apps were written in Objective-C: You’d simply
look at which files were imported. Swift is trickier because it automatically imports
types within the same module and an iOS app itself is a module. Consequently, you
need to actually scroll through a type and see which types are used to determine its
direct dependencies.
Open LoginViewController.swift and you’ll see the first line is import UIKit. This
isn’t surprising because it’s a UIViewController, after all. Because system libraries
are readily imported anywhere, you can skip adding this to your dependency map.
The next dependency you’ll find comes from the api property, which is of type API
and is accessed directly on the AppDelegate. This is interesting and should be added
to your diagram. Do the following:
3. Draw another box to the right of AppDelegate and write API within it.
4. Draw an arrow from the AppDelegate box pointing at the API box. This
indicates AppDelegate depends on API. Even if AppDelegate didn’t actually call
any methods or properties on API, it depends on it simply by having a reference
to it.
The next dependency is Skin, a helper object for styling the view. Add another box
for Skin to the left of LoginViewController and draw an arrow from
LoginViewController pointing at Skin.
raywenderlich.com 259
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
There aren’t any dependencies within viewDidLoad. So, scroll past it and down to
signIn(_:). This method is tricky because its dependencies aren’t explicitly shown.
Rather, the computed properties isEmail and isValidPassword are defined in
Validators.swift and showAlert(title:subtitle:type:skin:) is defined in
UIViewController+Alert.swift.
Draw a new box for Validators to the bottom left of LoginViewController and draw
another box for UIViewController+Alert directly below LoginViewController.
Then, draw one arrow from LoginViewController pointing at Validators and
another arrow pointing at UIViewController+Alert.
Dependency maps show how your code interrelates — the types don’t need to be
classes. Rather, they can be protocols, extensions, files, libraries or anything else that
makes sense for your use case.
raywenderlich.com 260
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
It’s also important to note that dependency maps do not use UML, Archimate or any
other formal specification. Rather, the arrows only indicate that one type depends on
another.
There are several models used in this extension: Event, Employee, Announcement,
Product, PurchaseOrder and UserInfo.
You have a few choices for how you represent this on your dependency map:
1. Draw a separate box for each type. This has the advantage of clearly representing
each type, but it takes up more space. This is a good option if the models have a
complex relationship. For example, dependencies on other types, circular
dependencies on the view controller, etc.
2. Draw a single box for Models. This has the advantage of taking up the least
amount of space, but it doesn’t clearly define which models are used. This is a
good option if the models are simple, don’t have complex relationships and it’s
not important for your use case to show exactly which models are used.
3. Draw a single box for Models and list each within it. This is a tradeoff between
the two options above. It minimizes space but also still clearly defines which
models are used. This is a good option if the models don’t have complex
relationships but you still want to clearly show which models are used.
In this app, the models don’t have complex relationships. However, it’s a code smell
that LoginViewController depends on so many models and you should clearly
show this on the diagram. Hence, let’s go with the last option.
Draw another box for Models to the bottom left of LoginViewController and list
each type within it. Then, draw an arrow from LoginViewController pointing at
Models.
raywenderlich.com 261
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
Fantastic! You’ve reached the end of LoginViewController and you’ve identified all
of its direct dependencies. However, its dependencies also have dependencies
themselves. These are so-called “secondary dependencies” of
LoginViewController.
raywenderlich.com 262
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
The first interesting dependency you’ll find is Configuration. Draw a new box for
Configuration above AppDelegate and draw an arrow from AppDelegate pointing
at it.
AppDelegate also has a dependency on API but you identified this earlier and
already have it drawn on the map.
raywenderlich.com 263
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
This file starts by defining APIDelegate. Since its methods use all of the previously-
identified models, it depends on them. Draw an arrow from APIDelegate to Models
to show this.
Even though APIDelegate is defined within the same file as API, this doesn’t
actually make API depend on APIDelegate. If needed, you could easily move
APIDelegate to a separate file.
However, API later declares a delegate property of type APIDelegate and this
created a dependency on it. Draw an arrow from API pointing to APIDelegate to
show this.
API also declares a property for server, which is a String that it gets from the
configuration on the AppDelegate. Hence, it depends on both Configuration and
AppDelegate. Draw an arrow from API pointing at AppDelegate and another arrow
pointing at Configuration to show this.
Oh no! You’ve found another circular dependency between AppDelegate and API.
Again, you’ll deal with this later.
raywenderlich.com 264
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
API also has a new dependency on Token. Draw a new box to the top right of API for
Token and draw an arrow from API pointing at it.
Lastly, API also has a dependency on URLSession. However, this is defined within
Foundation. As you did before with the system dependency on UIKit, you don’t
need to explicitly indicate this in the diagram.
The rest of this class doesn’t introduce any new dependencies, so you can move onto
the next file you need to inspect: UIViewController+Alert.swift. This file declares
an extension on UIViewController that has one new dependency on
ErrorViewController.
raywenderlich.com 265
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
As a sanity check to verify you’ve gone far enough, do a text search for
LoginViewController to see if any other files reference it. You’ll find
ErrorViewController actually has a reference to LoginViewController.
ErrorViewController also has a property of type Skin. Add another arrow from
ErrorViewController pointing at Skin.
raywenderlich.com 266
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
In this case, your goal is to pull login into a separate module. Anything that prevents
this is a problematic dependency.
Practically speaking, how can you identify these problematic dependencies? Ask the
following of each direct dependency of LoginViewController:
2. Is the dependency circular? If so, you may need to break one or both sides.
3. Does the dependency have many secondary dependencies? If so, it’s going to be
difficult to pull it into the module.
4. Does it make sense for the dependency to be pulled into the same module? Even
if it’s possible to pull the dependency into the same module, it may not be
appropriate to do so.
It may make sense to create another module but you should carefully plan what’s
best to do. This is especially true if the dependency is used in many places
throughout the app.
First, are any dependencies on the AppDelegate? Yes, there are. The problematic
relationships are the arrows that point to to the AppDelegate. This includes
LoginViewController depending on the AppDelegate and API depending on the
AppDelegate.
raywenderlich.com 267
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
Got your red marker handy? Highlight both of these arrows in red to indicate they’re
problematic.
You won’t ever be able to pull the AppDelegate into a module, so it’s problematic in
general. Highlight the AppDelegate box in red to indicate this.
Are there any circular dependencies? Yep, there are those too.
LoginViewController depends on API, which it gets from the AppDelegate. In turn,
AppDelegate depends on LoginViewController.
No, actually. AppDelegate could depend on the new login module and, in turn, it
could still set up the LoginViewController. Hence, it’s not problematic in terms of
your goal.
1. API is used in other places throughout the app, so it would be difficult to pull it
into the login module.
2. API doesn’t conceptually make sense in the login module. It knows about all of
the models and networking calls within the app. This is way beyond the scope
that login should know about.
Hence, highlight the LoginViewController-to-API arrow and the API box in red to
show these are problematic.
Does the login module really need to know about any of these models? The two
APIDelegate methods that are related to login are loginFailed(error:) and
loginSucceeded(userId:). Neither of these actually use these models!
raywenderlich.com 268
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
If Skin were only used by LoginViewController, it might be okay to pull it into the
same module. However, it’s also used by ErrorViewController, so it’s not okay to
do this. Highlight the LoginViewController-to-Skin relationship and the Skin box
itself in red.
raywenderlich.com 269
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
Should Validators be moved into the same login module? Yes, actually! It’s only
used by LoginViewController and its methods are explicitly related to login
validation. Highlight this relationship and the Validators box in green to indicate
it’s okay to move.
raywenderlich.com 270
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
If you find that a dependency is okay, you do need to evaluate its secondary
dependencies. It could turn out some of the secondary dependencies are problematic
and you’ll need to add them somehow.
Once you’ve completed this for all relevant dependencies, you’re done with
evaluating the dependencies on your map!
In this case, you’ve actually completed both of these already, so your map is good as
is.
Of course, there’s still the issue of actually addressing the problematic relationships.
You usually cannot simply delete a relationship, as it’s providing some sort of useful
functionality.
Practically speaking then, how can you fix these problems? Using TDD, of course!
Yes, there’s more to it than simply “magically TDD code it” but you’ll learn all about
this in the next chapter!
Key points
You learned about dependency maps in this chapter. Here are their key points:
raywenderlich.com 271
iOS Test-Driven Development by Tutorials Chapter 12: Dependency Maps
raywenderlich.com 272
13 Chapter 13: Breaking Up
Dependencies
By Michael Katz
It’s always safer to make a change when you have tests in place already. In the
absence of existing tests, however, you may need to make changes just to add tests!
One of the most common reasons for this is tightly-coupled dependencies: You can’t
add tests to a class because it depends on other classes that depend on other
classes… View controllers especially are often victims of this issue.
By creating a dependency map in the last chapter, you were able to find where you
want to make changes and, in turn, where you really need to have tests.
This chapter will teach you how to break dependencies safely to add tests around
where you want to change.
raywenderlich.com 273
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
Getting started
As a reminder, in this chapter, you will build upon and improve the MyBiz app. The
powers that be want to build a separate expense reporting app. In the interest of DRY
(Don’t Repeat Yourself) they want to reuse the login view from your app in the new
app. The best way to do that is to pull the login functionality into its own framework
so it can be reused across projects.
The login view controller is the obvious place to start because it presents the login
UI and uses all of the other code related to login. In the previous chapter, you built
out a dependency map for the login view controller and identified some change
points. You’ll use that map as a guide to break up the dependencies so login can
stand alone.
raywenderlich.com 274
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
Add a new Unit Test Case Class file in CharacterizationTests ▸ Cases named
LoginViewControllerTests.swift.
import XCTest
@testable import MyBiz
// 1
override func setUpWithError() throws {
try super.setUpWithError()
sut = UIStoryboard(name: "Main", bundle: nil)
.instantiateViewController(withIdentifier: "login")
as? LoginViewController
UIApplication.appDelegate.userId = nil
sut.loadViewIfNeeded()
}
// 2
override func tearDownWithError() throws {
sut = nil
UIApplication.appDelegate.userId = nil //do the "logout"
try super.tearDownWithError()
}
func testSignIn_WithGoodCredentials_doesLogin() {
// given
sut.emailField.text = "agent@shield.org"
sut.passwordField.text = "hailHydra"
// when
// 3
let predicate = NSPredicate { _, _ -> Bool in
UIApplication.appDelegate.userId != nil
}
let exp = expectation(
for: predicate,
evaluatedWith: sut,
handler: nil)
raywenderlich.com 275
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
sut.signIn(sut.signInButton!)
// then
// 4
wait(for: [exp], timeout: 5)
XCTAssertNotNil(
UIApplication.appDelegate.userId,
"a successful login sets valid user id")
}
}
This code handles the basic sign-in scenario in the following ways:
1. In setUpWithError() creates the sut from the main storyboard and loads it. It
also clears the shared userId from AppDelegate. It is a proxy for the “being
logged in” state. Since the app delegate is persisted across tests, it’s important to
clear it out so each test starts off as not logged in.
3. In the test itself, this predicate expectation waits for the userId state to be set in
order to fulfill the expectation. This way, the test knows it is safe to proceed.
4. The test waits for the userId to be set and then asserts that it is not nil. Even
though the expectation will also time out for the same condition, it’s always good
to have an explicit assert — rather than using the timeout to catch the error.
Remember to start the backend before running this test — or it will fail! This test
requires live responses. You have not broken its dependency on the real backend
implementation yet. For instructions on setting up and starting the MyBiz backend,
see Chapter 11, “Legacy Problems”.
Build and test — and the test passes! This example short-circuits the discovery part
of characterization tests, as described in Chapter 11, “Legacy Problems”. It’s an
important part of the process but out of the scope of this chapter.
Next, capture the main error case in a test. This flow where an invalid login response
is shown to the user is an important function of the view controller. This also helps
cover detangling the ErrorViewController later.
func testSignIn_WithBadCredentials_showsError() {
// given
sut.emailField.text = "bad@credentials.ca"
sut.passwordField.text = "Shazam!"
raywenderlich.com 276
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
// when
let predicate = NSPredicate { _, _ -> Bool in
UIApplication.appDelegate
.rootController?.presentedViewController != nil
}
let exp = expectation(
for: predicate,
evaluatedWith: sut,
handler: nil)
sut.signIn(sut.signInButton!)
// then
wait(for: [exp], timeout: 5)
let presentedController =
UIApplication.appDelegate
.rootController?
.presentedViewController
as? ErrorViewController
XCTAssertNotNil(
presentedController,
"should be showing an error controller")
XCTAssertEqual(
presentedController?.alertTitle,
"Login Failed")
XCTAssertEqual(
presentedController?.subtitle,
"Unauthorized")
}
• The when section creates an expectation that waits for a modal view to be shown,
supposedly the error view.
• The then section, after waiting for the expectation, checks that the modal is an
ErrorViewController and that the alertTitle and subtitle match the
expected response for bad credentials.
These conditions are great because they test for a specific error, rather than a broken
network connection. However, this test is then quite brittle and dependent on server-
side text.
Build and test. Yet again this will pass and you’ve covered the two main (existing)
flows through this view controller. As a challenge, write tests for the validator
conditions as well (bad email and password).
raywenderlich.com 277
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
You can use Swift’s strict type system to make it easier when removing
dependencies. For example, go to API.swift and search the file for uses of
AppDelegate.
The first usage of AppDelegate creates the server constant. This one is quite simple
to deal with. You’ll just move it from being set automatically to an init parameter.
init(server: String) {
self.server = server
session = URLSession(configuration: .default)
}
Next, update the line for let server = with the following:
If you build the app, the compiler will tell you what needs to happen next. Go to
AppDelegate.swift and replace the instantiation of API with:
Going back to the tests, open MockAPI.swift, and add the following init method to
the MockAPI class:
init() {
super.init(server: "http://mockserver")
}
Build and test and the tests will still pass. This was a simple move so it doesn’t need
any additional testing beyond the tests already in place.
raywenderlich.com 278
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
let userLoggedOutNotification =
Notification.Name("user logged out")
This creates a new notification that informs the rest of the app that the user logged
out.
Before proceeding, it’s time to create some tests! Create a New Unit Test Class
named APITests in the MyBizTests ▸ Cases target.
import XCTest
@testable import MyBiz
// 1
override func setUpWithError() throws {
try super.setUpWithError()
sut = MockAPI()
}
// 2
func givenLoggedIn() {
sut.token = Token(token: "Nobody", userID: UUID())
}
// 3
func testAPI_whenLogout_generatesANotification() {
// given
givenLoggedIn()
let exp = expectation(
forNotification: userLoggedOutNotification,
raywenderlich.com 279
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
object: nil)
// when
sut.logout()
// then
wait(for: [exp], timeout: 1)
XCTAssertNil(sut.token)
}
}
1. This test sets up an API as the system-under-test. It’s okay that it’s a MockAPI
since the methods under test are inherited from API. This short-term
compromise is okay because its modified behavior is not part of the work of
breaking out LoginViewController.
2. There’s one helper method givenLoggedIn that sets a fake token to simulate the
“logged in” state for the SUT.
3. The test itself is pretty simple, calling logout and waiting for the
UserLoggedOutNotification. The test also asserts that the token was reset to
nil.
Run the tests. You’ll see that this new test does not yet pass. To get the test to pass,
open API.swift and replace the entire logout method with the following:
func logout() {
token = nil
delegate = nil
let note = Notification(name: userLoggedOutNotification)
NotificationCenter.default.post(note)
}
Instead of calling back directly into AppDelegate, this calls that code indirectly
through notification center. Now, API no longer has any direct dependency on the
delegate. However, to keep the app running, you need to make the following
changes.
raywenderlich.com 280
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
Open AppDelegate.swift, and add the following method at the end of the class:
func setupListeners() {
NotificationCenter.default
.addObserver(
forName: userLoggedOutNotification,
object: nil,
queue: .main) { _ in
self.showLogin()
}
}
setupListeners()
Build and test again. You can also build and run and then can go through a full login/
logout cycle to see that everything still works.
1. Configuring the object at instantiation. API now has its server URL set at init
time rather than calling into a singleton later.
2. Replacing direct calls with events. Logout events are propagated through a
Notification instead of a hard-coded callback.
raywenderlich.com 281
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
The other technique used here was to pass in the configuration to the init method.
Here, all that was needed was the base URL for the server and there was no
functional reason to reach back to the AppDelegate. In fact, API no longer relies on
any UI code: You can go ahead and remove the import UIKit line from the top of
the file. Now, API can be used in all sorts of other apps that are built upon the same
API!
You can update the dependency map with a little white-out to reflect API’s
newfound freedom from the AppDelegate.
raywenderlich.com 282
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
Now, the api can be set externally to the class instead of depending directly on
AppDelegate.
Note: For most classes, using let and injecting the value through an init is
the way to go. For view controllers, the injection will have to be done after
instantiation, usually in a prepare(for:sender:) with a segue or just before
presentation when done through code, as you’ll see below.
To make the app still work, you have to set the api variable in a few places. In
SceneDelegate.swift, add the following method
application(_:didFinishLaunchingWithOptions:):
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
let loginViewController =
UIApplication.appDelegate.rootController as?
LoginViewController
loginViewController?.api = UIApplication.appDelegate.api
}
This sets that api when the app, or more correctly, the window scene is first loaded.
At this point the login controller will be instantiated but not yet had its view loaded.
Next, change showLogin() by adding the following line immediately before setting
the rootViewController:
loginController?.api = api
raywenderlich.com 283
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
Finally, since there was already a test to cover the view controller, you’ll need to
update the test class. In LoginViewControllerTests.swift, add to the bottom of
setUp(), just above sut.loadViewIfNeeded():
sut.api = UIApplication.appDelegate.api
If you build and either run or test, the app should continue to behave as before even
though you’ve broken one dependency.
Add the following code to API.swift, just underneath the import statements:
let userLoggedInNotification =
Notification.Name("user logged in")
This adds a new notification for login and a key that will be used to get the user’s ID.
Before modifying more of the code, add the following test for the notification in
APITests.swift:
func testAPI_whenLogin_generatesANotification() {
// given
var userInfo: [AnyHashable: Any]?
let exp = expectation(
forNotification: userLoggedInNotification,
object: nil) { note in
userInfo = note.userInfo
return true
}
// when
sut.login(username: "test", password: "test")
// then
wait(for: [exp], timeout: 1)
let userId = userInfo?[UserNotificationKey.userId]
raywenderlich.com 284
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
XCTAssertNotNil(
userId,
"the login notification should also have a user id")
}
This test calls login(username:password:) and waits for the notification and
checks that the notification has a userId in its userInfo. Run your tests and you
will see that this test will not yet pass.
To get this test to pass, open API.swift and add the following to
handleToken(token:), just before the call to loginSucceeded(userId:):
This code will post the Notification. To make sure it gets called in the test, add the
following override to MockAPI.swift:
Now, the test will build and pass! But you’re not done yet. You now need to move the
login functionality from LoginViewController to AppDelegate.
raywenderlich.com 285
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
This does the same logic as the loginSucceeded(userId:) callback. Next, add the
following to setupListeners:
NotificationCenter.default
.addObserver(
forName: userLoggedInNotification,
object: nil,
queue: .main) { note in
if let userId =
note.userInfo?[UserNotificationKey.userId] as? String {
self.handleLogin(userId: userId)
}
}
If you build and test, the app will still have the same login/logout functionality —
even if the chain of events from a login is now a little different.
raywenderlich.com 286
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
It’s time to add characterization tests. Create a new Unit Test Case Class file named
ErrorViewControllerTests.swift in CharacterizationTests/Cases and replace its
contents with the following:
import XCTest
@testable import MyBiz
func whenDefault() {
sut.type = .general
sut.loadViewIfNeeded()
}
func whenSetToLogin() {
sut.type = .login
sut.loadViewIfNeeded()
}
func testViewController_whenSetToLogin_primaryButtonIsOK() {
// when
whenSetToLogin()
// then
XCTAssertEqual(sut.okButton.currentTitle, "OK")
}
func testViewController_whenSetToLogin_showsTryAgainButton() {
// when
whenSetToLogin()
raywenderlich.com 287
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
// then
XCTAssertFalse(sut.secondaryButton.isHidden)
XCTAssertEqual(
sut.secondaryButton.currentTitle,
"Try Again")
}
func testViewController_whenDefault_secondaryButtonIsHidden()
{
// when
whenDefault()
// then
XCTAssertNil(sut.secondaryButton.superview)
}
}
This adds three simple tests for the state of each button in the error view controller:
Ideally, there should also be a test for the secondary button that actually results in a
try again action. Unfortunately, in its current state, it would be difficult to write a
unit test due to how intertwined this class is with LoginViewController. In fact,
that is one of the main motivators for breaking the dependency.
To write a test in the current state, you would have to script a good portion of the
app to get the ErrorViewController to be set up correctly and bring in a fair
amount of overall state to check that there was an effect when tapping the button.
So, leave it for now. You’ll capture the try again behavior as part of breaking up the
dependency.
raywenderlich.com 288
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
The way to break out this dependency is with a form of the Command pattern. That is,
you’ll provide the necessary view information and behavior to the view controller so
the button can invoke the try again behavior at run time. This pattern is a way for
one object to provide implementation to another.
You’ll do this by adding the following struct at the top of the ErrorViewController
class above enum AlertType:
struct SecondaryAction {
let title: String
let action: () -> Void
}
This struct contains the view information — title — and the behavior — action
block. This is how view controllers will configure the error view going forward.
Create a new Unit Test Case Class in the MyBizTests target, named
ErrorViewControllerTests.swift. Then replace its contents with the following:
import XCTest
@testable import MyBiz
func testSecondaryButton_whenActionSet_hasCorrectTitle() {
// given
let action = ErrorViewController.SecondaryAction(
raywenderlich.com 289
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
title: "title") {}
sut.secondaryAction = action
// when
sut.loadViewIfNeeded()
// then
XCTAssertEqual(sut.secondaryButton.currentTitle, "title")
}
func testSecondaryAction_whenButtonTapped_isInvoked() {
// given
let exp = expectation(description: "secondary action")
var actionHappened = false
let action = ErrorViewController.SecondaryAction(
title: "action") {
actionHappened = true
exp.fulfill()
}
sut.secondaryAction = action
sut.loadViewIfNeeded()
// when
sut.secondaryAction(())
// then
wait(for: [exp], timeout: 1)
XCTAssertTrue(actionHappened)
}
}
This test follows the same pattern as your other view controller tests. There are two
test cases: testSecondaryButton_whenActionSet_hasCorrectTitle and
testSecondaryAction_whenButtonTapped_isInvoked. These cover only the new
functionality of using the SecondaryAction. The first tests that the button’s title is
set appropriately. The second checks that tapping the button performs the action
block.
Of course, this test won’t yet compile, let alone run. Now, head back to
ErrorViewController.swift.
Delete the AlertType enum. Then, replace the type variable with:
This property allows you to store the optional action. Then add this helper method:
raywenderlich.com 290
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
secondaryButton.removeFromSuperview()
return
}
secondaryButton.setTitle(action.title, for: .normal)
}
To use it, in viewDidLoad, replace the switch type {...} statement with:
updateAction()
Now, when the view is loaded, it will call the helper method to set up the button.
This replaces the call to the LoginViewController with a simple invocation of the
action block.
Now, if you try to build the project, you’ll see a compiler error. To begin fixing it,
navigate to UIViewController+Alert.swift. Update the
showAlert(title:subtitle:type:skin:) function signature with:
func showAlert(
title: String,
subtitle: String?,
action: ErrorViewController.SecondaryAction? = nil,
skin: Skin? = nil
) {
Next, replace:
alertController.type = type
alertController.secondaryAction = action
raywenderlich.com 291
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
This updated method uses the new showAlert signature to use the new action
instead of type.
Build and test and your tests will compile and pass. This means
ErrorViewController is free from LoginViewController and you’re ready to move
on to create a separate login module!
raywenderlich.com 292
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
Take a look at your updated dependency map. There is a lot less red now:
raywenderlich.com 293
iOS Test-Driven Development by Tutorials Chapter 13: Breaking Up Dependencies
Challenge
This chapter’s challenge is a simple one. You may have noticed that input validation
was left out of the LoginViewControllerTests characterization tests. Your
challenge is to add them now, so you will have a more robust test suite before
moving the code into its own module in the next chapter. For an additional
challenge, add unit tests for the Validators functions in MyBizTests.
Key Points
• Dependency Maps are your guide to breaking dependencies.
It’s also worth revisiting Section 3 on networking. The techniques taught in this
section will help explain how to fix LoginViewControllerTests so that you could
break up API and test its methods without having to use the MockAPI class.
raywenderlich.com 294
14 Chapter 14: Modularizing
Dependencies
By Michael Katz
Splitting an app into modules, whether they be frameworks, static libraries or just
structurally-isolated code, is an important part of clean coding. Having files with
related concerns at the same level of abstraction makes your code easier to maintain
and reuse across projects.
In this chapter, you’ll continue the work from the last chapter, further breaking
MyBiz into modules so you can reuse the login functionality. You’ll learn how to
define clean boundaries in the code to create logical units. Through the use of tests,
you’ll make sure the new architecture works and the app continues to function.
raywenderlich.com 295
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
Even if you completed the challenge from the last chapter, start with this chapter’s
starter project. That way, you won’t have any discrepancies with file or test names.
1. From the Project editor, create a new target. Choose the Framework template to
create a dynamic framework and click Next.
3. Make sure you’ve checked Include Unit Tests. This sets you up to add tests right
away!
4. Click Finish.
6. Select Build Phases and make sure Login is the only dependency. Remove
MyBiz as a dependency.
raywenderlich.com 296
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
Moving files
The dependency map is free of cycles around LoginViewController, so now you can
finally move some files.
Double-check that you’ve changed these files’ Target Membership from MyBiz to
Login.
Build and run your app and you’ll see a lot of red errors. LoginViewController may
now be free of bad dependencies, but it’s not free of dependencies altogether. You’ll
see by the number of issues that this won’t be as easy as it might first seem.
raywenderlich.com 297
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
Create a new Framework named UIHelpers using the same steps as above. Be sure
to also Include Unit Tests.
• UIViewController+Alert.swift
• ErrorViewController.swift
• Skin.swift
• Styler.swift
• Colors.swift
To simplify this refactoring process, switch the scheme to the auto-created one for
UIHelpers. This way, only this library will build, which will reduce the noise from
other build errors.
Move the UI substruct from Configuration.swift to this new file and rename it
UIConfiguration:
raywenderlich.com 298
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
This has to be set before you can use it; it’s no longer guaranteed to be set.
In style(button:skin:), replace:
button.layer.cornerRadius
= CGFloat(configuration.ui.button.cornerRadius)
button.layer.borderWidth
= CGFloat(configuration.ui.button.borderWidth)
button.layer.cornerRadius
= CGFloat(configuration?.button.cornerRadius ?? 0)
button.layer.borderWidth
= CGFloat(configuration?.button.borderWidth ?? 0)
raywenderlich.com 299
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
Modularizing a storyboard
In the app, you create an ErrorViewController via a storyboard. You do this
explicitly in UIViewController+Alert.swift through the Main storyboard. Since this
storyboard lives in an app module, it’s not available to this framework.
To fix this, move the view controller to a new storyboard in the UIHelpers
framework by following these steps:
2. Now, you can use an Xcode tool to help. Select Editor ▸ Refactor to
Storyboard….
3. Name it UIHelpers.storyboard.
5. Uncheck the MyBiz target and check the UIHelpers target instead.
6. Click Save.
This now loads the same scene, but from a new storyboard that lives within the
framework.
raywenderlich.com 300
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
Moving tests
What about the tests? You already have some test cases that cover
ErrorViewController. You can move those, too.
Make sure that you’ve enabled the tests in this target. To check, open the Test
Navigator, right-click on UIHelpersTests and select Enable “UIHelperTests”.
Now, you can build and test just the UIHelpers scheme and feel a bit more confident
that this major refactor will work.
Note: For some of the characterization tests, you need a live connection to the
backend. The setup and launch instructions are in Chapter 11, “Legacy
Problems”.
raywenderlich.com 301
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
In the Project editor, select Login. Under Frameworks and Libraries, add
UIHelpers. When that’s done, it should look like this:
import UIHelpers
Next, you’ll have to fix some access levels in UIHelpers. When all the files were in
the same target, the default internal access was fine, but now you’ll need to make
some things public.
• Skin.
• Styler.
• In UIViewController+Alert.swift: showAlert.
• UIConfiguration.
raywenderlich.com 302
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
This now exposes these types and functions for other modules to consume. In this
case, those modules will be Login and MyBiz.
Create a new Swift file named LoginAPI under Login and replace its contents with
the following:
This code accomplishes two life-changing code and architectural clean-ups. First,
LoginAPI only has the one method that concerns Login. Second, it replaces the
obnoxious catch-all delegate with a simple completion block that uses a Result.
Conceptually, it would also make sense to add Logout, but you can save that for a
future improvement.
raywenderlich.com 303
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
4. In the extension, remove the APIDelegate type, and remove every method other
than loginFailed(error:).
Ah, so much cleaner! I can’t understate the power of this change. To see the results
visually, look at this updated dependency map, now API is no longer in the picture:
raywenderlich.com 304
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
First, grab ValidatorsTests.swift and drag it to the LoginTests target, making sure
the target changes to LoginTests as well.
Build and run the Login target and tests to verify everything is working as intended.
The same conditions apply as with UIHelpersTests: Make sure there is no host
application and the target and scheme do not try to build MyBiz.
Fixing MyBiz
Now that you have two new frameworks that contain previously-available code,
you’ll need to fix up the dependencies in their usage project. Switch back to the
MyBiz scheme and you’ll start seeing all sorts of build errors.
Don’t worry, you’ll, tackle them one at a time and the project will straighten out in a
jiffy (or is it giffy? :]).
import UIHelpers
• DateSelectingViewController.swift
• AnnouncementsTableViewController.swift
• CreatePurachaseOrderTableViewController.swift
• PurchasesTableViewController.swift
• OrgTableViewController.swift
raywenderlich.com 305
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
• AddToOrderTableViewController.swift
• Configuration.swift
let ui: UI
This takes care of the UIHelpers framework, but you’ll also need to use the Login
framework.
import Login
import Login
Next, you’ll want to replace the existing login. Still inside API.swift, replace the
existing login(username:password:) and handleToken(token:) with the
following:
func login(
username: String,
password: String,
completion: @escaping (Result<String, Error>) -> Void
) {
let eventsEndpoint = server + "api/users/login"
let eventsURL = URL(string: eventsEndpoint)!
var urlRequest = URLRequest(url: eventsURL)
urlRequest.httpMethod = "POST"
let data = "\(username):\(password)".data(using: .utf8)!
let basic = "Basic \(data.base64EncodedString())"
urlRequest.addValue(
basic,
forHTTPHeaderField: "Authorization")
raywenderlich.com 306
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
task.resume()
}
func handleToken(
token: Token,
completion: @escaping (Result<String, Error>) -> Void
) {
self.token = token
Logger.logDebug("user \(token.user.id)")
DispatchQueue.main.async {
let note = Notification(
name: userLoggedInNotification,
object: self,
userInfo: [
UserNotificationKey.userId: token.user.id.uuidString
])
NotificationCenter.default.post(note)
completion(.success(token.user.id.uuidString))
}
}
raywenderlich.com 307
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
This code is mostly the same as before except instead of calling the delegate, it now
calls the passed in completion block instead. Finally, change the class definition to:
• AnnouncementsTableViewController.swift
• CalendarModel.swift
• OrgTableViewController.swift
• PurchasesTableViewController.swift
• CreatePurachaseOrderTableViewController.swift
• SettingsTableViewController.swift
Build and run MyBiz and you’ll find it now builds. If only that was all you needed to
do…
Open Main.storyboard. Select the Login View Controller Scene. In the Identity
inspector, change the Module to Login. You could also extract a separate storyboard
for the login framework, which is part of this chapter’s challenge, which you’ll come
to later.
Now the app will build and run and work just the same as before.
raywenderlich.com 308
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
This code adds the completion argument introduced when you replaced the login
and handleToken methods above.
In SpyAPI.swift, add the completion argument you created earlier by replacing the
override of login with the following:
raywenderlich.com 309
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
Finally, to use the correct storyboard, replace setUpWithError with the following:
Now, all tests will pass once again, and you can take a deep sigh of relief. The
refactor didn’t break anything!
Wrap up
Pat yourself on the back. Login is now in its own framework and ready to be re-used
in another project. You’ll have to distribute both the Login framework and the
UIHelpers frameworks, but it’s normal for frameworks to have their own
dependencies.
Take a look at the final dependency map, updated to reflect the changes to API:
It’s a nice, clean and hierarchical diagram. There are no cycles and you haven’t
pulled in any extraneous data types or unrelated functionality. Good job!
raywenderlich.com 310
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
Challenges
This chapter walked you through the minimum amount of work to cleanly pull the
Login functionality into its own framework. However, there’s (a lot of!) room for
improvement. Fix up the project by completing any of the following:
• Repurposing those test cases as unit tests by creating a mock LoginAPI so you
don’t have to go through API and the local server.
3. Fix the Logger issue by either bringing it into UIHelpers and passing its
configuration in like Styler OR by creating a logging protocol and attaching it to
the frameworks.
Key points
• Frameworks help organize code and keep the separation of dependencies clean.
raywenderlich.com 311
iOS Test-Driven Development by Tutorials Chapter 14: Modularizing Dependencies
Other great resources are the original Design Patterns book (Gamma et al) which,
although very object-oriented, contains a lot of useful patterns for incrementally
separating dependencies and breaking out functionality. More immediately useful
would be these architecture books at https://www.raywenderlich.com/books:
raywenderlich.com 312
15 Chapter 15: Adding
Features to Existing Classes
By Michael Katz
You won’t always have the time, or it may simply not be feasible, to break
dependencies of a very large class. If you have a deadline to add a new feature and
your app is a bowl of spaghetti, you won’t have the time to straighten it all out first.
Fortunately, there are TDD techniques to tackle this situation.
In this chapter, you’ll learn strategies to add functionality to an existing class, while
at the same time, avoiding modifying it! To do this, you’ll learn strategies like
sprouts and dependency injection.
To demonstrate these ideas, you’ll add some basic analytics to the MyBiz app’s main
view controllers. After all, every business wants to know what their users are doing.
Getting started
Use the back-end and starter projects from this chapter, as they have a few
modifications from the last chapter that you’re going to need. Start up the back end.
As always, refer back to Chapter 11, “Legacy Problems” if you need help getting it
running.
Your objective is to add a screen to view analytics events for each of the five main
view controllers: Announcements, Calendar, Org Chart, Purchase Orders and
Settings. This way, the product owners will be able to identify the most-used screens,
to figure out where to invest time and resources.
raywenderlich.com 313
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
Sending reports
It will be easiest, in this case, to start from the bottom up: Adding the ability to send
reports to the back end. You already have a class that communicates with the back
end, API. You’ll create an extension for this class to handle the new functionality
while avoiding bloating the current file.
Laying a foundation
First things first, take what you learned in the previous chapter and start with a
protocol to keep the dependencies clean and make the work easier to test.
Create a new group named Analytics in the starter project under the MyBiz group.
You’ll use this to organize all the analytics-related code and will make the project
easier to navigate. It should have been better organized from the beginning, but you
don’t always get to choose your starting project. Move Report.swift to this group.
This file holds Report, which represents an individual analytics event to send back
to the server.
Next, in that group, create a new Swift file named AnalyticsAPI.swift. You’ll use
this to define a protocol to keep the analytics work separate from other back-end
functions.
protocol AnalyticsAPI {
raywenderlich.com 314
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
Whenever you add new code, you should add tests first. In the MyBizTests/Cases
group, create a new Unit Test Case Class named AnalyticsAPITests and add it to
the MyBizTests target.
import XCTest
@testable import MyBiz
func testAPI_whenReportSent_thenReportIsSent() {
// given
let date = Date()
let interval: TimeInterval = 20.0
let report = Report(
name: "name",
recordedDate: date,
type: "type",
duration: interval,
device: "device",
os: "os",
appVersion: "appVersion")
raywenderlich.com 315
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
Thankfully, Swift allows you to split implementation across files through the use of
extensions. Using extensions, you can add new functionality to API for analytics
without having to perturb the existing mess any more than necessary.
You now have a concrete AnalyticsAPI you can use in your test.
raywenderlich.com 316
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
Sending a report
Now you can start thinking about that report.
Now, you have a method to send the report you can use within the test.
// when
sut.sendReport(report: report)
The hard part is figuring out how to test that the report was sent. This is a unit test,
so you don’t want to rely on a live back end to verify the app logic. On of top that,
the test instance of API doesn’t even have a valid URL to call!
What you really want is a mock object that stands in for the back end, but also uses
the real API implementation. If you just mock AnalyticsAPI, then the test would
only verify that, when you call an object method, the method executes. So you need
the real API.
To get around this, another protocol and injection comes to the rescue! Open
API.swift and add the following protocol to the file:
protocol RequestSender {
func send<T: Decodable>(
request: URLRequest,
success: ((T) -> Void)?,
failure: ((Error) -> Void)?
)
}
This method takes a URLRequest, sends it and reports back successes or failures in
one or the other completion blocks.
API already has a method that basically does this, so it will be easy to implement.
raywenderlich.com 317
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
This sets up a request sender that can be injected later, but uses itself as a default.
This will be a leverage point for adding testing to API in the next step. It may seem a
little indirect to have a self-reference like this. However, taking this step allows you
to otherwise leave this class untouched and still add new functionality to it,
including testing.
import XCTest
@testable import MyBiz
raywenderlich.com 318
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
do {
let obj = try decoder.decode(
T.self,
from: request.httpBody!)
lastSent = obj
success?(obj)
} catch {
print("error decoding a \(T.self): \(error)")
failure?(error)
}
}
}
This class implements the RequestSender protocol by returning the object you used
to create the request body then storing it in lastSent. There are a lot of things you
could do from here, but this is sufficient to finish the test.
mockSender = MockSender()
sutImplementation.sender = mockSender
Next, add the following to tearDownWithError, just before the call to super:
mockSender = nil
// then
XCTAssertNotNil(mockSender.lastSent)
XCTAssertEqual(report.name, "name")
XCTAssertEqual((mockSender.lastSent as? Report)?.name, "name")
Remember that MockSender stores the sent object in lastSent, so you’re able to use
this to verify the passed-in Report was sent. Build and run the test and you’ll see it
still fails. You still need to supply the implementation for sendReport(report:).
raywenderlich.com 319
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
BUT, you don’t have time for that amount of refactoring right now. In this case,
you’re going to do something your teachers told you never to do: Copy code. It’s
okay. You’re going to have tests for this copied method, and this work is only meant
to support you as you add analytics. You will go back and finish the refactor after you
get this working.
Open API+Analytics.swift and add the following API extension to the end of the
file:
extension API {
// 1
func logAnalytics(
analytics: Report,
completion: @escaping (Result<Report, Error>) -> Void
) throws {
// 2
let url = URL(string: server + "api/analytics")!
var request = URLRequest(url: url)
if let token = token?.token {
let bearer = "Bearer \(token)"
request.addValue(
bearer,
forHTTPHeaderField: "Authorization")
}
request.addValue(
"application/json",
forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
// 3
sender.send(
request: request,
success: { savedEvent in
completion(.success(savedEvent))
},
failure: { error in
completion(.failure(error))
raywenderlich.com 320
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
})
}
}
This code replicates the code of submitPO(po:) with a few notable changes:
2. Instead of the hard-coded endpoint for purchase orders, this has a hard-coded
analytics endpoint.
Here, you’re using a technique called sprouting a method, which is when you add a
new method in an existing class that enhances or duplicates existing functionality so
you can add a new feature. This technique allows you to sidestep going down a hole
refactoring a class or potentially breaking things not yet under test. It allows you to
define a new interface, cleanly separated from the legacy part of the code. In this
case, the interface is even defined in a separate file.
Now, build and test and the tests will pass. You’ve successfully added a new (and
testable!) method to API with only minimal intrusion into the existing codebase.
raywenderlich.com 321
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
import XCTest
@testable import MyBiz
func whenShown() {
sut.viewWillAppear(false)
}
func testController_whenShown_sendsAnalytics() {
// when
whenShown()
raywenderlich.com 322
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
In the Mocks group, create a new Swift File named MockAnalyticsAPI.swift and
replace its contents with the following:
import XCTest
@testable import MyBiz
This class implements AnalyticsAPI, but instead of sending the report on, it uses
reportSent to flag that it triggered. Your previous tests on API ensure that the
report will make its way to the server.
mockAnalytics = MockAnalyticsAPI()
sut.analytics = mockAnalytics
raywenderlich.com 323
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
mockAnalytics = nil
XCTAssertTrue(mockAnalytics.reportSent)
Finally, to get the test to build and pass, you need to wire up viewWillAppear(_:)
to the analytics API. In AnnouncementsTableViewController.swift add the
following below var announcements:
This creates a Report with some useful information about the app, device and the
specific event. You then hand it off to AnalyticsAPI, which sends it to the back end.
raywenderlich.com 324
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
Another thing you can do with mocks is to verify the number of times a method is
called or the order in which methods are called.
var reportCount = 0
reportCount += 1
func testController_whenShownTwice_sendsTwoReports() {
// when
whenShown()
whenShown()
// then
XCTAssertEqual(mockAnalytics.reportCount, 2)
}
This tests that each time the screen displays, it will send a report. Build and test and
you should be all green.
Therefore, you have to set analytics manually, too. You know that you’re
potentially going to add it to many view controllers. It makes sense to think about a
way that you can add it to existing classes with minimal impact. That means
protocols to the rescue, once again.
raywenderlich.com 325
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
Open AnalyticsAPI.swift and add the following protocol to the end of the file:
By adding a var and adhering to this protocol in any class, you’ll be able to inject an
AnalyticsAPI implementation.
tabController.viewControllers?
.compactMap { $0 as? ReportSending }
.forEach { $0.analytics = api }
rootController = tabController
}
This now adds an AnalyticsAPI to all of the tab bar’s view controllers that adhere to
ReportSending. Because that includes AnnouncementsTableViewController, you’ll
now see logging whenever its viewWillAppear(_:) fires.
raywenderlich.com 326
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
Build and run the app. After logging in, the AnnouncementsTableViewController
tab will display. Open http://localhost:8080/api/analytics in a browser and
you’ll see recorded events similar to those below:
To implement ReportSending on this controller, start with a test. Create a new Unit
Test Case Class named OrgTableViewControllerTests.swift. Open
MyBizTests\Cases and replace the contents with the following:
import XCTest
@testable import MyBiz
raywenderlich.com 327
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
mockAnalytics = MockAnalyticsAPI()
sut.analytics = mockAnalytics
}
func whenShown() {
sut.viewWillAppear(false)
}
func testController_whenShown_sendsAnalytics() {
// when
whenShown()
// then
XCTAssertTrue(mockAnalytics.reportSent)
}
}
To get the test to pass, OrgTableViewController will need to send the report when
its view displays. But, before modifying viewWillAppear(_:), it would be a good
idea to create a helper method so you don’t have to copy over the boilerplate.
raywenderlich.com 328
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
This factory method takes care of all the constants that go into a report, so the caller
only has to worry about the specifics on each screen. You should be comfortable
enough with TDD at this point to write a test for it on your own (Check out
ReportTests.swift in the final project if you want a hint).
You can now use this method in OrgTableViewController.swift. Add the following
to the end of viewWillAppear(_:):
Now, the tests will pass. Build and run, and you should see two different screen
events recorded as you change tabs.
raywenderlich.com 329
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
Challenge
There are few tasks left undone that you should take care of:
• Add screenView analytics to the other screens. As a hint, you’ll have to forward
the AnalyticsAPI through UINavigationControllers.
Key points
• You don’t have to bring a whole class under test to add new functionality.
• Use protocols to inject dependencies and extensions to separate new code from
legacy code.
• TDD methods will guide the way for clean and tested features.
raywenderlich.com 330
iOS Test-Driven Development by TutorialsChapter 15: Adding Features to Existing Classes
You can also rework RequestSender into its own object to pass into API that
contains the server details. Then you could replace MockAPI in the existing tests so
you can write better and more comprehensive unit tests. This eliminates the need for
characterization tests to contact a live sever altogether. Your work is never done.
This approach also has some downsides. The indirection introduced by lots of small
protocols can make the code harder to debug, which is why having comprehensive
tests is crucial. When sprouting methods, it can be tempting to never to go back and
revisit your old code, leaving the app in a state that might be confusing for
newcomers. It also means the legacy code never improves.
This is the end of the legacy code tutorials. Check out Design Patterns by Tutorials
https://www.raywenderlich.com/books/design-patterns-by-tutorials for more
techniques for reorganizing and isolating code.
raywenderlich.com 331
16 Conclusion
What a journey it’s been! From exploring the TDD Cycle to testing networking code
and finally introducing tests to legacy applications which were not written to be
testable, you’ve gotten a taste for testing iOS apps using the latest techniques.
By putting the book concepts to practice, you’ll be confident your codebase will be
able to withstand changes even to critical path code since any regressions should be
caught with your written tests.
If you have any questions or comments as you work through this book, please stop by
our forums at https://forums.raywenderlich.com and look for the particular forum
category for this book.
Thank you again for purchasing this book. Your continued support is what makes the
books, tutorials, videos and other things we do at raywenderlich.com possible. We
truly appreciate it!
Happy testing!
raywenderlich.com 332