I like long walks on the beach and test driven development
Like a day at the beach, writing code can be just as relaxing. You find a nice open spot in the code, put down your proverbial beach blanket and prop up your umbrella, looking forward to some easy going waves, sun, and relaxation, or in this case, some API routes, UI components, and simple util functions. Everything falls into place, after all, it’s green field development. One by one you knock out the API routes and see the data populate in your UI. You’re feeling good, the sun is shining, and the waves are rolling in. You’re in the zone, and you’re feeling good about the code you’re writing.
My AI editor keeps prompting me to take a turn here while writing, and unveil some hidden unfortunate reality of the beach. What a debbie downer. But no, this beach day of coding is brought to you by test driven development. Beach day takes some planning, but man does it pay off. Is this not the experience you get while developing? No? Well then pull up a beach chair and let’s build this sandcastle.
Enough with the allegories, give it to me straight
Test Driven Development (TDD) is a software development process that has been around for a while. In a nutshell, the idea is to write tests for your code before you write the code itself. This is a bit of a chicken and egg problem, but it’s a good practice to get into. If you write the tests beforehand, it forces you to think about what you want your code to do, it forces you to limit the scope of your change, and it gives you and excellent template to work from.
That’s really all there is to it. You’ll find more patterns and opinions elsewhere online, but the core of TDD is just to plan ahead a little. I think most junior developers, and even some more senior want to jump straight into coding a solution. During greenfield work, it’s easy to get away with this, since there are few existing requirements to work around. But as I’m sure you’ve experienced, once the code is built up some, it becomes harder to change. This is where TDD shines.
How to do TDD?
Let me give you some scenarios to illustrate this, and then you can extrapolate how else it can be used in your work. But remember, the basic concept is write a test first, however that suits your work best, and then write code to get that test to pass.
Scenario 1: High Priority Bug
Your users are complaining that they can’t save their work in some cases. You’ve tracked it down to a state management problem in the frontend. Whenever they do action X and then action Y, action X gets reset. For example, maybe they save a text field, and then edit a dropdown specifying a label for that text field.
Now that you know what the problem is, write a test that replicates the problem. Don’t code anything yet! To be more specific, say this is in react. In that case, write some jest test, something like this:
test('state is not reset when saving a text field and then editing a dropdown', () => {
// setup your test environment
const { getByText, getByLabelText } = render(<YourComponent />);
// do the actions that cause the bug
fireEvent.change(getByLabelText('Your Text Field'), { target: { value: 'new value' } });
fireEvent.change(getByLabelText('Your Dropdown'), { target: { value: 'new label' } });
// assert that the state is not reset
expect(getByText('new value')).toBeInTheDocument();
expect(getByLabelText('Your Dropdown')).toHaveValue('new label');
});
Since you haven’t written any code, the test can prove your assumption of what the problem is. Run that test, and watch it fail on the first expect statement. Bingo!
Now you can write code to fix the problem. This might be a little easier said than done, but now you can use this test automatically to verify when you get it right. No more flipping back to localhost:300/my-specific-route/:someRandomIdToReplicate
and clicking around to set up the data to replicate the bug. You can even set jest to watch for code changes and run the tests every time a change is made: jest --watch
.
After doing all this, you now have the added bonus of more test coverage. Next time someone touches that code, this test will make sure they don’t break it again.
Scenario 2: New Feature
Suppose you’re building a new feature that allows users to upload a profile picture, which is saved to a DB and also to object storage. A “fun” way to do this is to write a test for each line of code you write. So if your psuedocode looks like this:
1. User uploads a file
2. File is saved to DB
3. File is saved to object storage
Then the first thing you would do would be to write a test to cover step 1. This might feel like overkill, and perhaps it is in some cases, but let’s try it out. If it was an express app, you might write a test like this with supertest to test your route:
import request from 'supertest';
test('uploads a file', async () => {
const res = await request(app)
.post('/upload')
.attach('file', 'path/to/file.jpg');
expect(res.status).toBe(200);
});
all this test is doing is making sure that the route is working as expected. So if you got creative later and had a bunch of if statements, you could fall back to this test to make sure the happy path is still working. (note: supertest is a library that allows you to test your express routes in a more holistic way)
Next you can go add the route to your express app. An added bonus for writing such a simple test early on is that you won’t get tangled up in trying to mock complex code. The first mock is already written for you (supertest).
Then you would write a test for step 2, saving to the DB. This might look something like this:
const spy = jest.spyOn(FileDB, 'create');
test('saves a file to the DB', async () => {
const res = await request(app)
.post('/upload')
.attach('file', 'path/to/file.jpg');
expect(spy).toHaveBeenCalled();
});
(note: My AI pal is taking some liberty here. Imagine that FileDB
is a sequelize model, and that create
is a method on that model. By using jest.spyOn
, you can mock the create
method to return a promise that resolves to a file object. This is a bit of a tangent, but it’s a good practice to mock your database calls in your tests. This way you can test your code without actually hitting the database. This is a whole other topic, but I wanted to mention it here.)
Then you could write the code to save the file to the DB. Again, you have incrementally added another mock to this test suite, simplifying the testing requirements for your new code.
And finally, you’d write a test for step 3, saving the file to object storage. Maybe the mocking gets a little more complicated because you use S3 and a presignURL method. This might look something like this:
const spy = jest.spyOn(S3, 'upload');
const presignSpy = jest.spyOn(S3, 'getPresignURL');
test('saves a file to object storage', async () => {
const res = await request(app)
.post('/upload')
.attach('file', 'path/to/file.jpg');
expect(spy).toHaveBeenCalled();
expect(presignSpy).toHaveBeenCalled();
});
And then you’d write the code to save the file to object storage. You’ve now written a test for each line of code you’ve written. You’ve incrementally added tests to your code, and you’ve incrementally added mocks to your test suite. This makes it easier to test your code, and it makes it easier to write your code.
As a bonus, you’re at like 100% test coverage now. Time to go flex on your coworkers (My AI pal suggests this).
Scenario 3: End to End Test
Whether or not you are a QA engineer, it is very helpful to write plain English testing steps for a story or feature you are working on. If you can surface a test to the developers, QA engineers, and product owners, you can all be on the same page about what the feature is supposed to do. An excellent pattern for this is to write a test describing the feature you are about to work on in JIRA or whatever ticketing system you use.
For example, suppose your PO gave you that feature to let users upload a profile picture. You could write a test like this:
Given a user is logged in
and the user is on the profile page
When the user uploads a file
Then the file is saved to the DB
and the file is saved to object storage
and the user's profile picture is visible on the profile page
The Given
, When
, and Then
are a pattern for writing tests in plain English, and also an excellent pattern for writing Acceptance Criteria (hint, hint, Product Owners!). There’s a lot more to say about this side of testing, but I wanted to mention it briefly, as test driven development can shift left all the way to when the feature is being written.
This will also give your team the added bonus of more documentation. If you ever need to revisit this feature, you can look at the test to see what it’s supposed to do. This is especially helpful if you’re working on a legacy codebase, or if someone else jumps in later to work on your code.
Back to Beach Day
I hope these examples have sparked some ideas for how to resolve the problems you face in your work. Test Driven Development is more of an attitude than a set of regimented actions. There will be some fine tuning as you adapt this to your work and your team. Nonetheless, this pattern helps with legacy codebases at established companies, greenfield startups with no patience, and everything in between.
By doing TDD, you will see free benefits, like
- more test coverage
- simpler mocking
- better readability and documentation
- more confidence in your code
- less time spent debugging
So next time you’re at the beach, remember to bring your umbrella, and remember to start out with some good tests. You’ll be glad you did.