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.
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() { } }
- 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:
-
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 isapplication/json
-
The endpoint is
-
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
-
set via
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 } }
@AutoConfigureMockMvc
will allow you to autowire aMockMvc
instance in your test, which you can use to fire requests against your application and run assertions on the responses.- After performing a request, you will get an object implementing the
ResultActions
interface, which we can apply our expectations to. - Execute a HTTP request according to how you configure it using
MockMvcRequestBuilders
. - Do a
GET
request to the/api/time
endpoint. - Using the
andExpect()
method and the SpringMockMvcResultMatchers
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))); }
- 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.