Test-driven development (TDD) and how to use it

Callum Chapman
Credera Engineering
8 min readJul 24, 2023
A man programming on a laptop

As software engineers, we spend as much time on average debugging and refactoring our code as we do writing and designing it.

In contrast, engineering teams that use test-driven development (TDD) have been found to produce higher quality software in smaller timeframes - with developers spending more time writing new code and less time debugging.

With this being the case, does it not make sense to focus our efforts on the testability and maintainability of our code from the very start of a project?

This article explores how major organisations have leveraged TDD to produce superior code and provides guidance on implementing the methodology in your own projects.

Effectiveness at IBM and Microsoft

In 2008, a study with teams of engineers at IBM and Microsoft was conducted into the effectiveness of practising TDD. The teams found an average drop of 40% and 60–90% in the average number of defects per 1000 lines of code in their projects respectively (Source).

As software projects become larger and more complex — even with high quality architectural designs — there’s always the possibility that a change we make will break the code in another location.

This causes issues in projects with many 1000s of lines of code, where it can be incredibly difficult to tell if one change we have made has had unintentional consequences on another part of the codebase.

However, all is not lost. Unit testing is the solution to this uncontrollable complexity!

If we can take the time to define our requirements thoroughly and write high quality tests, it can help us to spot these issues before they make it into production.

Why test-first instead of test-after development?

Here are some of the main reasons to test-first instead of test-after development:

Improved code quality

Writing tests first forces developers to think about the functionality of their code before they start writing it. This leads to more focused and better structured code than if you are trying to test afterwards, which can result in messy refactoring when we’re trying to make our code more testable or fix bugs that could have been fixed earlier in the development cycle.

Fixing bugs is easier

Generally, it’s much easier to spot and fix bugs whilst in development rather than afterwards - especially in tightly coupled codebases. TDD doesn’t guarantee that all bugs will be spotted before development has finished, but bugs that are caught early are usually quicker and easier to fix than those found later in the development cycle.

Greater confidence in our code

Using TDD correctly ensures that the key functionality of our code is in place and that it is working as expected once a piece of the programme has been implemented. This helps provide greater confidence that our code works and allows us to automatically check that we haven’t written any bugs into the codebase when refactoring.

TDD can also provide us with greater confidence in our tests. When you write a test after developing the functionality, you’ll likely never see it fail. The issue with this is that you’re less confident that the test is actually testing the functionality that we want it to. Test-driven development allows us to be more confident that our tests work, since we see it fail before writing the functionality to make it pass.

How can you start using TDD?

One of the biggest challenges with test-driven development is the change of mindset and approach that is required when adopting a test-first method of programming. Here is a summary of some of the most important aspects to consider and some advice to help you get started in using TDD:

The five steps of TDD:

Red-green-refactor diagram
The Red-Green-Refactor TDD method
  1. Take the time to understand the functionality you are going to implement. The key here is to clearly understand the requirements of the function you’re going to create and the different inputs that it needs to be able to deal with.
  2. Write a test for a scenario that could occur in the programme. It should contain the following sections:
  • Arrange: The “arrange” section of a unit test involves any setup required to run the part of the code that we are testing. This can include initialising values, declaring variables etc.
  • Act: The “act” section is where we run the code that we’re testing with the inputs we’ve defined in the “arrange” section.
  • Assert: The “assert” section confirms that the behaviour or output from the “act” section is what we are expecting to see. If it is, the test will pass - otherwise, it will fail.

3. Write the minimum amount of code required to pass the test you’ve created.

4. Run the tests, refactor the code, and run the tests again. If a test fails at any point, go back to step 2!

5. Repeat the first four steps for each new requirement and any potential inputs for each new feature until you’re happy that you have tested all of the different uses that your function could see.

Below is an example unit test written in C# that has been obtained from the Microsoft documentation:

[TestMethod]
public void Withdraw_ValidAmount_ChangesBalance()
{
// arrange
double currentBalance = 10.0;
double withdrawal = 1.0;
double expected = 9.0;
var account = new CheckingAccount("JohnDoe", currentBalance);

// act
account.Withdraw(withdrawal);

// assert
Assert.AreEqual(expected, account.Balance);
}

