Test Driven Development Tutorial for iOS: Getting Started

In this Test Driven Development Tutorial, you will learn the basics of TDD and how to be effective at it as an iOS developer.

Version

  • Swift 4, iOS 11, Xcode 9

Test Driven Development (TDD) is a popular way to write software. The methodology dictates that you write tests before writing supporting code. While this may seem backward, it has some nice benefits.

One such benefit is that the tests provide documentation about how a developer expects the app to behave. This documentation stays current because test cases are updated alongside the code, which is great for developers who aren’t great at creating or maintaining documentation.

Another benefit is that apps developed using TDD result in better code coverage. Tests and code go hand-in-hand, making extraneous, untested code unlikely.

TDD lends itself well to pair-programming, where one developer writes tests and the other writes code to pass the tests. This can lead to faster development cycles as well as more robust code.

Lastly, developers who use TDD have an easier time when it comes to making major refactors in the future. This is a by-product of the fantastic test coverage for which TDD is known.

In this Test Driven Development tutorial, you’ll use TDD to build a Roman numeral converter for the Numero app. Along the way, you’ll become familiar with the TDD flow and gain insight into what makes TDD so powerful.

Getting Started

To kick things off, start by downloading the materials for this tutorial (you can find a link at the top and bottom of this tutorial). Build and run the app. You’ll see something like this:

The app displays a number and a Roman numeral. The player must choose whether or not the Roman numeral is the correct representation of the number. After making a choice, the game displays the next set of numbers. The game ends after ten attempts, at which point the player can restart the game.

Try playing the game. You’ll quickly realize that “ABCD” represents a correct conversion. That’s because the real conversion has yet to be implemented. You’ll take care of that during this tutorial.

Take a look at the project in Xcode. These are the main files:

  • ViewController.swift: Controls the gameplay and displays the game view.
  • GameDoneViewController.swift: Displays the final score and a button to restart the game.
  • Game.swift: Represents the game engine.
  • Converter.swift: Model representing a Roman numeral converter. It’s currently empty.

Mostly, you’ll be working with Converter and a converter test class that you’ll create next.

Note: This may be a good time to brush up on your Roman numeral skills.

Creating Your First Test and Functionality

The typical TDD flow can be described in the red-green-refactor cycle:

It consists of:

  1. Red: Writing a failing test.
  2. Green: Writing just enough code to make the test pass.
  3. Refactor: Cleaning up and optimizing your code.
  4. Repeating the previous steps until you’re satisfied that you’ve covered all the use cases.

Creating a Unit Test Case Class

Create a new Unit Test Case Class template file under NumeroTests, and name it ConverterTests:

Open ConverterTests.swift and delete testExample() and testPerformanceExample().

Add the following just after the import statement at the top:

@testable import Numero

This gives the unit tests access to the classes and methods in Numero.

At the top of the ConverterTests class, add the following property:

let converter = Converter()

This initializes a new Converter object that you’ll use throughout your tests.

Writing Your First Test

At the end of the class, add the following new test method:

func testConversionForOne() {
  let result = converter.convert(1)
}

The test calls convert(_:) and stores the result. As this method has yet to be defined, you’ll see the following compiler error in Xcode:

In Converter.swift, add the following method to the class:

func convert(_ number: Int) -> String {
  return ""
}

This takes care of the compiler error.

Note: If the compiler error doesn’t go away, try commenting out the line that imports Numero, and then uncomment the same line. If that doesn’t work, select ProductBuild ForTesting from the menu.

In ConverterTests.swift, add the following to the end of testConversionForOne():

XCTAssertEqual(result, "I", "Conversion for 1 is incorrect")

This uses XCTAssertEqual to check the expected conversion result.

Press Command-U to run all the tests (of which there’s currently only one). The simulator should start but you’re more interested in the Xcode test results:

You’ve come to the first step of a typical TDD cycle: Writing a failing test. Next, you’ll work on making this test pass.

Fixing Your First Failure

Back in Converter.swift, replace convert(_:) with the following:

func convert(_ number: Int) -> String {
  return "I"
}

The key is writing just enough code to make the test pass. In this case, you’re returning the expected result for the only test you have thus far.

To run it — and because there’s only one test — you can press the play button next to the test method name in ConverterTests.swift:

The test now passes:

The reason why you start with a failing test and then fix your code to pass it is to avoid a false-positive. If you never see your tests fail, you can’t be sure you’re testing the right scenario.

Pat yourself on the back for getting through your first TDD run!

