This article covers TDD, a popular and sometimes controversial approach to writing tests for code. We briefly explain the approach and process that underpins TDD, and touch upon the benefits of TDD before diving into an extended case study to illustrate the process of using TDD in practice. The article shows not only how TDD can help drive development of code in small increments but also how having a comprehensive suite of tests that TDD enables by design makes it easier and safer to refactor code.
Related article: Unit Testing and UI Testing in Swift
Contents
What is TDD
TDD is a methodology of using tests to drive development of code. TDD leads to writing test and production code in tandem, using small incremental cycles consisting of three steps:
- Red: Write just enough test code until you get a failing test, where failure of the test code to compile is considered a failing test.
- Green: Write just enough production code to make the failing test pass.
- Refactor: Improve the code by changing its internal structure without altering its external behaviour, and without breaking any tests.
What this means in practice is that we always start by writing test code, specifically a test that is meant to fail because we haven’t implemented the functionality that we are testing. We write production code only in two situations — either to make a failing test pass or to refactor code that passes all existing tests to improve the quality of the code without adding or changing any functionality. Refactoring is carried out in small increments, running the tests after each change to ensure we don’t accidentally break anything. This practice of writing tests before implementing the functionality to be tested, and making sure we see a test fail before writing code to make it pass, may seem a bit strange at first but it plays a critical role in ensuring that test code itself is correct and tests don’t pass for the wrong reasons.
Note that, in practice, every test may not fail when written. Some tests may pass with the existing production code. In such cases, we can modify the production code in specific ways that will make the new test fail, to give us confidence in the correctness of the test. We can then restore the production code to its original state, see the test pass, and continue on our way. We will not show this extra step in this article, in the interest of space, but when using TDD it is customary to see each test fail before making it pass. This ensures that we are sure about the validity of the tests, which instills more faith in the correctness of the code when it passes the tests.
TDD differs from how we think about testing in general. We would normally expect to have the code we are testing written before we test it. TDD is more akin to writing tests as a specification of what we expect the code to do. But, unlike a generic test-first approach where we may write a bunch of tests that we would expect the code to pass before we write the code, in TDD we write tests one micro feature at a time and write production code to pass each test before writing the next one, leading to a continuous cycle of feedback that drives the development of code.
Why TDD
TDD can seem a bit tedious at first, as those new to TDD or unconvinced of its benefits will feel as they start to go through the case study in the next section. Writing test and production code in such small increments, and constantly switching between test and production code, can seem unnecessary. But there are good reasons to follow the rhythm and discipline of TDD.
Listed below are some of the key benefits of TDD:
- Interface before implementation: TDD forces us to think about how the code will be used before it is written. We must first put ourselves in the shoes of the programmer who will call our code, which tends to clarify the intent and improve the interface of each unit of code, from individual methods to larger parts of the application. This makes us think about the interface of the code before, and independently of, its implementation.
- More flexible and maintainable code: Writing code in a way that makes each unit of code independently testable forces us to decouple units of code from their environment. This tends to make the overall design more flexible, and easier to extend and maintain.
- Tests as documentation: Tests written using TDD provide a useful way to document the code. Each test usually verifies a micro feature of the code or validates that the code deals correctly with an edge case. We can understand at a granular level what the code is supposed to do just by going through the tests. In fact, if the tests are named using an expressive and consistent syntax, even glancing through the test names can give a fairly good idea of what the code is supposed to do. This can be especially useful when we are dealing with code written by someone else, or even code that we wrote some time ago.
- Easier refactoring: With a suite of tests that validate each micro feature and edge case, we can refactor code with much more confidence, secure in the knowledge that as long as all the tests pass, the code continues to deliver the required functionality. Since refactoring in small increments is part of the rhythm of TDD, and refactoring is done only after the code passes all the tests, we are always one small step away from returning the code to a stable state. Running tests frequently during refactoring can make even major refactoring of the code relatively painless, as we will see when we get into the TDD case study later in this article.
- Only required code: We know we are done when we have no further tests to write. This helps write only code that is required to implement the features we need, and handle the expected edge cases. It prevents the tendency to write code that we find may not be required once we write parts of the application that will use the code. It can also reduce instances of having to change the interface of units of code when we have to use them since TDD forces us to think about how we will use a unit of code before we implement it.
- Less debugging: TDD by its very nature leads to code that is correct by design. The moment any test fails, whether it is the last test we wrote or one of the existing tests, we stop writing any more test code and modify the production code only to make all tests pass without adding any new feature or functionality. We add new features by adding new tests only once all existing tests pass. This, combined with the fact that TDD imposes a process where we add test and production code one micro feature at a time, makes it easier to identify minor issues before they get buried under layers of code and become hard-to-locate bugs. This can in many cases significantly reduce debugging at a later stage.
In spite of these advantages, TDD is no panacea, and it is by no means the dominant approach to testing. It has fervent supporters and strong detractors in equal measure and, like most coding practices and conventions, it works well when adopted across the team.
Case study
The best way to get our heads around TDD is to see it in action. In the remainder of this article, we will use an extended case study to demonstrate how to write and refactor code using TDD.
Let’s assume we are designing an application to model courses taken, and fees to be paid, by students studying in a college. To keep the example simple, let’s say the college has two types of students – students who are enrolled in the college, and casual students who are not enrolled and may take courses as and when they like. We would like each student instance to keep track of the courses the student is currently taking, and use that to generate an invoice containing fees payables by the student, using a fee per course credit. This applies to both enrolled and casual students although the fee per course credit payable by enrolled and casual students is different. Enrolled students also pay a fixed enrollment fee in addition to the course fees, which should be included in the invoices for enrolled students.
1. Fees
We start by writing tests for a type that will represent a fee. A fee should have an amount which could be either on account of enrollment or for a course with a title. So the requirement we want our code to satisfy is that every fee should have an amount along with a category with two possible values – one representing enrollment and the other representing a course with a title.
Let’s first create a skeleton for a test class, where the system under test, or sut
for short, is a variable that can hold an instance of a Fee
type. Note that we define sut
as an implicitly unwrapped optional, which we set to nil
in the tearDown()
method. This ensures that each test gets its own instance of Fee
, making the tests independent of each other.
As we start writing the test class, we encounter our first compilation error when we define the variable sut
to be of type Fee
, which we haven’t defined yet.
class FeeTests: XCTestCase {
private var sut: Fee!
// ... TO BE CONTINUED
}
We stop writing test code here because we have encountered a failure of the test code to compile, which is regarded in TDD as a failed test. If you are new to TDD, you should get used to encountering compilation errors in test code since this is an essential part of the practice of TDD. We must now write production code to remove this compilation error before writing any more test code. So we define the following type in production code, which is just enough to make the test code compile.
struct Fee: Equatable {
}
Note that we make the Fee
type conform to Equatable
. This is something we will do for all our value types since attribute-based equality is one of the defining characteristics of values. We will not write any tests for Equatable
conformance of value types since it is not a feature being added to a specific value type but rather a defining characteristic of all value types.
This production code removes the compilation error in the test code and allows us to finish writing the skeleton of the test class, as shown below.
class FeeTests: XCTestCase {
private var sut: Fee!
override func tearDown() {
sut = nil
}
}
Now we are ready to add the first test method to this class. This will validate that a Fee
instance can be initialized by passing in as the argument a category enumeration with the value enrollment
, and that the value passed in through the initializer is assigned to an instance property named category
.
Here is how we start writing the test until we encounter a compilation failure because the Fee
type does not have a nested enum named Cateogry
with a case called enrollment
.
func testCategory_hasValueEnrollmentPassedInThroughIntitializer() {
// given
let testCategory = Fee.Category.enrollment
// ... TO BE CONTINUED
}
We cannot write more test code until we have removed this compilation failure. Here is the simplest production code that will make our test code compile.
struct Fee: Equatable {
enum Category: Equatable {
case enrollment
}
}
Moving along, we add more code to the above test method, until we encounter our next compilation error because the Fee
type does not have an initializer that takes a parameter named category
.
func testCategory_hasValueEnrollmentPassedInThroughIntitializer() {
// given
let testCategory = Fee.Category.enrollment
// when
sut = Fee(category: testCategory)
// ... TO BE CONTINUED
}
We add the following property to our Fee
struct to make the test code compile.
var category: Category
Note that we do not need to add an initializer to our struct since Swift automatically provides a member-wise initializer to every struct where an initializer is not explicitly provided in the declaration of the struct.
Having returned to green, we finish writing our first test, as shown below.
func testCategory_hasValueEnrollmentPassedInThroughIntitializer() {
// given
let testCategory = Fee.Category.enrollment
// when
sut = Fee(category: testCategory)
// then
XCTAssertEqual(sut.category, testCategory)
}
This test passes without writing any more production code thanks to the automatically generated member-wise initializer.
Congratulations! We have just written our first passing test. Those new to TDD may be thinking this constant switching between test and production code is a bit tedious. This may be true but it is, in fact, a key feature of the rhythm of TDD. Once we get used to the rapid feedback cycle that TDD enables, it creates a high level of confidence in the correctness of our code. We just have to stick with it, and trust the process, for the benefits to become apparent.
Moving on, we write a test that the Fee
initializer can also take a Category
value of course
with an associated String
value and, as in the previous test, this Category
value gets stored in the category
property.
Before writing this test, we create a new TestConstants
class, as shown below.
class TestConstants {
static let someString = "String"
}
We do this because when initializing or preparing the sut
for a particular test, we are sometimes interested in only a subset of the arguments we need to provide. The arguments that we are passing in only to make the code compile can have any valid values. Since we are not interested in the actual values of these arguments, we define a let
constant of each relevant type, which we can pass in whenever a value of that type is expected. This keeps the focus on the variables that are relevant for each test while avoiding having to either pepper our tests with literal values that are not relevant to the test or define unnecessary named variables in each test.
In this case, we are only interested in validating that when we initialize a Fee
instance with the course
case of Category
with a certain course title as the associated value, the same Category
value gets assigned to the category
property of the new instance. We are not interested in what value we use for the course title. So rather than defining a new variable in the test method for the title or using a literal value, neither of which would be relevant to the intention of the test, we use the someString
constant we have defined above.
We start writing our test using the let
constant someString
for the course title.
func testCategory_hasValueCourseWithTitlePassedInThroughInitializer() {
// given
let testCategory = Fee.Category.course(TestConstants.someString)
// ... TO BE CONTINUED
}
We are unable to complete writing our test since the Category
enum does not have a course
case that takes a String
associated value. So we modify the Category
enum nested inside the Fee
struct, as shown below.
enum Category: Equatable {
case enrollment
case course(String)
}
Now we can finish writing our test.
func testCategory_hasValueCourseWithTitlePassedInThroughInitializer() {
// given
let testCategory = Fee.Category.course(TestConstants.someString)
// when
sut = Fee(category: testCategory)
// then
XCTAssertEqual(sut.category, testCategory)
}
This test also passes since the member-wise initializer initializes all the properties with the values provided.
Next, we write a test that the Fee
type also has a property named amount
which is initialized through a Double
value passed in through the initializer. Since in this case we are only interested in the amount we pass in through the initializer and not in the fee category, before writing the test we add the following property to the TestConstants
class.
static let someFeeCategory = Fee.Category.enrollment
Now we can start writing our test.
func testAmount_hasValuePassedInThroughInitializer() {
// given
let testAmount = 100.0
// when
sut = Fee(category: TestConstants.someFeeCategory, amount: testAmount)
// ... TO BE CONTINUED
}
We get a compilation error because the initializer of the Fee
struct has a single parameter whereas we are passing in two arguments. This is easily fixed by adding the following property to the Fee
struct.
var amount: Double
Now we have an unintended consequence. The two tests we wrote earlier no longer compile since they expect the Fee
initializer to accept a single argument. Now we also need to provide an argument of type Double
for the amount
parameter. Since we are not interested in the value of this argument for those tests, we define the following property in the TestConstants
class.
static let someDouble = 1.0
To make the two previous tests compile again, we change the line in the when
section of both the tests where we initialize the sut
, as shown below.
// when
sut = Fee(category: testCategory, amount: TestConstants.someDouble)
We run all the tests to make sure everything still passes, and then finish writing the above test as follows.
func testAmount_hasValuePassedInThroughInitializer() {
// given
let testAmount = 100.0
// when
sut = Fee(category: TestConstants.someFeeCategory, amount: testAmount)
// then
XCTAssertEqual(sut.amount, testAmount)
}
This test passes without the need to write any further production code because of the member-wise initializer automatically provided by Swift.
2. Invoices
Next we test for the presence of an Invoice
type, instances of which will contain a collection of fees. We begin as before by creating a new test class.
class InvoiceTests: XCTestCase {
private var sut: Invoice!
// ... TO BE CONTINUED
}
We have to stop here because the above code does not compile. To remedy this, we create an Invoice
type in the production code.
struct Invoice: Equatable {
}
We complete the skeleton of our test class, as shown below.
class InvoiceTests: XCTestCase {
private var sut: Invoice!
override func tearDown() {
sut = nil
}
}
We write our first test for the Invoice
type to validate that an invoice has no fees associated with it when it is first created. We do this by asserting that a newly initialized Invoice
instance has an empty collection named fees
.
func testFees_isEmpty() {
// when
sut = Invoice()
// then
XCTAssertTrue(sut.fees.isEmpty)
}
This test does not compile since Invoice
does not have the required property. Accordingly, we add the following property to the Invoice
struct, which makes the above test compile and pass.
var fees = [Fee]()
Note that our test does not require a collection type with any specific features so we have used the Array
type, which meets the requirement of the test.
Next, we write a test for a fee to be added to a newly created invoice, which should result in that fee being the only element in the fees
collection of the invoice. Note that we use the let
constants someFeeCategory
and someDouble
respectively for the category
and amount
parameters of the Fee
initializer since we are only interested in validating that the Fee
instance contained in the fees
collection of the invoice is equal to the Fee
instance we added to the invoice. We are not interested in the actual values of the arguments we pass in while creating the Fee
instance.
func testAddFee_newFeeIsTheOnlyFeeInFees() {
// given
let testFee = Fee(category: TestConstants.someFeeCategory, amount: TestConstants.someDouble)
sut = Invoice()
// when
sut.addFee(testFee)
// ... TO BE CONTINUED
}
Before writing more test code, we must add the following method to the Invoice
struct.
func addFee(_: Fee) {
}
Now we can finish writing the test.
func testAddFee_newFeeIsTheOnlyFeeInFees() {
// given
let testFee = Fee(category: TestConstants.someFeeCategory, amount: TestConstants.someDouble)
sut = Invoice()
// when
sut.addFee(testFee)
// then
XCTAssertEqual(sut.fees.count, 1)
XCTAssertTrue(sut.fees.contains(testFee))
}
Note that we have written two assertions, one to verify that the fees
collection contains only one element, and the other to verify that it contains the fee that was added. We could have achieved the same objective by creating an array with the test fee as its only element, and asserting that the fees
collection is equal to this array. This, however, would have created a coupling between the test and implementation details of the production code. This is not a desirable pattern since it can cause test code to become brittle, i.e., tests start to break when we change implementation details of production code without altering its behaviour.
To pass this test, we modify the addFee(_:)
method as shown below.
mutating func addFee(_ fee: Fee) {
fees.append(fee)
}
As stated in the beginning of this article, once we have achieved the green state, i.e., all test we have written pass, we should look for any opportunities to refactor our code. This includes not only production code but also test code. We see that we have some code duplication in the two invoice tests. Both the tests use the same line of code to initialize the sut
. To remove this duplication, we restructure the InvoiceTests
class to move initialization of the sut
to a setUp()
method, as shown below.
class InvoiceTests: XCTestCase {
private var sut: Invoice!
override func setUp() {
sut = Invoice()
}
override func tearDown() {
sut = nil
}
func testFees_isEmpty() {
XCTAssertTrue(sut.fees.isEmpty)
}
func testAddFee_newFeeIsTheOnlyFeeInFees() {
// given
let testFee = Fee(category: TestConstants.someFeeCategory, amount: TestConstants.someDouble)
// when
sut.addFee(testFee)
// then
XCTAssertEqual(sut.fees.count, 1)
XCTAssertTrue(sut.fees.contains(testFee))
}
}
Before we write our next test, we add the following properties to TestConstants
.
static let someOtherDouble = 2.0
static let someOtherFeeCategory = Fee.Category.course("String")
Now we can write a test that when a second fee is added to an invoice, it gets added to the fees
collection of the invoice.
func testAddFee_oneFeeAdded_newFeeAddedToFees() {
// given
let testFee = Fee(category: TestConstants.someFeeCategory, amount: TestConstants.someDouble)
sut.addFee(testFee)
let secondTestFee = Fee(category: TestConstants.someOtherFeeCategory, amount: TestConstants.someOtherDouble)
// when
sut.addFee(secondTestFee)
// then
XCTAssertEqual(sut.fees.count, 2)
XCTAssertTrue(sut.fees.contains(testFee))
XCTAssertTrue(sut.fees.contains(secondTestFee))
}
This test passes without requiring any change to the production code.
Now we have another opportunity to refactor this test class. The test we have just written uses a locally defined let
constant testFee
. The test we wrote earlier defines an identical let
constant. To remove this duplication, we define the following instance property in the test class.
private let testFee = Fee(category: TestConstants.someFeeCategory, amount: TestConstants.someDouble)
We then rewrite the last test we wrote, as shown below.
func testAddFee_oneFeeAdded_newFeeAddedToFees() {
// given
sut.addFee(testFee)
let secondTestFee = Fee(category: TestConstants.someOtherFeeCategory, amount: TestConstants.someOtherDouble)
// when
sut.addFee(secondTestFee)
// then
XCTAssertEqual(sut.fees.count, 2)
XCTAssertTrue(sut.fees.contains(testFee))
XCTAssertTrue(sut.fees.contains(secondTestFee))
}
We also rewrite the previous test to use the same instance property instead of defining a local let
constant.
func testAddFee_newFeeIsTheOnlyFeeInFees() {
// when
sut.addFee(testFee)
// then
XCTAssertEqual(sut.fees.count, 1)
XCTAssertTrue(sut.fees.contains(testFee))
}
We run all tests to make sure everything passes.
Next, we write a test that an invoice has a property named totalPayableAmount
with the sum of the amounts of all the fees added to the invoice.
func testTotalPayableAmount_hasSumOfAmountsOfFeesAddedToInvoice() {
// given
let testAmount = 100.0
let secondTestAmount = 150.0
// when
sut.addFee(Fee(category: TestConstants.someFeeCategory, amount: testAmount))
sut.addFee(Fee(category: TestConstants.someFeeCategory, amount: secondTestAmount))
// then
XCTAssertEqual(sut.totalPayableAmount, testAmount + secondTestAmount)
}
This test fails to compile at the last line since Invoice
does not have a totalPayableAmount
property. So we add the following property to the Invoice
struct.
var totalPayableAmount = 0.0
We run the above test and see it fail. We then make it pass by changing totalPayableAmount
to a computed property, as shown below.
var totalPayableAmount: Double {
fees.reduce(0) { runningTotal, fee in
runningTotal + fee.amount
}
}
3. Courses
Now we look at how students would take and complete courses. We begin by creating a test class for a Course
type. We start writing the skeleton of our new test class as follows.
class CourseTests: XCTestCase {
private var sut: Course!
//... TO BE CONTINUED
}
As we have done with the other types that haven’t been defined in production code yet, we stop writing test code and define the Course
type to remove this compilation error.
struct Course: Equatable {
}
Now we can complete the skeleton of the test class.
class CourseTests: XCTestCase {
private var sut: Course!
override func tearDown() {
sut = nil
}
}
We add the first test to this class for the presence of a property named title
, which has the value passed in through the initializer of the Course
type.
func testTitle_hasValuePassedInThroughInitializer() {
// given
let testTitle = "Course title"
// when
sut = Course(title: testTitle)
//... TO BE CONTINUED
}
We can’t finish writing our test since we get a compilation error when we call the non-existent initializer of the Course
type. To remove this error, we add the following property to the Course
struct.
var title: String
Now we can finish writing the above test.
func testTitle_hasValuePassedInThroughInitializer() {
// given
let testTitle = "Course title"
// when
sut = Course(title: testTitle)
// then
XCTAssertEqual(sut.title, testTitle)
}
This test passes without the need for any addition or modification to the production code.
For our next test, where we test the Course
type for the presence of a property named credits
, which should have the value passed in through the initializer, we again use the someString
constant we had defined earlier in the TestConstants
class. This helps us initialize a Course
instance for our test without having to worry about providing a new value for the title
parameter of the initializer, which is not relevant to this test.
func testCredits_hasValuePassedInThroughInitializer() {
// given
let testCredits = 5.0
// when
sut = Course(title: TestConstants.someString, credits: testCredits)
// ... TO BE CONTINUED
}
In a pattern that would have become familiar by now, we have to stop writing the test and add the following property in the Course
struct.
var credits: Double
This causes our earlier test to stop compiling since it expects the Course
type to have an initializer with a single parameter. We fix this by changing the when
section of that test as shown below.
// when
sut = Course(title: testTitle, credits: TestConstants.someDouble)
Now we can finish writing our partly written test, which passes without any further change to the production code.
func testCredits_hasValuePassedInThroughInitializer() {
// given
let testCredits = 5.0
// when
sut = Course(title: TestConstants.someString, credits: testCredits)
// then
XCTAssertEqual(sut.credits, testCredits)
}
4. Enrolled students
With all tests passing, we create a new test class for an EnrolledStudent
type. As before, we have to stop after writing the following test code because of a compilation error.
class EnrolledStudentTests: XCTestCase {
private var sut: EnrolledStudent!
// ... TO BE CONTINUED
}
We define an EnrolledStudent
struct in our production code as shown below to enable us to proceed.
struct EnrolledStudent: Equatable {
}
Now we can finish writing the skeleton for our new test class.
class EnrolledStudentTests: XCTestCase {
private var sut: EnrolledStudent!
override func tearDown() {
sut = nil
}
}
The first test in this class is for a courses
property, which should hold an empty collection when an instance is first created.
func testCourses_isEmpty() {
// when
sut = EnrolledStudent()
// then
XCTAssertTrue(sut.courses.isEmpty)
}
This test does not compile since EnrolledStudent
does not have the required property. To remedy this, we add the following property to the EnrolledStudent
struct, which makes the above test compile and pass.
var courses = [Course]()
Note that our test does not require a collection type with any specific features so we have used the Array
type, which meets the requirement of the test.
Next, we write a test for an enrolled student taking a new course, which should result in the newly taken course being the only course in the courses
collection of the EnrolledStudent
instance.
func testTakeCourse_newCourseIsTheOnlyCourseInCourses() {
// given
let testCourse = Course(title: TestConstants.someString, credits: TestConstants.someDouble)
sut = EnrolledStudent()
// when
sut.takeCourse(testCourse)
// ... TO BE CONTINUED
}
To finish writing this test, we first add the following method to the EnrolledStudent
struct.
func takeCourse(_: Course) {
}
Now we can finish writing the test.
func testTakeCourse_newCourseIsTheOnlyCourseInCourses() {
// given
let testCourse = Course(title: TestConstants.someString, credits: TestConstants.someDouble)
sut = EnrolledStudent()
// when
sut.takeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(testCourse))
}
Note that, as we had done when testing the Invoice
type and for the same reasons as explained there, we have written two assertions, one to verify that the courses
collection contains one element, and the other to verify that it contains the course that the student has taken.
To pass this test, we modify the takeCourse(_:)
method as shown below.
mutating func takeCourse(_ course: Course) {
courses.append(course)
}
As noted earlier in this article, while writing code we should constantly be on the lookout for opportunities for refactoring not only production code but also test code. Here we can see that in the two tests we have so far added to the EnrolledStudentTests
class, we are initializing the sut
in exactly the same way. To remove this duplication, we add a setUp()
method where we initialize the sut
, and remove the initialization code from the tests, as shown below.
class EnrolledStudentTests: XCTestCase {
private var sut: EnrolledStudent!
override func setUp() {
sut = EnrolledStudent()
}
override func tearDown() {
sut = nil
}
func testCourses_isEmpty() {
XCTAssertTrue(sut.courses.isEmpty)
}
func testTakeCourse_newCourseIsTheOnlyCourseInCourses() {
// given
let testCourse = Course(title: TestConstants.someString, credits: TestConstants.someDouble)
// when
sut.takeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(testCourse))
}
}
As with every refactoring step, we run all tests to make sure nothing has been broken.
Before we write our next test, we add the following property to TestConstants
.
static let someOtherString = "Another string"
Now we can write a test that when an enrolled student takes a second course, that course gets added to the courses
collection of the EnrolledStudent
instance.
func testTakeCourse_oneCourseTaken_newCourseAddedToCourses() {
// given
let testCourse = Course(title: TestConstants.someString, credits: TestConstants.someDouble)
sut.takeCourse(testCourse)
let secondTestCourse = Course(title: TestConstants.someOtherString, credits: TestConstants.someOtherDouble)
// when
sut.takeCourse(secondTestCourse)
// then
XCTAssertEqual(sut.courses.count, 2)
XCTAssertTrue(sut.courses.contains(testCourse))
XCTAssertTrue(sut.courses.contains(secondTestCourse))
}
This test passes without requiring any change to the production code.
Now we have another opportunity to refactor this test class. The test we have just written uses a locally defined let
constant testCourse
. The test we wrote earlier also defines and uses a let
constant with the same name and value. To remove this duplication, we define the following property in the test class.
private let testCourse = Course(title: TestConstants.someString, credits: TestConstants.someDouble)
We then rewrite the last test we wrote, as shown below.
func testTakeCourse_oneCourseTaken_newCourseAddedToCourses() {
// given
sut.takeCourse(testCourse)
let secondTestCourse = Course(title: TestConstants.someOtherString, credits: TestConstants.someOtherDouble)
// when
sut.takeCourse(secondTestCourse)
// then
XCTAssertEqual(sut.courses.count, 2)
XCTAssertTrue(sut.courses.contains(testCourse))
XCTAssertTrue(sut.courses.contains(secondTestCourse))
}
We also rewrite the previous test to use the same instance property instead of defining a local let
constant.
func testTakeCourse_newCourseIsTheOnlyCourseInCourses() {
// when
sut.takeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(testCourse))
}
We run all tests to make sure everything passes.
Next, we test that an enrolled student should not be allowed to attempt to take a duplicate course.
func testTakeCourseWithDuplicateCourse_oneCourseTaken_newCourseNotAddedToCourses() {
// given
sut.takeCourse(testCourse)
// when
sut.takeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(testCourse))
}
This test fails because the Array
type allows addition of duplicate elements. Rather than creating our own duplication check, we can use the Set
type from the standard library which better fits our requirements since a set by definition is an unordered collection of unique elements. Accordingly, we modify the EnrolledStudent
struct as shown below.
struct EnrolledStudent: Equatable {
var courses = Set<Course>()
mutating func takeCourse(_ course: Course) {
courses.insert(course)
}
}
This does not compile, however, since the Swift Set
type requires that its elements conform to the Hashable
protocol. Sets need a way to efficiently test for membership and the Set
type does this using hash values. The Hashable
protocol ensures that all conforming types provide a way to produce an integer hash value. Swift makes it easy to add Hashable
conformance by automatically synthesizing it for user-defined types as long as Hashable
conformance is declared when the type is defined and all its stored properties are of types that conform to Hashable
. Since both the String
and Double
types conform to Hashable
, all we have to do is declare Hashable
conformance in the declaration of the Course
type, as shown below.
struct Course: Equatable, Hashable {
var title: String
var credits: Double
}
This makes the code compile, and the test we added above passes.
Next, we write a test for an enrolled student to complete a course. This should remove the course from the collection of courses currently being taken by the student. First, we write a test for a student currently taking a single course.
func testCompleteCourse_oneCourseTaken_coursesIsEmpty() {
// given
sut.takeCourse(testCourse)
// when
sut.completeCourse(testCourse)
// ... TO BE CONTINUED
}
We cannot finish writing this test until we add the following method to the EnrolledStudent
struct.
func completeCourse(_: Course) {
}
Now we can finish writing the test.
func testCompleteCourse_oneCourseTaken_coursesIsEmpty() {
// given
sut.takeCourse(testCourse)
// when
sut.completeCourse(testCourse)
// then
XCTAssertTrue(sut.courses.isEmpty)
}
To make this test pass, we modify the completeCourse(_:)
method as shown below.
mutating func completeCourse(_ course: Course) {
courses.remove(course)
}
We write another test to confirm that if a student is taking two courses, completing one course removes the correct course from the courses
collection.
func testCompleteCourse_twoCoursesTaken_coursesHasOnlyCourseNotCompleted() {
// given
sut.takeCourse(testCourse)
let secondTestCourse = Course(title: TestConstants.someOtherString, credits: TestConstants.someOtherDouble)
sut.takeCourse(secondTestCourse)
// when
sut.completeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(secondTestCourse))
}
This test passes without the need for any change to the production code.
With all greens, we do a bit of refactoring in our test code. Since we have locally defined the let
constant secondTestCourse
in two tests with exactly the same value, we factor it out and define it as an instance property in the EnrolledStudentTests
class.
private let secondTestCourse = Course(title: TestConstants.someOtherString, credits: TestConstants.someOtherDouble)
We can now rewrite the last test we wrote as shown below.
func testCompleteCourse_twoCoursesTaken_coursesHasOnlyCourseNotCompleted() {
// given
sut.takeCourse(testCourse)
sut.takeCourse(secondTestCourse)
// when
sut.completeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(secondTestCourse))
}
We also rewrite the test we wrote earlier that locally defines secondTestCourse
to use the same instance property.
func testTakeCourse_oneCourseTaken_newCourseAddedToCourses() {
// given
sut.takeCourse(testCourse)
// when
sut.takeCourse(secondTestCourse)
// then
XCTAssertEqual(sut.courses.count, 2)
XCTAssertTrue(sut.courses.contains(testCourse))
XCTAssertTrue(sut.courses.contains(secondTestCourse))
}
We run all the tests to make sure we still have greens all around.
5. Casual students
Now we turn our attention to casual students who are not enrolled but may take courses as and when they like. As before, we start by creating a test class.
class CasualStudentTests: XCTestCase {
private var sut: CasualStudent!
// ... TO BE CONTINUED
}
We get an error because the CasualStudent
type does not exist, which we define as shown below.
struct CasualStudent: Equatable {
}
This allows us to finish creating the skeleton of the test class.
class CasualStudentTests: XCTestCase {
private var sut: CasualStudent!
override func tearDown() {
sut = nil
}
}
Since there is no difference between how enrolled and casual students take and complete courses, all the tests related to taking and completing courses, and the corresponding production code, will be identical to what we have written above for enrolled students. Whilst in a real project we will still write the tests and the production code line by line, in the interest of space in this article, we will simply copy the test and production code related to taking and completing courses from EnrolledStudentTests
to CasualStudentTests
, and from EnrolledStudent
to CasualStudent
.
Here is the CasualStudentTests
class after copying the tests related to taking and completing courses along with the setUp()
method and the two instance properties that support these tests. The only change as would be expected is that the setUp()
method in this case creates an instance of CasualStudent
.
class CasualStudentTests: XCTestCase {
private let testCourse = Course(title: TestConstants.someString, credits: TestConstants.someDouble)
private let secondTestCourse = Course(title: TestConstants.someOtherString, credits: TestConstants.someOtherDouble)
private var sut: CasualStudent!
override func setUp() {
sut = CasualStudent()
}
override func tearDown() {
sut = nil
}
func testCourses_isEmpty() {
XCTAssertTrue(sut.courses.isEmpty)
}
func testTakeCourse_newCourseIsTheOnlyCourseInCourses() {
// when
sut.takeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(testCourse))
}
func testTakeCourse_oneCourseTaken_newCourseAddedToCourses() {
// given
sut.takeCourse(testCourse)
// when
sut.takeCourse(secondTestCourse)
// then
XCTAssertEqual(sut.courses.count, 2)
XCTAssertTrue(sut.courses.contains(testCourse))
XCTAssertTrue(sut.courses.contains(secondTestCourse))
}
func testTakeCourseWithDuplicateCourse_oneCourseTaken_newCourseNotAddedToCourses() {
// given
sut.takeCourse(testCourse)
// when
sut.takeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(testCourse))
}
func testCompleteCourse_oneCourseTaken_coursesIsEmpty() {
// given
sut.takeCourse(testCourse)
// when
sut.completeCourse(testCourse)
// then
XCTAssertTrue(sut.courses.isEmpty)
}
func testCompleteCourse_twoCoursesTaken_coursesHasOnlyCourseNotCompleted() {
// given
sut.takeCourse(testCourse)
sut.takeCourse(secondTestCourse)
// when
sut.completeCourse(testCourse)
// then
XCTAssertEqual(sut.courses.count, 1)
XCTAssertTrue(sut.courses.contains(secondTestCourse))
}
}
Here is the CasualStudent
struct, with the courses
property and the two methods for taking and completing courses, copied from EnrolledStudent
.
struct CasualStudent: Equatable {
var courses = Set<Course>()
mutating func takeCourse(_ course: Course) {
courses.insert(course)
}
mutating func completeCourse(_ course: Course) {
courses.remove(course)
}
}
Just to repeat, in a real project we would have written all this test and production code incrementally just as we did for enrolled students. Here we have copied only to save space in this article. Just to make sure everything still works, we run all the tests once again.
6. Refactoring (round 1)
Having written (or in this case, copied) some duplicate code to pass our tests, let’s see how we can refactor this code. This is made easy by Swift since Swift protocols enable us to retroactively introduce abstractions in our code. So we can write concrete code first without having to worry about code duplication or abstractions. This avoids having to lock ourselves into abstractions too early in the development process. This is useful in particular when we are still exploring some of the functionality that we are looking to implement.
As we have noted before, the entire functionality related to taking and completing courses is duplicated in the EnrolledStudent
and CasualStudent
structs.
We begin by focusing on the functionality related to taking courses, by defining a protocol named CourseTaking
, as shown below.
protocol CourseTaking {
var courses: Set<Course> { get set }
mutating func takeCourse(_: Course)
}
We then implement the method required by this protocol in a protocol extension. The implementation of the method is identical to what is already contained in the EnrolledStudent
and CasualStudent
structs, which we remove from these structs.
extension CourseTaking {
mutating func takeCourse(_ course: Course) {
courses.insert(course)
}
}
We make the EnrolledStudent
and CasualStudent
structs conform to CourseTaking
by means of extensions on the structs, as shown below.
extension EnrolledStudent: CourseTaking {}
extension CasualStudent: CourseTaking {}
We run all the tests to make sure the refactoring hasn’t broken anything.
We then do the same for the functionality related to completing courses, starting by adding the following method to the CourseTaking
protocol.
mutating func completeCourse(_: Course)
We add the implementation of this new method in the extension on CourseTaking
that we have defined above. The implementation of the method is identical to what is already contained in the EnrolledStudent
and CasualStudent
structs, which we remove from these structs.
mutating func completeCourse(_ course: Course) {
courses.remove(course)
}
Having completed this round of refactoring, we run all the tests again to make sure everything passes.
7. Generating invoices
The next step is to see how fees will be calculated for enrolled students so they can be invoiced. Because all enrolled students pay the same enrollment fee, to calculate the fees to be paid by enrolled students we first need some place to store the enrollment fee amount. We also need to store the amount of the fee per course credit for enrolled students to calculate course fees for enrolled students who are taking courses.
We could store these fee amounts using instance properties in the EnrolledStudent
struct but that would make the design inflexible. Instead we decide to create a Configuration
type to store all fee amounts so it will be easier to configure them as required. In the interest of space, we will not get into how these properties could be configured, which can be done in a number of ways, e.g., reading the fee amounts from a configuration file, creating a configuration screen in the user interface, etc. For the purposes of this article, we will just create a Configuration
type, with the fee amounts as properties.
The first step is to create a test class for a Configuration
type, which will store the fee amounts as static
properties. Since there will be no instance properties or methods to test, we don’t need to create an instance of the type to be tested, which in earlier tests we had referred to as the system under test, and stored in a variable called sut
. Here we are just testing properties related to the type of the system under test, so we define sutType
as a typealias
for the Configuration
type, as shown below.
class ConfigurationTests: XCTestCase {
typealias sutType = Configuration
}
This code does not compile because we don’t yet have a Configuration
type in the production code. We remedy this by defining it as follows.
class Configuration {
}
Next, we write a test that the enrollment fee amount in the Configuration
type is a Double
with a value of 200.0
. This is just an arbitrary value that we will use for the purposes of this article.
func testEnrollmentFeeAmount_hasDoubleValue200() {
XCTAssertEqual(sutType.enrollmentFeeAmount, 200.0)
}
We make this test compile, and pass, by adding the following static
property to the Configuration
class.
static var enrollmentFeeAmount = 200.0
Having created a place for the enrollment fee amount to be used, we turn our attention back to EnrolledStudentTests
and add a test for a method that generates an invoice for an enrolled student who is not taking any courses so should pay only the enrollment fee with the same value as the enrollment fee amount in the Configuration
type. Here is how far we get before we get a compilation error.
func testGenerateInvoice_notTakingCourses_invoiceHasOnlyEnrollmentFee() {
// when
let invoice = sut.generateInvoice()
// ... TO BE CONTINUED
}
To remove this error, we add the following method to the EnrolledStudent
struct.
func generateInvoice() -> Invoice {
Invoice()
}
Now we can finish writing the test.
func testGenerateInvoice_notTakingCourses_invoiceHasOnlyEnrollmentFee() {
// when
let invoice = sut.generateInvoice()
// then
let expectedEnrollmentFee = Fee(category: .enrollment, amount: Configuration.enrollmentFeeAmount)
XCTAssertEqual(invoice.fees.count, 1)
XCTAssertTrue(invoice.fees.contains(expectedEnrollmentFee))
}
Now we see the test compile and fail, since the generateInvoice()
method we wrote just returns an empty invoice. To make the test pass, we modify this method as follows.
func generateInvoice() -> Invoice {
var invoice = Invoice()
invoice.addFee(Fee(category: .enrollment, amount: Configuration.enrollmentFeeAmount))
return invoice
}
Next, we need to write a test for an invoice for an enrolled student who is taking courses. Before we do that, we need to test that the Configuration
type has a property for the amount of the fee per course credit to be paid by enrolled students. We add the following test to ConfigurationTests
using an arbitrary value for the enrolledStudentCourseCreditFeeAmount
property.
func testEnrolledStudentCourseCreditFeeAmount_hasDoubleValue50() {
XCTAssertEqual(sutType.enrolledStudentCourseCreditFeeAmount, 50.0)
}
The above test fails because this property does not exist. We make the test compile and pass by adding the following static
property to the Configuration
class.
static var enrolledStudentCourseCreditFeeAmount = 50.0
Now we can write a test to verify that the invoice for an enrolled student who is taking courses contains both an enrollment fee and fees for all the courses being taken by the student, using the enrollment and course credit fee amounts contained in the Configuration
class.
func testGenerateInvoice_takingCourses_invoiceHasEnrollmentFeeAndCourseFees() {
// given
let testCourseCreditsArray = [1.0, 2.0, 3.0]
for testCourseCredits in testCourseCreditsArray {
let course = Course(title: TestConstants.someString, credits: testCourseCredits)
sut.takeCourse(course)
}
// when
let invoice = sut.generateInvoice()
// then
let expectedEnrollmentFee = Fee(category: .enrollment, amount: Configuration.enrollmentFeeAmount)
XCTAssertEqual(invoice.fees.count, testCourseCreditsArray.count + 1)
XCTAssertTrue(invoice.fees.contains(expectedEnrollmentFee))
for testCourseCredits in testCourseCreditsArray {
let expectedCourseFee = Fee(category: .course(TestConstants.someString), amount: testCourseCredits * Configuration.enrolledStudentCourseCreditFeeAmount)
XCTAssertTrue(invoice.fees.contains(expectedCourseFee))
}
}
The test shows multiple failures since the generateInvoice()
method as written above only considers the enrollment fee. To make the test pass, we modify the generateInvoice()
method in EnrolledStudent
as shown below.
func generateInvoice() -> Invoice {
let enrollmentFee = Fee(category: .enrollment, amount: Configuration.enrollmentFeeAmount)
let courseFees = courses.map() { course in
Fee(category: .course(course.title), amount: course.credits * Configuration.enrolledStudentCourseCreditFeeAmount)
}
return ([enrollmentFee] + courseFees).reduce(into: Invoice()) { invoice, fee in
invoice.addFee(fee)
}
}
The main difference between enrolled and casual students is in the calculation of fees. Casual students don’t pay an enrollment fee so the invoice for a casual student should have no fees when the student is not taking any courses. We add a test for this in CasualStudentTests
as shown below.
func testGenerateInvoice_notTakingCourses_invoiceDoesNotHaveAnyFee() {
// when
let invoice = sut.generateInvoice()
// ... TO BE CONTINUED
}
We get an error because CasualStudent
does not have a generateInvoice()
method, which we add as shown below.
func generateInvoice() -> Invoice {
Invoice()
}
This allows us to complete, and pass, the test shown below.
func testGenerateInvoice_notTakingCourses_invoiceDoesNotHaveAnyFee() {
// when
let invoice = sut.generateInvoice()
// then
XCTAssertTrue(invoice.fees.isEmpty)
}
Our last test is for a casual student who is taking courses. In such a case, the invoice should contain fees for all the courses the student is taking. Since casual students pay a different course credit fee from enrolled students, however, we first need to add a property to the Configuration
class to represent the amount of the course credit fee to be paid by casual students.
We add the following test to ConfigurationTests
, using an arbitrary value for the amount of the course credit fee for casual students.
func testCasualStudentCourseCreditFeeAmount_hasDoubleValue75() {
XCTAssertEqual(sutType.casualStudentCourseCreditFeeAmount, 75.0)
}
To make this test compile and pass, we add the following property to the Configuration
class.
static var casualStudentCourseCreditFeeAmount = 75.0
Now we can add the test shown below to CasualStudentTests
.
func testGenerateInvoice_takingCourses_invoiceHasCourseFees() {
// given
let testCourseCreditsArray = [1.0, 2.0, 3.0]
for testCourseCredits in testCourseCreditsArray {
let course = Course(title: TestConstants.someString, credits: testCourseCredits)
sut.takeCourse(course)
}
// when
let invoice = sut.generateInvoice()
// then
XCTAssertEqual(invoice.fees.count, testCourseCreditsArray.count)
for testCourseCredits in testCourseCreditsArray {
let expectedCourseFee = Fee(category: .course(TestConstants.someString), amount: testCourseCredits * Configuration.casualStudentCourseCreditFeeAmount)
XCTAssertTrue(invoice.fees.contains(expectedCourseFee))
}
}
As expected, this tests shows multiple failures so we modify the generateInvoice()
method in CasualStudent
as follows.
func generateInvoice() -> Invoice {
courses.map() { course in
Fee(category: .course(course.title), amount: course.credits * Configuration.casualStudentCourseCreditFeeAmount)
}.reduce(into: Invoice()) { invoice, fee in
invoice.addFee(fee)
}
}
8. Refactoring (round 2)
In this round, we will refactor the functionality related to generating invoices. Note that we don’t currently have any code duplication in the EnrolledStudent
and CasualStudent
structs so this refactoring is not required from that perspective. What we will do is extract the code related to generating invoices from the structs and put it into protocol extensions. This not only further demonstrates the protocol-oriented approach to programming but also shows a design that could prove more flexible and scalable over time.
We start by creating a protocol named Invoicable
, which contains the generateInvoice()
method.
protocol Invoicable {
func generateInvoice() -> Invoice
}
We know that fee calculation for enrolled students is different from that for casual students. Therefore, we need a way for the Invoicable
protocol to use different implementations of generateInvoice()
depending on whether the student for whom the fees are being calculated is enrolled or casual.
We deal first with enrolled students by defining the following protocol.
protocol Enrolled {}
This protocol does not have any functionality in itself but it will allow us to define a constrained extension on the Invoicable
protocol.
Next we make EnrolledStudent
conform to the above protocol through the following extension.
extension EnrolledStudent: Enrolled {}
We define a constrained extension on the Invoicable
protocol to cater to enrolled students who are taking courses, moving the generateInvoice()
method from the EnrolledStudent
struct to the protocol extension. Note that the constraint we have added to the protocol extension means that it applies only when the conforming type also conforms to both the Enrolled
and CourseTaking
protocols. This makes the courses
property of CourseTaking
accessible to the generateInvoice()
method.
extension Invoicable where Self: Enrolled & CourseTaking {
func generateInvoice() -> Invoice {
let enrollmentFee = Fee(category: .enrollment, amount: Configuration.enrollmentFeeAmount)
let courseFees = courses.map() { course in
Fee(category: .course(course.title), amount: course.credits * Configuration.enrolledStudentCourseCreditFeeAmount)
}
return ([enrollmentFee] + courseFees).reduce(into: Invoice()) { invoice, fee in
invoice.addFee(fee)
}
}
}
Finally, we make EnrolledStudent
conform to the Invoicable
protocol, as shown below.
extension EnrolledStudent: Invoicable {}
After running all tests to make sure nothing has been broken, we turn our attention to casual students.
Following the pattern we have used for enrolled students, we start by defining a Casual
protocol, and make CasualStudent
conform to it.
protocol Casual {}
extension CasualStudent: Casual {}
Then we define a constrained extension on the Invoicable
protocol for casual students who are taking courses, this time moving the generateInvoice()
implementation from the CasualStudent
struct.
extension Invoicable where Self: Casual & CourseTaking {
func generateInvoice() -> Invoice {
courses.map() { course in
Fee(category: .course(course.title), amount: course.credits * Configuration.casualStudentCourseCreditFeeAmount)
}.reduce(into: Invoice()) { invoice, fee in
invoice.addFee(fee)
}
}
}
Finally, we make CasualStudent
conform to the Invoicable
protocol, as shown below.
extension CasualStudent: Invoicable {}
As has been our practice, we run all the tests again to make sure everything passes.
This completes our refactoring, which has taken all the functionality out of the EnrolledStudent
and CasualStudents
structs and placed it in protocol extensions. The refactored EnrolledStudent
and CasualStudent
structs just provide storage for the courses
property.
struct EnrolledStudent: Equatable {
var courses = Set<Course>()
}
struct CasualStudent: Equatable {
var courses = Set<Course>()
}
This demonstrates not only the protocol-oriented approach to programming that Swift enables but also the ease and confidence with which we can refactor our code in fundamental ways when we have tests that validate every aspect of functionality provided by the code. This, as noted earlier in the article, is one of the key benefits of using TDD.
Conclusion
TDD helps us write code that is not only less prone to bugs but also easier to extend and maintain. Those new to TDD may be surprised at first by the seemingly slow pace at which new code gets written and the constant switching between writing test and production code. This, however, is the foundation of the discipline and rhythm of TDD which becomes second nature over time, and programmers used to this approach can find it hard to write code for a new feature without first writing a test for it.
Thank you for reading! I always appreciate constructive comments and feedback. Please feel free to leave a comment in the comments section of this post or start a conversation on X.
Adnan says
Thank you for such an incredibly well explained tutorial, you’ve really made the benefits of TDD shine.
The style of teaching was ideal for me, it felt like ‘pair programming’ where a test was written and I was coding the implementation.
Please do write more such tutorials.
Khawer Khaliq says
Hi Adnan,
Thanks for you kind words. Glad that you found the article useful.
It is comments like yours that provide the motivation to keep writing. Do keep reading and providing feedback.
Cheers,
Khawer