One of the challenges with large, complicated codebases is ensuring the quality is consistent throughout. Testing—including unit tests, integration tests, and a manual test team—helps ensure you’re not shipping bug-filled software. The question is when should you write the tests.
Many developer teams opt to run them at the end of the software development lifecycle (SDLC) before the code goes live. But when tests are the last task to be completed and time runs short, they are quickly dropped.
If you’re not careful, you’ll keep cutting the testing stage out of your workflow, and eventually, you’ll ship software with a feature-breaking bug.
Test-driven development (TDD) can help solve these issues. This article will discuss the benefits and limitations of TDD and demonstrate how to use it to solve an example problem.
What Is Test-Driven Development?
TDD is a software development practice in which test cases are produced at the beginning of the development process before any code has been written. These tests ensure that the acceptance criteria are met and the code is bug-free.
This is the usual flow:
- Write a test that will fail (because there’s no code written yet)
- Write the minimum amount of code to make the test pass
- Refactor the code to make it “correct” (comments, refactor any messy logic, etc.) and ensure all the tests still pass
TDD is sometimes known as red-green-refactoring. In the above diagram, the text is highlighted accordingly. Red means the test should be failing at this stage, and green means it should be passing.
By writing the tests upfront, you are using them as a guide to how you develop your software. The process is repeated until you have put all your completed unit features and quality processes for the code through an extensive suite of tests that will verify them as ready for release.
The following problem will demonstrate the process.
Example Problem
Say you have to write a function that will calculate the price of a meal. The items in the meal are passed in as an array, and you need to use TDD throughout. The price of chips is $1 and pizzas are $4.
You’re going to use Node.js along with a TDD library called should
to assert if something is wrong.
Write your first test:
var should = require('should');
should(purchase([chips])).be.equal(CHIPS_COST * 1);
You don’t have a purchase
function yet, but this is only step 1. You have your first failing test.
Now try to get this test to pass:
var should = require('should');
const CHIPS_COST = 1;
function purchase(items){
return 1;
}
let chips = {"selection": "Chips"};
should(purchase([chips])).be.equal(CHIPS_COST * 1);
Now your test passes and step 2 is complete.
Look at the code and review what needs to change. You need to:
- stop returning a hard-coded response
- use the `CHIPS_COST` variable
Perfect, now refactor:
var should = require('should');
const CHIPS_COST = 1;
function purchase(items){
let cost = items.length * CHIPS_COST;
return cost;
}
let chips = {"selection": "Chips"};
should(purchase([chips])).be.equal(CHIPS_COST * 1);
Feature one is complete. The purchase function can accept an array of chips and return the combined price.
For these test cases, you only need to check the returned value. For more complex tests, you might need to do some work before the tests run (like mock certain API calls).
If that’s required, the industry standard is to follow AAA:
- Arrange: organize everything your test needs to run (parse any function arguments, initialize variables you depend upon or load necessary modules)
- Act: execute the code that is being tested (call the function to ensure that it’s returning the correct chips price)
- Assert: ensure the expected output matches the actual, and fail the test if it doesn’t match
If you would like the challenge of continuing this, the code can be found here. Try to:
- implement adding pizzas for $4
- implement a discount where every third pizza is free
- implement a discount where one meal deal of a pizza and chips has a twenty percent discount. But this should be secondary to the third free pizza offer
- ensure that rotten pizzas aren’t added to the total during a purchase attempt
All of these examples should have tests written first and correct calculations made for all different permutations of chips, pizzas, and rotten pizzas (with their respective discounts).
Benefits of TDD
The main benefit of using a well-thought-out testing approach is that the tests can’t be removed from the process, which forces you to consider how to write your code to pass the tests.
For instance, I have seen software projects that were completely de-scoped at the end due to how difficult and fragile the tests would be. They were considered unachievable because of how the code was written. If the tests had been considered at the start, that wouldn’t have happened.
But there are other benefits to TDD. Your codebase becomes much more maintainable and flexible, thanks to the immediate feedback from the test suite if something has been broken. This provides assurance that your code is behaving as expected, similar to a compiler catching type errors.
The tests are often included in the build pipeline to ensure each new merge request won’t break anything in production, so developers don’t forget to run them.
Code written utilizing TDD often is simpler; you only need to write enough to get the tests to pass. Sometimes developers add something they know they will need in the next sprint to the codebase, but TDD actively discourages this.
TDD also tends to result in more modular code, since each feature is split down cleanly into multiple micro-features and exposes a simple interface (which gets added to as the code complexity increases).
Limitations of TDD
No software practice is ever perfect, and that includes TDD.
For example, TDD requires the test suite to be kept up to date. In a huge codebase, maintaining the tests could be a full-time job. If there are thousands of tests, developers might feel frustrated that they are fixing tests providing no value from features that were deleted. This can slow down your team’s workflow, especially since your build pipelines can also take much longer.
Developing code using TDD can feel tedious. Writing tests for something you have yet to implement feels redundant, and if you need to iterate on something quickly (like a hotfix on production), you need a sensible middle ground where tests can be omitted.
Some software features are difficult to unit test or have so many dependencies they’re too complex to be useful. If you need to mock five different dependent network calls to your API and set up a complex state in a unit test, has this been carefully considered in the testing approach? The test may seem fragile and might give a false positive error result when the real issue is that the setup is wrong.
The final limitation is that you can only unit test what you can imagine. If the developer doesn’t consider the specific use case that could cause a bug (the network request timing out, for instance), all the TDD in the world will not prevent that bug. You need to think creatively and consider all the ways in which your software could go wrong.
Conclusion
Using TDD may add complexity to your workflow, but it also ensures right from the start that your code is stronger and cleaner, improving your development process as well as your final product. This makes it well worth the effort.
For a faster, smoother way of managing your tests, try Codecov. This tool allows you to see which code is being executed by your tests, so you can monitor the results. It easily integrates into your workflow and can change and isolate test coverage based on results. To see more, schedule a demo.