But don’t celebrate too long. There’s more work to do, because what good is a Roman Numeral converter that only handles one number?

Extending the Functionality

Working on Test #2

How about trying out the conversion for 2? That sounds like an excellent next step.

In ConverterTests.swift, add the following new test to the end of the class:

func testConversionForTwo() {
  let result = converter.convert(2)
  XCTAssertEqual(result, "II", "Conversion for 2 is incorrect")
}

This tests the expected result for 2 which is II.

Run your new test. You’ll see a failure because you haven’t added code to handle this scenario:

In Converter.swift, replace convert(_:) with the following:

func convert(_ number: Int) -> String {
  return String(repeating: "I", count: number)
} 

The code returns I, repeated a number of times based on the input. This covers both cases you’ve tested thus far.

Run all of the tests to make sure your changes haven’t introduced a regression. They should all pass:

Working on Test #3

You’ll skip testing 3 because it should pass based on the code you already wrote. You’ll also skip 4, at least for now, because it’s a special case that you’ll deal with later. So how about 5?

In ConverterTests.swift, add the following new test to the end of the class:

func testConversionForFive() {
  let result = converter.convert(5)
  XCTAssertEqual(result, "V", "Conversion for 5 is incorrect")
}

This tests the expected result for 5 which is V.

Run your new test. You’ll see a failure as five I’s isn’t the correct result:

In Converter.swift, replace convert(_:) with the following:

func convert(_ number: Int) -> String {
  if number == 5 {
    return "V"
  } else {
    return String(repeating: "I", count: number)
  }
}

You’re doing the minimum work here to get the tests to pass. The code checks 5 separately, otherwise it reverts to the previous implementation.

Run all your tests. They should pass:

Working on Test #4

Testing 6 presents another interesting challenge, as you’ll see in a moment.

In ConverterTests.swift, add the following new test to the end of the class:

func testConversionForSix() {
  let result = converter.convert(6)
  XCTAssertEqual(result, "VI", "Conversion for 6 is incorrect")
}

This tests the expected result for 6 which is VI.

Run your new test. You’ll see a failure since this is an unhandled scenario:

In Converter.swift, replace convert(_:) with the following:

func convert(_ number: Int) -> String {
  var result = "" // 1
  var localNumber = number // 2
  if localNumber >= 5 { // 3
    result += "V" // 4
    localNumber = localNumber - 5 // 5
  }
  result += String(repeating: "I", count: localNumber) // 6
  return result
}

The code does the following:

  1. Initializes an empty output string.
  2. Creates a local copy of the input to work with.
  3. Checks if the input is greater than or equal to 5.
  4. Appends the Roman numeral representation for 5 to the output.
  5. Decrements the local input by 5.
  6. Appends the output with a repeating count of the Roman numeral conversion for 1. The count is the previously decremented local input.

This seems like a reasonable algorithm to use based on what you’ve seen up to this point. It’s best to avoid the temptation of thinking too far ahead and handling other cases that you haven’t tested.

Run all of your tests. They should all pass:

Working on Test #5

You often have to be wise in picking what you test and when you test it. Testing 7 and 8 won’t yield anything new, and 9 is another special case, so you can skip it for now.

This brings you to 10 and should uncover some nuggets.

In ConverterTests.swift, add the following new test to the end of the class:

func testConversionForTen() {
  let result = converter.convert(10)
  XCTAssertEqual(result, "X", "Conversion for 10 is incorrect")
}

This tests the expected result for 10 which is a new symbol, X.

Run your new test. You’ll see a failure due to the unhandled scenario:

Switch to Converter.swift and add the following code to convert(_:) just after localNumber is declared:

if localNumber >= 10 { // 1
  result += "X" // 2
  localNumber = localNumber - 10 // 3
}

This is similar to how you previously handled 5. The code does the following:

  1. Checks if the input is 10 or greater.
  2. Appends the Roman numeral representation of 10 to the output result.
  3. Decrements 10 from a local copy of the input before passing execution to the next phases that handle 5 and 1’s.

Run all of your tests. They should all pass:

Uncovering a Pattern

As you build up your pattern, handling 20 seems like a good one to try out next.

In ConverterTests.swift, add the following new test to the end of the class:

func testConversionForTwenty() {
  let result = converter.convert(20)
  XCTAssertEqual(result, "XX", "Conversion for 20 is incorrect")
}

This tests the expected result for 20, which is the Roman numeral representation for 10 repeated twice, XX.

