If you have been working as a developer for a while, you will almost certainly have come across legacy code. And you probably hated working on it. It is code that has been working fine the way it is for long enough that most of – if not all – the developers in your team or company are not familiar with the code anymore.

It is not uncommon to break things in unexpected ways when having to work on such code, regardless of the reason it has to be worked on.

arrow
Source: https://xkcd.com/939/

This post will show how exactly we used Test Driven Development (TDD), what kind of effects could be observed and whether or not I think it is worth it. Additionally, there will be code samples and an example project for you to play around with.

The part about the example project will follow a tutorial-like structure, with a step by step description on how to approach the requirements and satisfying them.

The Base Case

We had a legacy project that was working fine for a long time without any major changes – the Wallet. Wallet is a system that keeps track of the premium currency of all players of all our games and is a pure backend project offering JSON web APIs.

We wanted to release a new API version for Wallet. However, even just updating the dependencies to a recent version was proving to be a massive challenge. There were also many more issues like tests that depended on system state created by other tests and problems with the software architecture itself. Given that even the tech stack was very different from what other Java projects have at InnoGames, we came to the conclusion that rewriting Wallet is a sensible thing to do before we actually implement the new Wallet API version.

At that point, we figured that this is the perfect opportunity to try Test Driven Development, since the specification was there, albeit in software. Given how critical Wallet is for InnoGames, we wanted to have a very well tested code base anyway, so the common prejudice that TDD will slow down the development was not a concern for us.

General Notes

While I cannot show you Wallet code directly, I will keep the requirements and examples as close to the situations we faced as possible.

The code samples provided assume a Spring Boot 2 project including spring-boot-starter-test and JUnit5. I’ll put the commit hashes of the associated example project below the code samples where applicable.

In case you are a new developer, you can look at the specific state the application was in by checking out that particular commit hash, e.g.:

git checkout b02f1371c42e577ad8e546bd7375089237e6b3f7

Also, we were not super strict about using TDD “the right way”. We did it in a way that we felt made sense without undermining the idea behind it.

Example Project

Once you have set up the Spring Boot project in the IDE of your choice, you will have something like this:

package com.innogames.tdd_example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TddExampleApplication {

  public static void main(String[] args) {
    SpringApplication.run(TddExampleApplication.class, args);
  }
}

You will most likely also already have a test that you can execute:

package com.innogames.tdd_example;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public class TddExampleApplicationTests {

  @Test // 1
  public void contextLoads() {
  }
}
  1. This test may look like it does nothing, but it will check if the Spring Boot application will start

See commit b02f1371c42e577ad8e546bd7375089237e6b3f7

Nothing terribly exciting so far, but it will show you the most basic way to implement a functional test in Spring Boot 2 with JUnit 5, which we will need next.

Project Requirements

Let’s assume the following requirements:

  1. We have a JSON API that returns the local system time
    • The endpoint is /api/time.
    • HTTP request method is GET.
    • The HTTP response status is 200 OK in success case.
    • The HTTP Content-Type of the response is application/json
  2. We need to support an arbitrary amount of API versions for the same endpoints
    • set via X-API-VERSION HTTP header
    • responses also contain the same HTTP header

The JSON response for Version 1.0.0 should look like this:

{
  "time": "2019-10-01 14:01:21"
}

The JSON response for Version 1.1.0 should look like this:

{
  "time": "2019-10-01T14:01:21Z"
}

The First Test

If, like right now, you essentially start with nothing, you may find it difficult to write tests. My first impulse was always to write some unit tests, but I couldn’t really think of meaningful tests to write when there was no “infrastructure” around that you know you have to extend or adjust to achieve your goal. I was often locked into trying to work out the appropriate new services and sensible patterns to use.

However, if you change the perspective to that of the client that is supposed to use your API, I find it much easier to figure out a meaningful test. You can just ask yourself the question: “What is supposed to happen if I do a GET request on this endpoint?”

The answer could be: “I want to get a HTTP 200 response.”

There is your first test case. In this case it would be a functional test. This is one of the purposes of TDD. It helps you break down your requirements into as small a coding task as possible.

The following is one instance where we are not strictly following TDD anymore, as we are not supposed to write any code before our test. However, my IDE makes it very convenient to create the test file in the correct namespace inside the test package if the class we want to create tests for already exists.
Also, given that we are in a Spring project, we already know that requests will end up in a controller, so I will just create a bare controller first.

