How to Implement TDD on Existing Code
"Yeah TDD is great but it doesn't work in real life." π
If I had a penny for every time I heard a similar comment during my career as an iOS Developer I would be rich!
In this article, I'll go over how it is possible to do Test-Driven Development even after the code is implemented. And I promise it won't take as long as you think it will.
What is Test-Driven Development
According to Martin Fowler:
Test-Driven Development (TDD) is a technique for building software that guides software development by writing tests. It was developed by Kent Beck in the late 1990's as part of Extreme Programming.
In essence, the motto of TDD is this:
- Write a failing test
- Make it pass
- Refactor
These steps should be followed and repeated several times. By test-driving your application, you make sure that your code is testable and maintainable. With this approach, it's easier to have good maintainable code that works well into the future.
If it's that great why isn't everyone doing it?
One of the most common comments that I hear is that TDD is great, however, in a company setting where you need to do things fast, TDD takes too much time. And, you can still write good tests without TDD.
As with everything in life, there is a learning curve and TDD is no different, and fortunately, it gets faster once you get used to it. It's important to reflect on why this too much time, at first glance, we can say it's related to development costs. Which may increase over time if the feature is harder to maintain or bugs appear. So, in the long run, following TDD takes much less time than not following because it produces code with a lower development cost [2].
The comment "You can still write good tests without TDD" is both true and false. Indeed, you can still write good tests without TDD, however, you will most likely struggle to succeed since the development part is already done, and you're doing tests as a formality. Your code is not making you accountable.
Demonstration
Even if you're familiar with TDD it's important to do a simple exercise before we dive in into the topic at hand. I'm going to base my exercise on the exercise that Kent Beck gave in Chapter 3 of his book: Test-Driven Development By Example.
Imagine you are building software that handles money in several currencies. To convert from one currency to the other you should know the rate between them so you can multiply it.
The feature at hand is to be able to receive a money amount and with a given rate make a conversion to the requested currency.
Under the hood, this is a basic multiplication. We can start there.
Since we're starting with the tests, we're going to imagine the interface of our operation and how it will look from the outside. We start from the best possible outcome and work our way backwards to our desired code.
func test_multiplication() {
Dollar five = Dollar(5)
five.times(2)
XCTAssertEqual(10, five.amount)
}
No, you're not missing some section at this point, this code does not compile. But we accomplished the first step, we wrote a failing test. Now, let's make it pass.
It's failing because:
- Dollar class does not exist
- method times(Int) does not exist
- The property amount in dollars does not exist.
Let's create them.
final class Dollar {
var amount: Int?
init(_ amount: Int) {
}
func times(_ multiplier: Int) {
}
}
The build passes now but not the test, what is the smallest change you can think of for the test to pass?
Let's make a sacrifice now and add the amount as a constant value:
let amount: Int = 10
Now, we just accomplished the second part of the TDD process, we made the test pass.
However this is very smelly, we can now move to the refactor. What is the smallest refactor you can think of? We can add duplicated code, from the test to our class.
let amount: Int = 5 * 2
The test passes again, let's refactor, now, does it make more sense to move this multiplication to the times() method?
func times(_ otherAmount: Int) {
amount = 5 * 2
}
Test passes again, let's remove more duplication. The 5 comes from initializing an instance of Dollar, let's move it to the constructor.
final class Dollar {
var amount: Int
init(_ amount: Int) {
self.amount = amount
}
func times(_ multiplier: Int) {
amount = amount * 2
}
}
Test passes again, let's remove the final duplication. The 2 value that comes from the multiplier.
final class Dollar {
var amount: Int
init(_ amount: Int) {
self.amount = amount
}
func times(_ multiplier: Int) {
amount = amount * multiplier
}
}
We can mark this first test as done and finish this brief sample.
Does the code that you implement have steps this small? Of course not, this is just an example, however, as Kent Beck, the creator of TDD, put it:
(...) TDD is not ablout taking teensy tiny steps, it's about being able to take teensy tiny steps [3].
In this example we were able to generalize the code by replacing constants with variables, we told a story of how we wanted to view this operation and created small incrementable implementations with passing tests each time.
We can now move to the topic at hand, how you can write tests for existing code.
How to Do TDD With Code That Is Already Implemented
As developers, we usually work in a legacy code environment where we're adding a new feature to a huge class that already has little to no tests. What should you do in this case?
The Development of the Feature is Already Done
There is no way around it, if you already develop the feature there is no development to be done in this case. You should try and implement the tests that you think that feature needs. After implementing the tests you may try to refactor using Test-Driven Development, otherwise, it's not worth it.
Developing a Feature in Legacy Code
As I stated before, TDD requires discipline, especially in the case of adding a new feature in code that already doesn't use TDD.
The priority is, as stated by Michael C. Feathers in Working Effectively with Legacy Code, your priority should be to add a test harness to the class.
I'm going to use the example that Michael C. Feathers uses in Chapter 9, the Credit Validator [4].
The simplest way to do it is to try to initialize the class with no arguments.
let creditValidator = CreditValidator()
This will most likely fail since you need to do some dependency injection in this case. There is no Credit Validator without other parameters.
In the case of the book, the Credit Validator class used RGHConnections that connected directly to an API. This takes a lot of time and the test wouldn't be reliable (the server could be down at any moment and the test would fail).
By creating a fake object it would be possible to use a RGHConnection class without accessing the server directly.
How can you create an RGHConnection instance that has a fake and a production implementation? By extracting this into an interface.
We could then create a class that implements this interface and reply with the values that we needed in the Credit Validator.
And little by little you're creating a test harness for your legacy class. With this, you can start implementing your features using TDD.
Of course in most classes, there are several dependencies and this may become loathsome to do after some time, however, I can assure you that you're doing yourself a favour by already creating a test suite of testable and maintainable code.
Conclusion
In summary, implementing Test-Driven Development takes a lot of discipline and what I just told you requires additional work to your already busy day. However, you want to develop software that is maintainable and easy to add and remove features. The only way to do that is through test-driven development.
And as Michael C. Feathers [5] put it:
(...) The beginnings of those iterations are terrible. People feel that they arenβt getting all the work done that they need to. But slowly, they start to discover that they are revisiting better code. Their changes are getting easier, and they know in their gut that this is what it takes to move forward in a better way. It takes time for a team to get over that hump, but if there is one thing that I could instantaneously do for every team in the world, it would be to give them that shared experience, that experience that you can see in their faces: βBoy, we arenβt going back to that again.β
If enjoyed this and other articles please don't forget to support my work by following me on LinkedIn, X and Github. Thank you for reading. ππ»
References
[1] Test Driven Development by Martin Fowler: https://martinfowler.com/bliki/TestDrivenDevelopment.html
[2] Chapter 1: What is TDD? iOS Test-Driven Development by Tutorials by Joshua Greene & Michael Katz, page 26
[3] Chapter 3: Money Example Test-Driven Development by Example by Kent Beck, page 13
[4] Chapter 9: I can't get this class into a test harness: Working Effectively with Legacy Code by Michael C. Feathers page 109
[5] Chapter 6: I don't have much time and I have to change it: Working Effectively with Legacy Code by Michael C. Feathers page 64