Run your new test. You’ll see a failure:

The actual result is XVIIIII, which doesn’t match what you expect.

Replace the conditional statement:

if localNumber >= 10 {

With the following:

while localNumber >= 10 {

This small change loops through the input when handling 10 instead of going through it just once. This appends a repeating X to the output based on the number of 10s.

Run all of your tests, and now they all pass:

Do you see a small pattern emerging? This is a good time to go back and handle the skipped special cases. You’ll start with 4.

Handling the Special Cases

In ConverterTests.swift, add the following new test to the end of the class:

func testConversionForFour() {
  let result = converter.convert(4)
  XCTAssertEqual(result, "IV", "Conversion for 4 is incorrect")
}

This tests the expected result for 4 which is IV. In Roman numeral land, 4 is represented as 5 minus 1.

Run your new test. You shouldn’t be too surprised to see a failure. It’s an unhandled scenario:

In Converter.swift, add the following to convert(_:) just before the statement that adds the repeating I:

if localNumber >= 4 {
  result += "IV"
  localNumber = localNumber - 4
}

This code checks if the local input after 10 and 5 have been handled is greater than or equal to 4. It then appends the Roman numeral representation for 4 before decrementing the local input by 4.

Run all of your tests. Once again, they’ll all pass:

You also skipped 9. It’s time to try it out.

In ConverterTests.swift, add the following new test to the end of the class:

func testConversionForNine() {
  let result = converter.convert(9)
  XCTAssertEqual(result, "IX", "Conversion for 9 is incorrect")
}

This tests the expected result for 9 which is IX.

Run your new test. The VIV result is incorrect:

Based on everything you’ve seen so far, do you have an idea about how you can fix this?

Switch to Converter.swift, and add the following to convert(_:), in between the code that handles 10 and 5:

if localNumber >= 9 {
  result += "IX"
  localNumber = localNumber - 9
}

This is similar to how you handled 4.

Run all of your tests, and again, they’ll all pass:

In case you missed it, here’s the pattern that emerged when handling many of the use cases:

  1. Check if your input is greater than or equal to a number.
  2. Build up the result by appending the Roman numeral representation for that number.
  3. Decrement your input by the number.
  4. Loop through and check the input again for certain numbers.

Keep this in the back of your mind as you move on to the next step in the TDD cycle.

Refactoring

Recognizing duplicate code and cleaning it up, also known as refactoring, is an essential step in the TDD cycle.

At the end of the previous section, a pattern emerged in the conversion logic. You’re going to identify this pattern fully.

Exposing the Duplicate Code

Still in Converter.swift, take a look at the conversion method:

func convert(_ number: Int) -> String {
  var result = ""
  var localNumber = number
  while localNumber >= 10 {
    result += "X"
    localNumber = localNumber - 10
  }
  if localNumber >= 9 {
    result += "IX"
    localNumber = localNumber - 9
  }
  if localNumber >= 5 {
    result += "V"
    localNumber = localNumber - 5
  }
  if localNumber >= 4 {
    result += "IV"
    localNumber = localNumber - 4
  }
  result += String(repeating: "I", count: localNumber)
  return result
}

To highlight the code duplication, modify convert(_:) and change every occurrence of if with while.

To make sure you haven’t introduced a regression, run all of your tests. They should still pass:

That’s the beauty of cleaning up your code and refactoring with TDD methodology. You can have the peace of mind that you aren’t breaking existing functionality.

There’s one more change that will fully expose the duplication. Modify convert(_:) and replace:

result += String(repeating: "I", count: localNumber)

With the following:

while localNumber >= 1 {
  result += "I"
  localNumber = localNumber - 1
}

These two pieces of code are equivalent and return a repeating I string.

Run all of your tests. They should all pass:

Optimizing Your Code

Continue refactoring the code in convert(_:) by replacing the while statement that handles 10 with the following:

let numberSymbols: [(number: Int, symbol: String)] // 1
  = [(10, "X")] // 2
    
for item in numberSymbols { // 3
  while localNumber >= item.number { // 4
    result += item.symbol
    localNumber = localNumber - item.number
  }
}

Let’s go through the code step-by-step:

  1. Create an array of tuples representing a number and the corresponding Roman numeral symbol.
  2. Initialize the array with values for 10.
  3. Loop through the array.
  4. Run each item in the array through the pattern you uncovered for handling the conversion for a number.

Run all of your tests. They continue to pass:

You should now be able to take your refactoring to its logical conclusion. Replace convert(_:) with the following:

func convert(_ number: Int) -> String {
  var localNumber = number
  var result = ""

  let numberSymbols: [(number: Int, symbol: String)] =
    [(10, "X"),
     (9, "IX"),
     (5, "V"),
     (4, "IV"),
     (1, "I")]
    
  for item in numberSymbols {
    while localNumber >= item.number {
      result += item.symbol
      localNumber = localNumber - item.number
    }
  }

  return result
}

This initializes numberSymbols with additional numbers and symbols. It then replaces the previous code for each number with the generalized code you added to process 10.

Run all of your tests. They all pass:

Handling Other Edge Cases

Your converter has come a long way, but there are more cases you can cover. You’re now equipped with all the tools you need to make this happen.

Start with the conversion for zero. Keep in mind, however, zero isn’t represented in Roman numerals. That means, you can choose to throw an exception when this is passed or just return an empty string.

In ConverterTests.swift, add the following new test to the end of the class:

func testConverstionForZero() {
  let result = converter.convert(0)
  XCTAssertEqual(result, "", "Conversion for 0 is incorrect")
}

This tests the expected result for zero and expects an empty string.

Run your new test. This works by virtue of how you’ve written your code:

Try testing for the last number that’s supported in Numero: 3999.

In ConverterTests.swift, add the following new test to the end of the class:

func testConverstionFor3999() {
  let result = converter.convert(3999)
  XCTAssertEqual(result, "MMMCMXCIX", "Conversion for 3999 is incorrect")
}

This tests the expected result for 3999.

Run your new test. You’ll see a failure because you haven’t added code to handle this edge case:

In Converter.swift, modify convert(_:) and change the numberSymbols initialization as follows:

let numberSymbols: [(number: Int, symbol: String)] =
  [(1000, "M"),
   (900, "CM"),
   (500, "D"),
   (400, "CD"),
   (100, "C"),
   (90, "XC"),
   (50, "L"),
   (40, "XL"),
   (10, "X"),
   (9, "IX"),
   (5, "V"),
   (4, "IV"),
   (1, "I")]

This code adds mappings for the relevant numbers from 40 through 1,000. This also covers the test for 3,999.

Run all of your tests. They all pass:

If you fully bought into TDD, you likely protested about adding numberSymbols mappings for say 40 and 400 as they’re not covered by any tests. That’s correct! With TDD, you don’t want to add any code unless you’ve first written tests. That’s how you keep your code coverage up. I’ll leave you with the exercise of righting these wrongs in your copious free time.

Note: Special mention goes to Jim Weirich – Roman Numerals Kata for the algorithm behind the app.

Use Your Converter

Congratulations! You now have a fully functioning Roman numeral converter. To try it out in the game, you’ll need to make a few more changes.

In Game.swift, modify generateAnswers(_:number:) and replace the correctAnswer assignment with the following:

let correctAnswer = converter.convert(number)

This switches to using your converter instead of the hard-coded value.

Build and run your app:

Play a few rounds to make sure all the cases are covered.

Other Test Methodologies

As you dive more into TDD, you may hear about other test methodologies, for example:

  • Acceptance Test-Driven Development (ATDD): Similar to TDD, but the customer and developers write the acceptance tests in collaboration. A product manager is an example of a customer, and acceptance tests are sometimes called functional tests. The testing happens at the interface level, generally from a user point of view.
  • Behavior-Driven Development (BDD): Describes how you should write tests including TDD tests. BDD advocates for testing desired behavior rather than implementation details. This shows up in how you structure a unit test. In iOS, you can use the given-when-then format. In this format, you first set up any values you need, then execute the code being tested, before finally checking the result.

Where to Go From Here?

Congratulations on rounding out Numero. You can download the final project by using the link at the top or bottom of this tutorial.

You now have an excellent idea of how TDD works. The more you use it, the better you’ll get at it, so try exercising and building up that muscle memory. The other developers who work with your code will thank you for using TDD.

Although you used TDD to develop a model class, it can also be used in UI development. Take a look at the iOS Unit Testing and UI Testing Tutorial for guidance on how to write UI tests and apply TDD methodology to it.

The beauty of TDD is it’s a software development methodology. This makes it useful beyond developing iOS apps. You can use TDD for developing Android apps, JavaScript apps, any many others. As long as that technology has a framework for writing unit tests, you can use TDD!

I hope you enjoyed this tutorial. If you have any comments or questions, please join the forum discussion below!

Contributors

Comments