For years I treated tests as something you added when someone on the team nagged you, or when a PR reviewer left the comment. I wanted to write the feature and ship it. Testing meant stopping the fun part to do paperwork.
My actual workflow was worse: relaunch the app, click through the flow, squint at the output, tweak a line, repeat. Five times for a one-line change. Sometimes ten. I called it “manual testing.” It was slow debugging with extra steps and no record of what I’d already checked.
Then I tried test-driven development on a greenfield project. The first hour felt ridiculous. By the end of the week I was annoyed at every codebase that didn’t have a test harness ready. This is what changed.
The lie I told myself
The objections sound familiar because everyone has them. Tests slow you down. You’ll test manually, because you’re careful. TDD is for pedants and conference speakers. You don’t know how to write good tests yet, so why start badly.
I believed all of it. What I was really optimizing for was immediate gratification, seeing the UI move, instead of confidence that the system behaves. Those are different products. I was shipping the first and borrowing trouble on the second.
- Change code → restart app → click through → forget edge case
- Bugs found late — often by someone else
- Fear of refactoring; "if it works don't touch it"
- Debugging sessions that eat afternoons
- Write failing test → minimal code to pass → refactor safely
- Bugs caught in seconds, at the keyboard
- Refactors become cheap — tests are the safety net
- Debugging is rare; most issues surface as red tests
How many times did you relaunch the app to “test” a one-line change? That number is a metric, and mine was embarrassing.
What TDD actually is
Strip away the ideology and TDD is a tight feedback loop with three moves.
Red. Write a test that describes the behavior you want. Run it. Watch it fail for the right reason, not because the test itself is broken. Green. Write the smallest amount of production code that makes it pass. Resist the urge to build the cathedral. Refactor. Clean up with the tests running: rename, extract, simplify, knowing you’ll find out immediately if you broke something. Then the next test, and around again.
The first few cycles feel awkward. You’re writing tests for code that doesn’t exist, and your tests are too big or too coupled to implementation details. That’s normal. The skill behaves like any other: bad at first, then muscle memory.
Virtue had nothing to do with what hooked me. It was speed of certainty. A passing test is a binary signal. No clicking, no log-diving, no “did I forget to rebuild?” Just green or red.
What you actually gain
This isn’t about coverage percentages on a dashboard. It’s about how it feels to work.
Writing the test first applies design pressure in the right direction, because you have to decide what the code should do before you decide how. Untestable designs show up immediately, and that’s usually a hint the API is wrong.
Bugs die young. A bug caught while the context is still in your head costs minutes. The same bug found in staging costs hours. In production at a bank it costs an incident channel and a postmortem.
Refactoring stops being scary. The biggest hidden tax in legacy code is fear of change. Tests don’t eliminate that risk, they cap it. You can rename that gnarly module because 40 tests will tell you what you broke.
And you ship faster, which is counterintuitive until you’ve lived it. Less time restarting apps, less time debugging mysteries, more time in a flow where every change has a defined done state.
I said it in the original piece and I’ll say it again without the meme framing: I hardly debug my code anymore. Not because I stopped making mistakes, but because mistakes surface as failing tests while I’m still in the same file, instead of as a Slack message from QA on Friday afternoon.
I don’t write tests first for every spike or throwaway script. TDD shines when behavior is non-obvious, regressions are expensive, or the code will outlive the afternoon. Match the discipline to the stakes.
How to start without hating it
You don’t need to convert the whole repo. You need one win.
Not the authentication service. Something with clear inputs and outputs — a parser, a fee calculator, a validator.
One assertion. Run it. Watch it fail. That's success — you specified behavior before implementation.
Resist feature creep. The test is the scope boundary.
Null input, empty string, boundary value. This is where TDD pays back fastest.
Extract a helper, rename a variable, kill duplication. Feel the safety net.
If your environment makes testing painful, whether that’s no harness, slow CI, or a flaky integration suite, fix the harness first. TDD on a broken test setup is misery. A fast unit test run is infrastructure.
What this isn’t
TDD won’t save a bad architecture. It won’t replace code review. It won’t make you enjoy writing tests on day one; I didn’t.
It will change what “done” means. Done isn’t “it worked when I clicked through once.” Done is “the behavior is specified, the tests pass, and I can change this tomorrow without fear.”
Three things I’d tell past-me. The relaunch tax is real, and every manual verification path you don’t automate is debt you pay interest on every sprint. Tests are a design tool before they’re a safety net, so when something is hard to test, listen to what the code is telling you. And start embarrassingly small, because conversion doesn’t happen from a manifesto, it happens from one function, one test, one week.
The point
I didn’t learn to love writing tests because someone lectured me about quality. I learned because TDD made me faster and calmer, and because I got tired of being the person who broke things in environments where breaking things has a cost.
If you’re still relaunching the app five times per change, you’re not behind. You’re one good afternoon away from wondering why you waited.
Questions
What is the TDD red-green-refactor loop?
Three moves. Write a failing test that describes the behavior you want and watch it fail for the right reason. Write the smallest amount of production code that makes it pass. Then clean up with the tests running, so you find out immediately if you broke something. Then repeat with the next test.
Does test-driven development actually make you faster?
Yes, though it’s counterintuitive until you’ve lived it. The time goes back into your day as fewer app restarts, fewer debugging mysteries, and a flow where every change has a defined done state. A bug caught while the context is still in your head costs minutes; the same bug found in staging costs hours.
When should you not use TDD?
On spikes and throwaway scripts. TDD earns its keep when behavior is non-obvious, regressions are expensive, or the code will outlive the afternoon. Match the discipline to the stakes.
How do you start with TDD on an existing codebase?
Start with one small pure function, not the authentication service. Write one happy-path test with a single assertion and watch it fail, write the minimum code to pass, add one edge case, then refactor once with tests green. If the harness itself is painful or slow, fix that first.
This piece first appeared as How I Learned to Love Writing Tests (and You Can Too!) (February 2023). Rewritten for this site — less cheerleading, same conversion story.