The test above is designed to check that a bank balance withdrawal method is working as expected. We can see that it’s using the “arrange-act-assert” sections described above.

First, we “arrange” the function, which involves setting up any pre-defined values that are required to test the function. In this case, it’s the balance, the amount being withdrawn, and the expected balance after the function has run. Here, we can initialise a checkingAccount object with these values.

Next, we “act”, which involves running the function with the variables we’ve assigned in the “arrange” section. In this case, it’s withdrawing money from the account we’ve created.

Finally, we “assert” that the function has worked as expected. This simply means that we check that after the withdrawal, the account balance is what we expect it to be. If it is not, then the test will fail.

At first, this may seem unnecessary and time-consuming. However, as projects grow larger, it can become difficult to keep track of the side-effects of your changes and how other parts of the software are supposed to work.

Therefore, test-driven development can be viewed as an investment in the correctness of the programme you are going to build.

Mocking methods and why it’s useful

Sometimes our code interacts with APIs or functions and causes unwanted side effects that we don’t want when testing (such as writing to a database or creating new directories locally).

In test-driven development (TDD), mocking methods is a technique used to simulate the behaviour of a part of the code that the system under test depends on.

When writing tests, it’s important to isolate the code being tested from other parts of the system, such as databases, network services, or other modules. This is because these dependencies can introduce variability and make tests difficult to write and maintain.

Below is an example of a very common use case of mocking. When making an API call, we want to be able to “mock” the response from the server. This is a simple example written in JavaScript obtained from DEV (source).

// Arrange
const getFirstAlbumTitle = require("./index");
const axios = require("axios");

jest.mock("axios");

it("returns the title of the first album", async () => {
axios.get.mockResolvedValue({
data: [
{
userId: 1,
id: 1,
title: "My First Album",
},
{
userId: 1,
id: 2,
title: "Album: The Sequel",
},
],
});

// Act
const title = await getFirstAlbumTitle();

// Assert
expect(title).toEqual("My First Album");
});

The test is using the Jest testing library and mocking the axios library so that it will always return a specific JSON response, containing an array of album objects. This means that we can call a function we’ve created, and when it makes the API call using axios, only the mocked data is returned and no request is ever actually made to the server. The mocked method will only take effect in this test, so we can customise the responses depending upon the functionality we are trying to test.

This may seem like quite an obvious example since we know what data we’re going to get after we mocked the response. However, this can be highly effective when we’re testing how our code works in response to the API call (for example, rendering that information on the screen or calling a function that uses the API response).

Designing testable architecture

The theory behind test-driven development makes a lot of sense, and the results it can yield have been highlighted throughout this article. In reality, however, the ease with which we can test our programmes is highly dependent on how testable our architectural design is.

For example, when deciding which packages and APIs to use, we should consider how we’re going to deal with these dependencies when testing our application. It’s also important when making more fundamental architectural decisions, such as deciding to create a monolithic application or to use microservices, for example.

Generally, these aren’t blockers to using TDD because there are ways to mock this functionality and inputs, as outlined above. They can, however, make the process much more complicated and time-consuming. Therefore, it’s important to be aware of the impact that these decisions can have on the overall time taken to develop your application.

Other challenges associated with TDD

The whole team must adhere to TDD:

One of the most difficult aspects of test-driven development is that it doesn’t work as well if the whole team aren’t fully invested. This is arguably one of the primary reasons that it isn’t more widely adopted.

Making the most of TDD requires a team that is trained to use it efficiently and effectively, as well as a culture where each member of the team feels responsible for the testability of their code and the overall quality of the software in general.

Writing tests is time-consuming:

Writing tests can feel unproductive, especially at the start of a project or when deadlines are tight. As a result, it can be tempting to ignore writing tests and focus instead on the code, even though it generally leads to more bugs in the long-run.

Why you should start using TDD

Using test-driven development methods can massively improve the quality of your code and reduce the time taken to deliver software. You’ll also likely spend more time creating new features, and less time debugging!

As it is a new way of working, it’s a method that can take some learning and time to get used to, but the benefits are well worth it in the long-run. Using TDD can transform the way you write code and architect applications; resulting in the rapid development of high quality, robust code.

Got a question?

Please get in touch to speak to a member of our team at Credera.

--

--