Common misunderstandings and uncommon benefits of TDD

A pinch of history

Test-Driven Development was formed and popularized by Kevin Beck in 2003 (whose book you should definitely read if you haven't already). He wanted to resolve some problems known to any developer - to make dirty code cleaner, especially when it involves more advanced logic; to create more reliable programs when we have to work with legacy code and to make programming easier and solve complicated problems faster. You may be surprised but TDD, as many modern techniques, is much older than we think - it started in the ‘60s of last century and was then called “writing early unit-tests to micro-increments”. 

This concept relies on a very short development cycle using three unbreakable rules:

  • You can’t write any model code until you write a failing unit test first,
  • You can’t write more test code than is enough to fail,
  • You can’t write more model code than is needed to pass the currently failing test.

So considering these rules we can see why TDD has its own name - tests drive us to build better code. 

We cannot add anything to our model code if there is no failing test, and we cannot add another test if we don’t resolve the previous one. 

As we see, we have to work in a very short iteration, and every iteration adds something new to our production code and pushes the project forward. Does it look familiar? Maybe... agile? Yes, TDD is a good example of Agile Manifesto principles (which Kent Beck was also one of the founding fathers and creators by the way), especially these rules:

  • Working software is the primary measure of progress,
  • Sustainable development, able to maintain a constant pace.
Scalable Web Apps | Merixstudio's ebook

Why Test-Driven Development is not about...tests?

And here the biggest and most common mistake comes along. In order to create a new code we have to almost constantly add new and new tests, there is a conviction that Test-Driven Development is a discipline that creates new tests. And I’m not surprised that people think like that - it really looks like with TDD we create tests and well-tested code. Developers can use this methodology for a pretty long time and never think differently about it. So why do I claim differently?

Let’s jump into code. Not in particular language, but we try to use some pseudocode for simplicity and not focusing on specified implementation (if you would like to see implementation in “the real” code check Sławek’s article about TDD in React). For example, we try to build some Calculator class with addition and multiplication using TDD. Value Object design pattern will be most useful in this case. So let's start:

First, we have to create a failing test method, which adds two numbers. 

method test__add_2_and_3__should_equal_5() {
two = new Calculator(2);
five = two.add(3);
assertTrue(5, five.value);
}

Now we have to make this test to pass.

class Calculator(value) {
this.value = value;

method add(addend) {
return Calculator(this.value + addend);
}
}

We have to check if the test passes - and if so - we can create another test, and our application has one more working functionality. 

Let's stop for a moment and do a quick review of what just happened. As we said, we used Value Object pattern to create a new instance of Calculator everytime we use the addition method, and then we return this new object instead of modifying the old one. What can we do better in this case? For example, we have our object properties public and this is not an ideal solution. We should add a method to check if two Calculator objects are equal and try to assert that, but for the sake of this example, we can leave it like that. 

Now as we have to handle multiplications, we begin with writing the test.

method test__multiply_2_and_3__should_equal_6() {
two = new Calculator(2);
six = two.multiply(3);
assertTrue(6, six.value);
}

We should check if it fails, and if so - we can add some lines to our Calculator class.

class Calculator(value) {
this.value = value;

method add(addend) {
return Calculator(this.value + addend);
}

method multiply(multiplier) {
return Calculator(this.value * multiplier);
}
}

Ok. Now the test passed. What should we check next? Maybe do some multiplication and addition at once? Let's write test:

method test__multiply_2_and_3_and_add_4__should_equal_10() {
two = new Calculator(2);
six = two.multiply(3);
ten = six.add(4);
assertTrue(10, ten.value);
}

And... wait a moment. This test passed! So we should remove this test, because we can't add any production code, to make it pass - already everything is ok! So why did this just happen?

Schrödinger's Code

Let's talk a little more about tests and TDD. So from a QA and Developer perspective, when we write a test we want to check if the application meets some scenarios, if it can handle some different sets of data, or if we just test some edge cases to make sure our code works well. We are using tests for two entangled purposes - to find bugs and to make sure everything works as we assumed it should work.

Nevertheless, this is completely different while using tests in TDD. It is just a tool for building another functionality. We don't want to test our application. We just use tests to describe how the next functionality should work or look like. 

And to resolve this failing test we have to implement new functionality to our code. So this small step improves and extends our application in a measurable way. As you see, we cannot rely on TDD as a methodology which gives us a "well-tested code". We will build our test selectively, and there can be (and will be) some useful test cases that are not covered with TDD tests.

So this is a contradiction - our test method, when we use both addition and multiplication, is both useful (from QA perspective) and redundant (from TDD perspective). It's truly Schrödinger's Code.

Marty, we have to go back...

You probably wonder if there is any way to make this problematic test fail - so it will be useful from TDD perspective, and why won’t fail in the first place. Let's rollback our code and start using TDD in a pure manner, with small steps. With minimum comments and refactoring (but remember - refactoring after each cycle is very important in TDD) it could look like that:

Add a new failing test.

method test__add_2_and_3__should_equal_5() {
two = new Calculator(2);
five = two.add(3);
assertTrue(5, five);
}

Create code to make the test pass (remember the rules - we should add as little code as we have to - just to make the test pass).

class Calculator(value) {
method add(addend) {
return 5;
}
}

Add a new failing test.

method test__add_4_and_6__should_equals_10() {
four = new Calculator(4);
ten = four.add(6);
assertTrue(10, ten);
}

Create code to make the test pass.

class Calculator(value) {
this.value = value

method add(addend) {
return this.value + addend;
}
}

Add a new failing test.

method test__multiply_2_and_3__should_equal_6() {
two = new Calculator(2);
six = two.multiply(3);
assertTrue(6, six);
}

Create code to make the test pass.

class Calculator(value) {
this.value = value;

method add(addend) {
return this.value + addend;
}

method multiply(multiplier) {
return 6;
}
}

Add a new failing test.

method test__multiply_4_and_5__should_equal_20() {
four = new Calculator(4);
twenty = four.multiply(5);
assertTrue(20, twenty);
}

Create code to make the test pass.

class Calculator(value) {
this.value = value;

method add(addend) {
return this.value + addend;
}

method multiply(multiplier) {
return this.value * multiplier;
}
}

And now, we can finally add our "problematic" test.

method test__multiply_2_and_3_and_add_4__should_equal_10() {
two = new Calculator(2);
six = two.multiply(3);
ten = six.add(4);
assertTrue(10, ten);
}

We have to make the test pass, so implementing the Value Object pattern will be useful.

class Calculator(value) {
this.value = value;

method add(addend) {
return new Calculator(this.value + addend);
}

method multiply(multiplier) {
return new Calculator(this.value * multiplier);
}
}

So when we decide to make smaller steps (but not the smallest - we can easily make smaller ones) and reject all programming experience - we end up with the same result, but now we have many more tests! So this is another proof that TDD is not a methodology to write well-tested code. When we start thinking about the application using our experience - detect problems before they show up and design code using our knowledge - we will end up with fewer tests than someone who has less experience.

From a QA perspective this is a contradiction, but as we say - Testers use tests to find bugs. TDD uses tests to create new code. So what if we “think forward” and immediately implement better patterns? Yes, there will be fewer test cases, but code still will be tested in places that it should. Progress will be achieved faster because we can make bigger steps in coding. Fewer tests - even if it looks foolish - means that they are easier to maintain. Because tests - as any code - has to be maintained, reviewed and sometimes refactored or updated.

What do we know so far, and why should we use TDD?

So at this point, you already see or just start realising the difference between testing application and programming with TDD. Probably you think “there is so much fuss about this regime, why should I bother to use it?”. Here’s a couple of reasons. 

  • Simpler and more readable code

First of all - with TDD you have to make complicated problems simpler. You have to think how to break problems down - to small testable elements. That goes hand in hand with the first point of SOLID rule - Single Responsibility Principle - to make something testable it has to be simple and separated. So say “bye” to hundred-lines-methods, complicated conditions and increasing cyclomatic complexity. If you can’t achieve this, you are probably trying to take too big steps at once. 

  • Code is easier to hand over and refactor 

Maybe you haven’t noticed it yet, but with TDD you also create a well-commented code. Because tests are even better than comments - they show a real implementation of your assumptions: you can instantly see how your code works, what specified methods should do, and what results you should expect. So this is another argument for using TDD - when you create these simple unit tests, you make the quality of your code better - it’s easier to go back to older code, refactor something or pass your work to other team members or e.g. a remote team while outsourcing project. Because if you do something that breaks the basic logic of what your code should do - you can instantly find it.

  • Reduced technological debt

You may wonder if there are any cons, given all the pros. It looks like to create something new you have to spend more time preparing tests and instantly refactoring. And this all looks time-consuming. You are right, but you are also wrong. Writing code faster could be deceptive - you THINK you work faster, but as I said before, when you have to change anything in older code (and sometimes you can forget how a more complicated process works after a couple of weeks!) you have documentation in tests and simplicity of code. Technological debt is the worst nightmare, which can fire costs of expanding application through the roof after a couple of years. TDD is trying to minimize this effect.

Where is the catch? 

So it looks like it's a Holy Grail of programming. I’m afraid I have to say - no it isn’t. It’s just a tool - to create better code and to change the way of thinking. There are some cons of using it. For example, it’s very easy to “overtest” an application - do some tests which aren’t necessary (for instance testing getters/setters or any other methods without any logic in it). So it has to be used in moderation. Sometimes this methodology could be more difficult to implement - especially when you have to use TDD in existing applications or using frameworks. Ease of use will come with experience.

It should help you and your code not to make things more difficult. So if you are new with TDD and you want to use it, maybe you could use it to create the next feature for your current project? Start small and simple.
 

Navigate the changing IT landscape

Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .