Chapters

Hide chapters

iOS Test-Driven Development by Tutorials

Second Edition · iOS 15 · Swift 5.5 · Xcode 13

2. The TDD Cycle
Written 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:

  1. Red: Write a failing test, before writing any app code.
  2. Green: Write the bare minimum code to make the test pass.
  3. Refactor: Clean up both your app and test code.
  4. Repeat: Do this cycle again until all features are implemented.

This is also called the Red-Green-Refactor Cycle.

Green Refactor Red

Why is it color coded? This corresponds to the colors shown in most code editors, including Xcode:

  • Failing tests are indicated with a red X.
  • Passing tests are shown with a green checkmark.

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.

It’s best to learn by doing, so let’s jump straight into code!

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.

Red: Write a failing test

Before you write any production code, you must first write a failing test. To do so, you need to create a test class. Add the following below the import statements:

class CashRegisterTests: XCTestCase {

}

Above, you declare CashRegisterTests as a subclass of XCTestCase, which is part of the XCTest framework. You’ll almost always subclass XCTestCase to create your test classes.

Next, add the following at the end of the playground:

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())
}

Here’s a line-by-line explanation:

  1. We name tests using this convention throughout the book:
  • 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.

  • Lastly, describe the expected outcome or result. Here, this is createsCashRegister.

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.

  1. 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.

Green: Make the test pass

You’re only allowed to write the bare minimum code to make a test pass. If you write more code than this, your tests will fall behind your app code. What’s the bare minimum code you can write to fix this compilation error? Define CashRegister!

Add the following directly above class CashRegisterTests:

class CashRegister {
  
}

Press Play to execute the playground, and you should see output similar to the following in the console:

Test Suite 'CashRegisterTests' started at 
  2021-07-22 16:55:35.336
Test Case '-[__lldb_expr_3.CashRegisterTests 
  testInit_createsCashRegister]' started.
Test Case '-[__lldb_expr_3.CashRegisterTests 
  testInit_createsCashRegister]' passed (0.081 seconds).
Test Suite 'CashRegisterTests' passed at 
  2021-07-22 16:55:35.418.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.081 (0.082) seconds

Awesome, you’ve made the test pass! The next step is to refactor your code.

Refactor: Clean up your code

You’ll clean up both your app code and test code in the refactor step. By doing so, you constantly maintain and improve your code. Here are a few things you might look to refactor:

  • 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:

  • Write an initializer that accepts availableFunds.
  • Write a method for addItem that adds to a transaction.
  • Write a method for acceptPayment.

You’ve got this!

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:

  • Given a certain condition…
  • When a certain action happens…
  • Then an expected result occurs.

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.

This code doesn’t compile yet because you haven’t defined init(availableFunds:). Compilation failures are treated as test failures, so you’ve completed the red step.

You next need to get this to pass. Add the following code inside CashRegister:

var availableFunds: Decimal

init(availableFunds: Decimal = 0) {
  self.availableFunds = availableFunds
}

CashRegister can now be initialized with availableFunds.

Press Play to execute all of the tests, and you should see output similar to the following in the console:

Test Suite 'CashRegisterTests' started 
  at 2021-07-22 17:03:58.245
Test Case '-[__lldb_expr_5.CashRegisterTests 
  testInit_createsCashRegister]' started.
Test Case '-[__lldb_expr_5.CashRegisterTests 
  testInit_createsCashRegister]' passed (0.081 seconds).
Test Case '-[__lldb_expr_5.CashRegisterTests 
  testInitAvailableFunds_setsAvailableFunds]' started.
Test Case '-[__lldb_expr_5.CashRegisterTests 
  testInitAvailableFunds_setsAvailableFunds]' passed 
  (0.003 seconds).
Test Suite 'CashRegisterTests' passed at 
  2021-07-22 17:03:58.331.
	 Executed 2 tests, with 0 failures (0 unexpected) in 0.085 
	 (0.086) seconds

Both tests pass, so you’ve completed the green step.

You next need to refactor both your app and test code. First, take a look at the test code.

testInit_createsCashRegister is now obsolete: There isn’t an init() method anymore. Rather, this test is actually calling init(availableFunds:) using the default parameter value of 0 for availableFunds.