package com.innogames.tdd_example.controller;

import org.springframework.stereotype.Controller;

@Controller
public class TimeController {
}
package com.innogames.tdd_example.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc // 1
class TimeControllerTest {
  @Autowired
  private MockMvc mockMvc; // 1

  @Test
  void getTime_success() throws Exception {
    final ResultActions resultActions = // 2
        mockMvc.perform( // 3
            MockMvcRequestBuilders
                .get("/api/time") // 4
        );
    resultActions.andExpect(MockMvcResultMatchers.status().isOk()); // 5
  }
}
  1. @AutoConfigureMockMvc will allow you to autowire a MockMvc instance in your test, which you can use to fire requests against your application and run assertions on the responses.
  2. After performing a request, you will get an object implementing the ResultActions interface, which we can apply our expectations to.
  3. Execute a HTTP request according to how you configure it using MockMvcRequestBuilders.
  4. Do a GET request to the /api/time endpoint.
  5. Using the andExpect() method and the Spring MockMvcResultMatchers class, we can write easy to read tests. In this case “We expect the HTTP status to be OK“.

See commit 2f21befc6b0bf5acecf9a4601ba10c597002cbf3

That’s it. Our only concern here is that we get a HTTP 200 response code. Don’t try to think ahead and keep it simple. Trying to consider other requirements here will only increase your mental load and increase the chances of making mistakes.

From now on, I will strip out the unnecessary code parts (e.g. imports, other tests in the file) in the examples. You will of course still find the complete project state if you check out the specific commit in the example project.

You can, of course, use static imports to make the tests more concise. I refrained from doing so in this case to make it a bit easier to understand.

If you execute the test now, the test should fail with a message similar to this:

java.lang.AssertionError: Status expected:<200> but was:<404>
Expected :200
Actual   :404

So the application is returning a 404 NOT FOUND HTTP response instead of our desired 200 OK, which makes sense, given that we didn’t yet tell our controller what endpoint(s) it is responsible for. So we are just getting the generic 404 response from Spring.

Now that we have the failing test, we are allowed to write the logic to make it pass. For this, we simply create a method in the controller that takes the request.

@Controller
public class TimeController {

  @RequestMapping(
      method = RequestMethod.GET,
      path = "/api/time"
  )
  ResponseEntity getTime() {
    return new ResponseEntity(HttpStatus.OK);
  }
}

See commit b7c44a2238e9e235d8fc762c1068369fc2fea3b4

The Content Type Requirement

Since our test will now pass, we need to take the next requirement and test it: Having the Content-Type: application/json HTTP header.

At this point it is very easy to extend our test with this requirement thanks to the features Spring Boot and its included software offers:

@Test
void getTime_success() throws Exception {
  final ResultActions resultActions = mockMvc.perform(
      get("/api/time")
  );
  resultActions
      .andExpect(status().isOk())
      .andExpect(header().string("Content-Type", "application/json"));
}

See commit 378481bfcfc2c40c6c7a40054870bbe627f8d9a5

And here the code to make the test pass:

@RequestMapping(
    method = RequestMethod.GET,
    path = "/api/time"
)
ResponseEntity getTime() {
  HttpHeaders responseHeaders = new HttpHeaders();
  responseHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
  return new ResponseEntity(responseHeaders, HttpStatus.OK);
}

See commit a007650a1ea132e2484554e6fef4ed8347dbafb5

Ideally, TDD leads to very small development cycles as we could see here. We only needed to change very few lines to meet the new requirement. Of course, sometimes you’ll need bigger changes to meet certain requirements, but you’ll find that often your necessary changes will be on the smaller side.

Next, we will add the 1.0.0 JSON response expectations to our test.

@Test
void getTime_success() throws Exception {
  final LocalDateTime testStartTime = LocalDateTime.now();
  final DateTimeFormatter expectedFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

  final ResultActions resultActions = mockMvc.perform(
      get("/api/time")
  );
  resultActions
      .andExpect(status().isOk())
      .andExpect(header().string("Content-Type", "application/json"))
      .andExpect(jsonPath("$.time").exists()) // 1
      .andExpect(jsonPath("$.*", hasSize(1)));
  final MvcResult mvcResult = resultActions.andReturn();
  final String plainResponseContent = mvcResult.getResponse().getContentAsString();
  final String responseDateString = JsonPath.read(plainResponseContent, "$.time");
  assertNotNull(responseDateString);
  final LocalDateTime responseDateTime = LocalDateTime
      .parse(responseDateString, expectedFormatter);
  // Given that there is a slight delay between test execution and response
  // and that the response date is built from system time, it is good enough
  // if the response date is within 2 seconds of the start of this test
  assertTrue(responseDateTime.isAfter(testStartTime.minusSeconds(1)));
  assertTrue(responseDateTime.isBefore(testStartTime.plusSeconds(2)));
}
  1. JsonPath is a nice way to run assertions on specific parts of your JSON response. $ represents the root element. For more information, check out the json-path project on GitHub

See commit 017010ee497bc5e56672fb4e2a3d008134323cba

Brief summary: We are now checking if we get a response containing JSON with exactly one member called time. We are also checking if there is a date string with the expected format and value in there.

Now we can work some Spring magic to get our test to pass:

public class ZonelessDateTimeSerializer extends StdSerializer<LocalDateTime> {

  private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
      .ofPattern("yyyy-MM-dd HH:mm:ss");
  // ...
  @Override
  public void serialize(LocalDateTime localDateTime, JsonGenerator jsonGenerator,
      SerializerProvider serializerProvider) throws IOException {
    jsonGenerator.writeString(localDateTime.format(DATE_TIME_FORMATTER));
  }
}
public class TimeResponse {
  @JsonSerialize(using = ZonelessDateTimeSerializer.class)
  private LocalDateTime time;

  // ... Getters/Setters
}
@Controller
public class TimeController {

  @RequestMapping(
      method = RequestMethod.GET,
      path = "/api/time",
      produces = MediaType.APPLICATION_JSON_VALUE
  )
  ResponseEntity<TimeResponse> getTime() {
    HttpHeaders responseHeaders = new HttpHeaders();
    responseHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    final TimeResponse timeResponse = new TimeResponse(LocalDateTime.now());
    return new ResponseEntity<>(timeResponse, responseHeaders, HttpStatus.OK);
  }
}

See commit fd0842be0af86db369b93289db035b18cb71fed4

You might be wondering why I didn’t first create a unit test for the ZonelessDateTimeSerializer to test it in isolation. The reason is that if you mock all the dependencies of this serializer, you’d be left with testing stock Java functionality.

Also, if you were to somehow break this service, there would be no way to do so without also making our functional test fail, which nails down the exact format we expect in our response. Therefore, such a test would be of no value.

Supporting API Version 1.1.0

The two requirements that are left are being able to respond with a V1.1.0 response and being able to choose the version through the HTTP header. Since it’s much easier to deal with the header first, we’ll do just that.

At this point we could simply add the header in the request and expect it in the response for V1.0.0, copy the whole test and adjust it for V1.1.0. While this might be acceptable for very small and simple test cases that you most commonly find in unit tests, you should still avoid copy and paste for more complex and large test cases like the one we have. Consider the tests the blueprints for your software. You don’t want to make a mess here.
When you are refactoring code, you might also have to change tests sometimes, which becomes much more painful if you have a lot of copy and paste. It’s the same as with production code, really.

So we will make the test case reusable, as the requirements are very similar in both versions. Since it’s easy enough, I’ll also already add the tests we need for the new version. You don’t necessarily have to break everything down into atomic steps. Just make sure you can easily keep track of what you want to do right now. If you start losing track of what you want to do, that’s a clear sign that you are doing too much at once.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class TimeControllerTest {

  private static final String API_VERSION_HEADER = "X-API-VERSION";

  @Autowired
  private MockMvc mockMvc;

  @Test
  void getTime_successV100() throws Exception {
    getTime_success("1.0.0", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
  }

  @Test
  void getTime_successV110() throws Exception {
    getTime_success("1.1.0", DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"));
  }

  void getTime_success(final String apiVersion, final DateTimeFormatter expectedFormatter)
      throws Exception {
    final LocalDateTime testStartTime = LocalDateTime.now();

    final ResultActions resultActions = mockMvc.perform(
        get("/api/time")
            .header(API_VERSION_HEADER, apiVersion)
    );
    resultActions
        .andExpect(status().isOk())
        .andExpect(header().string("Content-Type", "application/json"))
        .andExpect(header().string(API_VERSION_HEADER, apiVersion))
        .andExpect(jsonPath("$.time").exists())
        .andExpect(jsonPath("$.*", hasSize(1)));

    final MvcResult mvcResult = resultActions.andReturn();
    final String plainResponseContent = mvcResult.getResponse().getContentAsString();
    final String responseDateString = JsonPath.read(plainResponseContent, "$.time");
    assertNotNull(responseDateString);
    final LocalDateTime responseDateTime = LocalDateTime
        .parse(responseDateString, expectedFormatter);
    // Given that there is a slight delay between test execution and response
    // and that the response date is built from system time, it is good enough
    // if the response date is within 2 seconds of the start of this test
    assertTrue(responseDateTime.isAfter(testStartTime.minusSeconds(1)));
    assertTrue(responseDateTime.isBefore(testStartTime.plusSeconds(2)));
  }
}

See commit e5ceb96c81ce829ff2b4f1a9e8c068e6fcf4f02f

As for the implementation, we will just look at the first test that is failing, which is the missing header in the response for V1.0.0 in my case, write some code for that and re-run the tests. Then we take the next failure and adjust the code for that and so on. This also helps you to pick up your work where you left it after a weekend or a good party, since you can just execute the tests and work on making the first one that fails pass.

First, receive the API version header in the getTime() method and add it to the response header.

private static final String API_VERSION_HEADER = "X-API-VERSION";
//...
ResponseEntity<TimeResponse> getTime(
    @RequestHeader(name = API_VERSION_HEADER) final String apiVersion
) {
  final HttpHeaders responseHeaders = new HttpHeaders();
  responseHeaders.add(API_VERSION_HEADER, apiVersion);
  responseHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
  final TimeResponse timeResponse = new TimeResponse(LocalDateTime.now());
  return new ResponseEntity<>(timeResponse, responseHeaders, HttpStatus.OK);
}

See commit 326d58c2abfd12a23c0fde6da2258d06bade1622

This should make your V1.0.0 test pass already. In the V1.1.0 test you should get a failure related to the date format, which makes sense as we send a V1.0.0 body at the moment even when we set version 1.1.0 in the headers (and receive the same version header back).

Now I have to make various changes to get this to work, I’ll not show some of the code here, but you can check out the commit for the details if you are interested. Essentially I made an abstract type TimeResponse and have a subtype of this for each version of the API.
The new version needs a new serializer ZuluDateTimeSerializer that gets us our desired format and finally I select which response to use in the controller based on the version header.

After the changes, the controller looks like this:

@Controller
public class TimeController {

  private static final String API_VERSION_HEADER = "X-API-VERSION";

  @RequestMapping(
      method = RequestMethod.GET,
      path = "/api/time",
      produces = MediaType.APPLICATION_JSON_VALUE
  )
  ResponseEntity<TimeResponse> getTime(
      @RequestHeader(name = API_VERSION_HEADER) final String apiVersion
  ) {
    final HttpHeaders responseHeaders = new HttpHeaders();
    responseHeaders.add(API_VERSION_HEADER, apiVersion);
    responseHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
    final TimeResponse timeResponse;
    if (apiVersion.equals("1.0.0")) {
      timeResponse = new TimeResponseV100(LocalDateTime.now());
    } else {
      timeResponse = new TimeResponseV110(LocalDateTime.now());
    }
    return new ResponseEntity<>(timeResponse, responseHeaders, HttpStatus.OK);
  }
}

See commit ae7d509b9c08296593825ca62e8b3ff77e40d061

Refactor

The controller is starting to be responsible for a lot of things. So let’s refactor a bit and put the response generation in its own service. But we need to define first what exactly our service is supposed to do through a test.

class TimeResponseBuilderTest {
  private TimeResponseBuilder timeResponseBuilder;

  @BeforeEach
  void setUp() {
    timeResponseBuilder = new TimeResponseBuilder();
  }

  @Test
  void buildGetTimeResponse_successWithApiVersion100() {
    final LocalDateTime testStartTime = LocalDateTime.now();
    final TimeResponse timeResponse = timeResponseBuilder.buildGetTimeResponse("1.0.0");
    assertNotNull(timeResponse);
    assertEquals(timeResponse.getClass(), TimeResponseV100.class);
    final TimeResponseV100 versionedTimeResponse = (TimeResponseV100) timeResponse;
    assertNotNull(versionedTimeResponse.getTime());
    assertTimeIsInExpectedRange(testStartTime, versionedTimeResponse.getTime());
  }

  @Test
  void buildGetTimeResponse_successWithApiVersion110() {
    final LocalDateTime testStartTime = LocalDateTime.now();
    final TimeResponse timeResponse = timeResponseBuilder.buildGetTimeResponse("1.1.0");
    assertNotNull(timeResponse);
    assertEquals(timeResponse.getClass(), TimeResponseV110.class);
    final TimeResponseV110 versionedTimeResponse = (TimeResponseV110) timeResponse;
    assertNotNull(versionedTimeResponse.getTime());
    assertTimeIsInExpectedRange(testStartTime, versionedTimeResponse.getTime());
  }

  @Test
  void buildGetTimeResponse_failureWithUnknownApiVersion() {
    assertThrows(InvalidApiVersionException.class,
        () -> timeResponseBuilder.buildGetTimeResponse("---invalid###")
    );
  }

  private void assertTimeIsInExpectedRange(final LocalDateTime testStartTime,
      final LocalDateTime responseTime) {
    // Given that there is a slight delay between test execution and response
    // and that the response date is built from system time, it is good enough
    // if the response date is within 2 seconds of the start of this test
    assertTrue(responseTime.isAfter(testStartTime.minusSeconds(1)));
    assertTrue(responseTime.isBefore(testStartTime.plusSeconds(1)));
  }
}

See commit f049e4398619c8dbdb36b98df8294549fa3b9fb6

You might feel like we are duplicating tests now (especially the part about checking the time range), however we are reasoning about different things here. Our controller test is checking if our web API behaves correctly towards the client, regardless of the inner workings of the code. The unit tests above, however, are testing the API of our new service.

Consider the following situation: At some point you create a different service that will also build TimeResponse objects and you replace the current implementation with this new one. Now, as time goes on, you may have changing requirements (e.g. more API versions). When implementing these changes, you will add functional tests to to your TimeControllerTest class for these versions, ensuring that your new service will give you the expected responses.

But what about the old TimeResponseBuilder? It has lost the tests that previously covered it’s functionality and therefore all bets are off when it comes to whether or not the service is behaving correctly.

The example in this case is trivial, but in a large project this could easily lead to bugs, especially if the project was not subject to Test Driven Development from the beginning and has poor coverage.

As the tests are in place, we can extract some code out of the TimeController now.

@Service
public class TimeResponseBuilder {
  public TimeResponse buildGetTimeResponse(final String apiVersion) {
    switch (apiVersion) {
      case "1.0.0":
        return new TimeResponseV100(LocalDateTime.now());
      case "1.1.0":
        return new TimeResponseV110(LocalDateTime.now());
      default:
        throw new InvalidApiVersionException("Unknown API Version");
    }
  }
}

See commit 5fba7c21ed4afa49a4e096bc6a4b0ff3a6ebc558

Repeat

With this you now know the basic process we used and repeated over and over to rewrite the Wallet until we eventually fulfilled all requirements.

If you would like to test if you got the gist of it, you could try to satisfy a new requirement in the example project: When using an unknown API version, return a HTTP 400 response with JSON body and an error message of your choice.

Positive Effects of TDD

While there are obvious benefits of TDD I could observe during the rewrite, there were also a few interesting effects I wouldn’t have expected.

Parallel Work

We worked in parallel with two people and in the beginning there were a lot of instances where, for the next ticket, we had to rely on code that was sometimes created by the other person just an hour or even less prior. Very often the previous code was also refactored. So even the code that I already knew often changed substantially.

But it still turned out that the constant changes didn’t cause any substantial delays. Quite the opposite really, because TDD more or less automatically made us write rather clean code. Moreover, even when the implementation was complex due to the nature of the problem being solved, I could always look at the tests for the code to easily see what the code is doing, even when I didn’t fully grasp how it is doing it.

This enabled me, as a Java beginner and someone who has never worked with Spring Boot before at the time, to learn what the code in front of me is doing and how it is achieving its result by reading the corresponding test code and use the debugger to go through the code step by step if the flow wasn’t obvious.

Forgetting is Impossible

One other thing is also that it was just impossible to forget writing tests because you have to do it as part of the development process, at least for the success case. You can only finish the task by also having tests. I think it’s pretty common to skip writing tests because something came up and then you end up not writing the tests after all because you need to get something else done.

There is also the issue with complex or large changes to the project. It becomes easy to miss important test cases as soon as you create more than a handful of lines of code. Even more so if the task spans across multiple work days. You also have to spend extra time to go through your code again to figure out the test cases for it and make sure you don’t miss any (which, let’s be honest, you will).

Less Bugs More Easily Fixed

In terms of bugs, there were surprisingly little of them (compared to what I am used to in non-TDD projects) and those that we had were mostly very quick to fix. Since at the very least “the happy path” was always tested, it meant very little work to construct a test case that triggered the error condition. This is especially true if you get bug reports from your API users. All you have to do is ask them for the request that behaved badly and it usually takes just a few minutes to create a test case in your functional test for this, which for me was much easier and faster than trying to manually fire requests against the API on test systems or locally.
It also becomes much faster to rule out some suspicions as to what could cause the bug since you can check if there is a test that is covering what you think could be happening or not.

Another reason for few bugs is that you have to have a clear understanding on what the requirements are. If they are not clear, you can’t write a meaningful test and are forced to talk to the stakeholder about it.

No Need to Fear Legacy Code

TDD will make sure you have a lot of test cases even if you just cover the success cases initially. Ideally you will also cover the common error cases. Any bugs you fix will also require you to create a test case for it. The more tests you have, the less likely it will be for you to break legacy code should you need to work on it, making it less painful to work with for anyone who has to.

You Cannot Lose Your Way

One of the issues that completely went away with Test Driven Development was the issue of “running out of time and forgetting where you were”. Whenever I have to leave my work half-done for whatever reason (meeting, work day is over, weekend), in non-TDD projects I often have to spend some time to figure out what I was doing and what I have to do next. With TDD, you just execute the tests and see what fails. Worst case you quickly match which requirements of your task already exist in the tests you have written. I usually left an intentional fail() at the end of the last test I have been working on to help me get into the task again if the test was not complete yet but had no failures at the moment.

Finally, TDD really helped me to keep my focus because the development cycles are small both in terms of time and scale. I feel like this caused me to be done faster with my task than I would’ve otherwise been, although I can offer no hard data for this claim. I also had high confidence that my code is working correctly, which is good for motivation.

TDD is Not a Silver Bullet

Even though TDD will prevent a lot of bugs from slipping through the cracks, there are some things it will simply not protect you from.

Race Conditions

Wallet is a high availability system that handles requests in parallel both in the same process as well as on different instances. TDD will not decrease the likelihood of you missing these cases. We had to become painfully aware of this when we first deployed the rewritten Wallet.

Misunderstood Specification

If you work on wrong assumptions, no amount of TDD is going to help you. You will simply write wrong tests and then create the right code to make those tests pass.

Concentration

Unfortunately developers are usually still human. Anything that causes a lapse in concentration, be it interruptions, noise, exhaustion or similar, will make it more likely for you to make mistakes. If that happens while you write a test in TDD, you might miss an important condition to test or just create a downright wrong test that doesn’t match what your task requires.

Bugs With Completely Unknown Cause

One occasion where you just can’t use Test Driven Development is when you observe a faulty behavior, but you have absolutely no idea what could be causing it. Writing regression tests requires you to understand the difference between what is supposed to happen and what is happening right now, so there is just no way to do that without first poking around in the production code and then create the test after you located the problem.

Why Not Just Write Good Tests Later?

Of course, if you and your team consistently wrote tests for all code you create, you would see many of the benefits of TDD, but not necessarily all of them. But in my experience, there are many reasons why this doesn’t always happen, many of which involve the desire of the developer or stakeholders to ship the next project or feature quickly.
You’d also have to be completely unbiased in writing the test code. It can easily happen that you write the test according to the code you have created, rather than the actual requirements. This would mean you are just nailing down your implementation, which can be wrong.

TDD is a means to ensure you do not forget or skip writing tests, at the very least for “the happy path” and expected error conditions. It also helps you to keep any change you make small and focused, rather than solving a big problem all at once. This further reduces the likelihood of making mistakes and often actually speeds up development.

The larger your change is, the more effort (and thus, time) you have to spend to go through all the code you created again and write meaningful tests for it.

Finally, you can’t run into the situation of having untestable code because of your architecture, since you create the test first and the code to make it pass afterwards. So you will never have to do a refactoring after you are done with the implementation just to get testable code.

Conclusion

For me personally, rewriting the Wallet using a Test Driven approach was a very good experience and I have been using this approach in other existing projects for my tasks whenever possible. Because of the positive effects I outlined above, I highly recommend anyone to try it, even if it takes a bit of discipline to not think about the solution before or while writing the test cases or if it feels a bit mundane to create tests for very simple services sometimes.