Introduction
Test-driven development is an effective method for ensuring well-tested and refactorable code. The basic idea is that you start development by writing tests. These tests clearly document expectations and create a rubric for a successful implementation. When done properly, you can clearly define the expected input/output of a function before writing any code. This has a few immediate benefits:
- You carefully consider the interface for interacting with your code and design it to be testable
- When you begin writing code, your flow isn’t interrupted by manual testing or stepping through execution logic to predict the outcome. Instead, you just run the tests
- Making a test pass becomes a goal that is satisfying to achieve. Breaking down the process into a series of well-defined and achieveable milestones makes the work more enjoyable
- Avoid post-implementation laziness and over-confidence that could prevent you from testing your code
Now that you’re convinced of the benefits, you can get started with test-driven development (TDD) by following these steps:
- Write or modify tests
- Check if test fails
- Write the minimum amount of code to make tests pass
These steps are followed in a cycle so you are always adding more tests to challenge the current implementation.
The last step, which specifies writing the minimum amount of code, is where things can get tedious if followed rigidly. It’s important to understand why this rule exists before you can determine when it’s appropriate to stray from it.
Simple Example
You’re tasked with implementing the function Add(x, y int) int
. Before you jump to the implementation and just return x + y
, write the simplest test: 1 + 1 == 2
. Then, what is the simplest implementation that would pass the test? It’s just return 2
. Now your tests pass!
At this point, you realize that you need more tests, so you pick up the pace and add a few more:
1 + 2 == 3
100 + 5 == 105
Now your tests fail, so you need to fix the implementation. You can’t just return 3
or return 105
this time, so you need to find a solution that works for all tests. This leads to the implementation: return x + y
.
While this feels overly tedious in the trivial example, strict adherence to this method caused you to write multiple tests instead of just trusting your implementation. Of course, your initial idea to return x + y
would have worked, but the point is to re-train yourself to rely on tests rather than your own understanding of the code. In the real world, you’re not the only one working on this piece of code and will inevitably forget implementation details. This process forces you to write more tests and think of more ways to break the simple implementation.
Eventually, you’ll gain experience and learn to find the balance that works in the different scenarios that you encounter. You’ll get back to full-speed implementation of features and find that you have fewer bugs and write more maintanable code.
Step by step TDD for an HTTP API
Let’s get into a more complicated example using TDD for an HTTP REST API. This step-by-step guide uses my Go framework, babyapi
, but the concepts can be applied anywhere.
babyapi
uses generics to create a full CRUD API around Go structs, making it super easy to create a full REST API and client CLI. In addition to this, the babytest
package provides some tools for creating end-to-end API tables tests. Using TDD at the API-level allows for fully testing the HTTP and storage layers of a new API or feature all at once.
Disclaimer: Since babyapi
handles most of the implementation and also is used to generate test boilerplate, we aren’t technically starting with TDD. However, we’ll see how beneficial it is when adding support for PATCH
requests to our API.
Create a new Go project
Create initial
main.go
usingbabyapi
‘s simple exampleUse the CLI to generate a test boilerplate
Implement each test by filling in the placeholders with expected JSON
Run the tests and see that they pass!
Since
PUT
is idempotent, it requires all fields to be included. To avoid this, we want to add support for togglingCompleted
withPATCH
requests. We start by adding a simple test for what we expect this feature to look likeThis test fails since
babyapi
doesn’t supportPATCH
by default. We can fix it by implementingPatch
for theTODO
struct. Since we defined our feature with two tests, our simplest implementation isn’t just settingCompleted = true
and we have to use the value from the requestNow we can change the
Completed
status of aTODO
, but we still cannot usePATCH
to modify other fields as show by this new set of testsUpdate
Patch
to set the remaining fieldsOur tests still fail since we always update the
TODO
with the request fields, even if they’re empty. Fix this by updating the implementation to check for empty valuesThe new
UpdateWithPatch
test passes, but our previous tests fail. Since we changedCompleted
to be*bool
,TODO
s created with an empty value will show asnull
Implement
Render
forTODO
so we can treatnil
asfalse
Implementing the PATCH
feature with test-driven development resulted in a robust set of tests and a well-implemented feature. Since we started by defining the expected input and output of a PATCH
request in tests, it was easy to see the issues caused by not checking for empty values in the request. Also, our pre-existing tests were able to protect from breaking changes when changing the type of Completed
to *bool
.
Conclusion
Test-driven development is an effective approach for creating fully tested and correct code. By starting with tests in mind, we can ensure that every piece of code is designed to be testable instead of letting tests be an afterthought.
If you’re hesitant about adopting TDD, here are a few ideas to get started:
- Try it in simple scenarios where a function’s input/output is clear and the implementation is not overly complicated. You can write a robust table test for the variety of input/output that could be encountered. Having a clear visual of the different scenarios can simplify implementation
- If you’re fixing a new bug, you have already identified a gap in your testing. Start by writing a test that would have identified this bug in the first place. Then, make this test pass without breaking any existing tests.
- Similar to the
babyapi
example, you can use TDD for high-level API tests. Once you have a definition of the expected request/response, you can resume your usual development flow for more detail-oriented parts of the implementation
Even if TDD isn’t a good fit for the way you write code, it’s still a powerful tool to have in your belt. I encourage you to at least commit some time to trying it out and see how it affects your development process.