Test-Driven Development (TDD) and Why People Get it Wrong
Whether at school, work, or while watching a YouTube video almost every single developer has been introduced to Test-Driven Development, AKA TDD.
For the following examples we will build out a calculator (for simplicity) in Typescript. However, all these principles are language agnostic.
Most tutorials of TDD explain it in more or less the same way:
- You receive a completely new feature request
- You write the test case
- Your test case fails
- You write the corresponding code
- You run your test again
- Your test passes
The code sample might look something like this:
You would then update the method to make it work, ending up with something like this:
Unfortunately, this is where the majority of TDD articles end, which in my opinion, misses the whole point of TDD.
The real magic of TDD comes after the initial features are built in two main use cases:
- Bugs: proving that one existed and proving that you fixed it.
- Evolution of features and proving that they meet the acceptance criteria.
Bugs
Let’s add some more functions to our calculator (and some new tests, of course) with a well-placed bug:
Any seasoned developer (or anybody who has used a calculator before) will see that there is a bug in the subtract method, however, our unit tests still pass. Dang! Looks like our unit test missed a pretty important use case, the fact that it will fail for any value outside of subtracting two zeroes. The initial reaction of any developer — myself included — would be to hop right in, fix the subtract method, and update our unit test.
Well, if you’re doing TDD you’re actually taking the wrong approach. You’re approach should be:
- Write a proper test case to prove that the bug exists
- Fix the feature
- Run the passing test proving that you fixed it
In terms of code changes, it would look something like this:
You would then fix the code and run the tests again:
By doing this two-step process, you not only improve your unit test suite, but you prove from a functional standpoint that you found the bug and resolved it.
Evolution of Features
As requirements and products grow, it’s also a great time to remember your TDD best practices.
Let’s say your stakeholders come to you and say that they absolutely need a divide function for your calculator application. All the customers are clamoring for one and you need to develop it ASAP.
If you’re not doing TDD, then you might jump right into coding and add the divide function. However, since we are TDD fanboys and girls, the approach will be different.
The steps we will take are:
- Add the unit test for division
- Run it and have it fail
- Program the functionality
- Run it and have it pass
Our code changes will look something like this:
Now we add the functionality of division, so our code change will look something like:
The advantage here is two-fold. The first is that you can sit with your PM and define the correct inputs and outputs, and then code against them with confidence. The second, which is one of the more underrated uses of unit tests, is that by writing the unit test first you will be able to see very early on if your code needs to be refactored.
This example is very simple and straightforward, but imagine the unit test was mocking several calls and database connections, and now you had to add an HTTP call. By starting with a unit test, you will be able to tell almost immediately that the unit is getting way too complicated and has too many dependencies. This will allow you to proactively refactor your code and build it in a scalable and testable way.
If you were to start coding first, you would only hit the mocking and dependency roadblock in your unit tests towards the end of the development cycle.
Hopefully this sheds some insight into doing TDD not just at the beginning but throughout the whole product life cycle.
Editorial reviews Deanna Chow, Liela Touré, & Prateek Sanyal.
Want to work with us? Click here to see all open positions at SSENSE!