About
News UK
Is the Red-Green-Refactor Cycle of Test-Driven Development Good?
Evaluating the effectiveness of using TDD
Blog by Nishant Aanjaney Jalan
I have heard of TDD for a couple of years now and implemented it briefly in personal projects and college coursework. However, I never extensively sat down with TDD and learn its core philosophy until recently. As I went over this common practice in software development, I grew increasingly worried about how to strictly follow such a routine and maintain productivity as a software engineer.
This article details my research and evaluates the effectiveness of TDD. It will also encourage you to re-think the ways of the popular approach to software development.
What is Test-Driven Development?
Test-Driven Development — a programming practice where the tests drive the software code written. The basic idea is to write some test cases, and then modify the code to make sure those tests pass. This sounds perfectly reasonable as it aligns with what is discussed above. This practice comes with a strict routine and rhythm one should follow to ensure minimum code defects
Red-Green-Refactor cycle
James Shore beautifully explains the TDD cycle in his blog here. To summarise his blog, TDD mainly comprises 3 cycles:
- Red stage — We write one failing test case (hence, the name red) on the feature we wish to implement.
- Green stage — Now we need to make that failing test pass as fast as possible (hence, the name green).
- Refactor stage — Improve on the existing solution, making sure that the test still passes.
Expectations of TDD
We are given a task to implement a Cash Register interface. The specification or acceptance criteria are listed as follows:
scan(String, Double)
is invoked when a new item is added to the bill.discount(Double)
is invoked when there is a discount on the bills.getCurrentBill()
is invoked whenever you need to get the current status, with the headings"Item | Quantity | Amount"
and a footer withDiscount
andTotal
fields.
Development Process
class CashierTest {
private lateinit var cashier: Cashier
@BeforeEach
fun setup() {
cashier = Cashier() // 💣 Cashier does not exist
}
}
Let’s make the Cashier class,
class Cashier
We should write our failing test:
class CashierTest {
private lateinit var cashier: Cashier
@BeforeEach
fun setup() {
cashier = Cashier()
}
@Test
fun `no scans result in empty bill`() {
val result = cashier.getCurrentBill() // 💣 getCurrentBill() does not exist
assertEquals(result, "")
}
}
We have completed the red stage of the cycle. we need to define this function now.
class Cashier {
fun getCurrentBill() = ""
}
After running the test, we see a green. Yay! There is no room for improvement/refactoring, so let’s write the second test.
class CashierTest {
// ...
@Test
fun `one scan gives a row in the bill`() {
cashier.scan("Apples", 2.4) // 💣 scan(String, Double) does not exist
val result = cashier.getCurrentBill()
assertEquals(result, "Item | Quantity | Amount\n" +
"Apples | 1 | 2.4\n" +
"Discount: 0\n" +
"Total: 2.4")
}
}
Again, the test is failing.. we need to make minimal implementation changes to make our test pass.
class Cashier {
var item: String = ""
var amount: Double = 0.0
fun getCurrentBill() = "Item | Quantity | Amount\n" +
"$item | 1 | $amount\n" +
"Discount: 0\n" +
"Total: $amount"
fun scan(item: String, amount: Double) {
this.item = item
this.amount = amount
}
}
Hurray! We have 2 passing test cases. Now for the third test:
class CashierTest {
// ...
@Test
fun `two scans give two rows in the bill`() {
cashier.scan("Apples", 2.4)
cashier.scan("Bananas", 1.3)
val result = cashier.getCurrentBill()
assertEquals(result, "Item | Quantity | Amount\n" +
"Apples | 1 | 2.4\n" +
"Bananas | 1 | 1.3\n" +
"Discount: 0\n" +
"Total: 3.7") // ❌ Assertion fails
}
}
For making this test case pass, we will have to implement a map data structure, have a function to sum the total amount, and display the bill in the correct format. This involves the deletion of the existing code and modification of the entire code.
In the future, you might write this test case:
class CashierTest {
// ...
@Test
fun `scanning same item twice increases quantity`() {
cashier.scan("Apples", 2.4)
cashier.scan("Apples", 2.4)
val result = cashier.getCurrentBill()
assertEquals(result, "Item | Quantity | Amount\n" +
"Apples | 2 | 2.4\n" +
"Discount: 0\n" +
"Total: 4.8") // ❌ Assertion fails
}
}
Whoops, now you realise you must use a data class
and a list of elements to keep track of the quantity.
You are coding a program with little to no room for defects. However, your productivity is massively declining at this rate.
You’re having to write some code, and immediately delete it to make sure new tests succeed. While this may be a great way to produce a flawless error-free program, you’re losing your productivity.
How to make TDD better? — A solution
Making TDD fun is possible by breaking the laws of TDD, or rather tuning. Most developers, I suppose, already follow this. If you don’t, have a go at this:
Refined Development Process for TDD
The specification is the same as described above. Let us first write all (or most) of the tests you wish to incorporate into this program. Constitute this to be the red stage of the “cycle”.
class CashierTest {
private lateinit var cashier: Cashier
@BeforeEach
fun setup() {
cashier = Cashier() // 💣 Cashier does not exist
}
@Test
fun `no scans result in empty bill`() {
val result = cashier.getCurrentBill() // 💣 getCurrentBill() does not exist
assertEquals(result, "")
}
@Test
fun `one scan gives a row in the bill`() {
cashier.scan("Apples", 2.4) // 💣 scan(String, Double) does not exist
val result = cashier.getCurrentBill()
assertEquals(result, "Item | Quantity | Amount\n" +
"Apples | 1 | 2.4\n" +
"Discount: 0\n" +
"Total: 2.4")
}
@Test
fun `two scans give two rows in the bill`() { /* ... */ }
@Test
fun `scanning same item twice increases quantity`() { /* ... */ }
@Test
fun `adding a discount reduces the total`() {
cashier.scan("Apples", 2.4) // 💣 scan(String, Double) does not exist
cashier.scan("Bananas", 1.3)
cashier.discount(5)
val result = cashier.getCurrentBill()
assertEquals(result, "Item | Quantity | Amount\n" +
"Apples | 1 | 2.4\n" +
"Bananas | 1 | 1.3\n" +
"Discount: 5%\n" +
"Total: 3.515")
}
}
Note: refer /* ... */
to the code written in the previous section.
Now that we have a rough idea of how the class should look, we can start building this. Based on the tests and how the test code is written, we can think ahead and start to formulate how our ultimate code should look like.
Instantly, we can start by creating a data class
to that keeps track of the quantity on the fly. This is your green and refactor stage hand-in-hand.
You don’t necessary need to build the entire application in one go but if you are aware of the end goal, then you know a direction to take to reduce code defects and maintain productivity as well.
Let’s say, at some intermediate stage, your code looks like the following:
data class Item(
val name: String,
val amount: Double,
var quantity: Int = 1
) // not implemented equals - important for the discount test case
private fun formattedBill(items: List<Item>): String { /* ... */ }
class Cashier {
private val bill = mutableListOf<Item>()
fun getCurrentBill() = formattedBill(bill)
fun scan(item: String, amount: Double) {
bill.add(Item(item, amount)) // Not checking multiple
// scans of single item
}
fun discount(percent: Double) {
TODO("not yet implemented") // runtime error in
// the discount test case
}
}
You don’t pass all the tests, but in such a stage, you have a good sense of direction towards your end goal; unlike strict TDD that forces you to be completely oblivious.
This shows incremental development of software where you do not excessively delete code to finish the program. At the end of the day, not everyone will go back to see if you followed TDD strictly or not.
Conclusion — Should you use TDD?
With all these, you might conclude that TDD is brilliant. In theory, yes; however, studies have shown that although there’s a reduction in defects and improved maintainability, it has worsened productivity as developers require more effort into writing tests and implementations. Internal and external quality of code does not seem to have a significant difference between groups who use TDD and those who don’t.
Should you use TDD — this is a question that you should ask yourself. In my opinion, yes! Test-driven development is brilliant if fine-tuned to maintain productivity and produce bug-free code. One can practice writing incrementally complex code based on the test cases presented to them. Instead of taking the red-green-refactor cycle for every test case, consider a batch of test cases and use the cycle for that batch. It may not be as error-free as strict TDD; nonetheless, you are writing good code having a general sense of where you are headed without compromising your productivity and code quality.
In the world of path finding algorithms, why force yourself to be Dijkstra’s algorithm when you can be A* algorithm?
Latest News
- Predicting the Unpredictable
- talkSPORT extends partnership as official Premier League broadcaster
- talkSPORT lineup for West Indies v England white ball series
- Shortlist announced for The Sunday Times Sportswomen of the Year
- News UK Joins the Women in Football community
- Another round of impressive RAJAR results for News Broadcasting