Delete testInit_createsCashRegister entirely.

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?

Ultimately, this is a design decision:

  • 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.

The fact that testInitAvailableFunds passes after refactoring init(availableFunds:) gives you a sense of security that your changes didn’t break existing functionality. This added confidence in refactoring is a major benefit of TDD!

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)
  
  let itemCost = Decimal(42)
  
  // when
  sut.addItem(itemCost)
  
  // then
  XCTAssertEqual(sut.transactionTotal, itemCost)
}

This test doesn’t compile because you haven’t defined addItem(_:) or transactionTotal yet.

To fix this, add the following property right after availableFunds within CashRegister:

var transactionTotal: Decimal = 0

Finally, add addItem(_:) below init(availableFunds:):

func addItem(_ cost: Decimal) {
  transactionTotal = cost
}

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:

let availableFunds = Decimal(100)
let sut = CashRegister(availableFunds: availableFunds)

This code is common to both testInitAvailableFunds and testAddItem. To eliminate this duplication, you’ll create instance variables within CashRegisterTests.

Add the following right after the opening curly brace for CashRegisterTests:

var availableFunds: Decimal!
var sut: CashRegister!

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()
}

Here’s what this does:

  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.

Note: there’s also alternative setup/teardown methods: setUpWithError() throws and tearDownWithError() throws. Use these methods instead if your code has the possibility of throwing an error during setup and/or teardown.

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.

Next, replace the contents of testAddItem with the following:

// 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.

Adding two items

testAddItem_oneItem confirms addItem() passes for one item, but it won’t pass for two… or will it? A new test can definitively prove this.

Add the following test below testAddItem_oneItem_addsCostToTransactionTotal:

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:

Test Case '-[__lldb_expr_14.CashRegisterTests 
  testAddItem_twoItems_addsCostsToTransactionTotal]' started.
CashRegister.playground:89: error: 
  -[__lldb_expr_14.CashRegisterTests 
  testAddItem_twoItems_addsCostsToTransactionTotal] : 
  XCTAssertEqual failed: ("20") is not equal to ("62") - 
Test Case '-[__lldb_expr_14.CashRegisterTests 
  testAddItem_twoItems_addsCostsToTransactionTotal]' 
  failed (0.008 seconds).
...
Test Suite 'CashRegisterTests' failed at 
  2021-07-22 17:23:52.413
    Executed 3 tests, with 1 failure (0 unexpected) in 0.141 
    (0.142) seconds

You next need to get this test to pass. To do so, replace the contents of addItem(_:) with this:

transactionTotal += cost

Here, you’ve replaced the = operator with += to add to the transactionTotal instead of set it. Press the Play button again, and you’ll now see that all tests pass.

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:

var itemCost: Decimal!

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

Next, delete these two lines from testAddItem_oneItem:

// given
let itemCost = Decimal(42)

Likewise, delete this one line from testAddItem_twoItems:

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)

This is common to both testAddItem_oneItem and testAddItem_twoItems. Should you try to eliminate this duplication?

Remember how setUp() is called before every test method is run? You already have one test method that doesn’t require this call, testInitAvailableFunds.

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!

Your challenge is to TDD this new method, acceptCashPayment(_ cash:).

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.

First, create a test method called testAcceptCashPayment_subtractsPaymentFromTransactionTotal. Within this, do the following:

  • Call sut.addItem(_:) to set up a “transaction in progress.”
  • Call sut.acceptCashPayment(_:) to accept payment.
  • Assert transactionTotal has the payment subtracted from it.

Next, implement acceptCashPayment(_:) within CashRegister to make the test pass, and refactor as needed.

Create a second test method called testAcceptCashPayment_addsPaymentToAvailableFunds. Therein, do the following:

  • Call sut.addItem(_:) to set up a current transaction.
  • Call sut.acceptCashPayment(_:) to accept payment.
  • Assert the availableFunds has the payment added to it.

Finally, update acceptCashPayment(_:) to make this test pass, and refactor as needed.

Key points

You learned about the TDD Cycle in this chapter. This has four steps:

  1. Red: Write a failing test.
  2. Green: Make the test pass.
  3. Refactor: Clean up both your app and test code.
  4. Repeat: Do it again until all of your features are implemented.

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.