The basic idea
Test-driven development, often shortened to TDD, is a way of working in which you write an automated test before you write the code that makes it pass. You do this in very small steps, repeating a short cycle many times. It is a working method, not a type of test: the tests that result are ordinary unit tests, and the difference lies in when they are written.
The cycle: red, green, refactor
The cycle has three steps.
- Red. Write a small test for behaviour that does not exist yet. Run it and watch it fail. The failure matters: it shows the test can detect the absence of the behaviour, and that it is actually being run.
- Green. Write the simplest code that makes the test pass. At this point, simple is better than elegant. Even a crude solution is fine.
- Refactor. With the tests passing, clean up: remove duplication, improve names, simplify structure. Run the tests again to confirm nothing changed in behaviour.
Then pick the next small behaviour and start again.
A tiny worked example
Suppose you need a function that converts a number of minutes to a text label such as “2 h 5 min”. This is an illustration with simple numbers, not a recommended design.
First test (red).
def test_minutes_only():
assert format_duration(45) == "45 min"
There is no format_duration yet, so the test fails.
Make it pass (green).
def format_duration(minutes):
return str(minutes) + " min"
That is the simplest code that passes. It is clearly incomplete, and that is intended.
Next test (red). Add a case with hours: 125 minutes is 2 hours and 5 minutes, because 2 times 60 is 120, and 125 minus 120 is 5.
def test_hours_and_minutes():
assert format_duration(125) == "2 h 5 min"
This fails with the current code, which returns “125 min”.
Make it pass (green).
def format_duration(minutes):
hours = minutes // 60
rest = minutes % 60
if hours == 0:
return str(rest) + " min"
return str(hours) + " h " + str(rest) + " min"
Check the earlier case: 45 // 60 is 0, and 45 % 60 is 45, so the result is “45 min” as before. For 125, 125 // 60 is 2 and 125 % 60 is 5, so the result is “2 h 5 min”. Both tests pass.
Refactor and continue. Now you might ask what 120 should give. As written it produces “2 h 0 min”. Whether that is acceptable is a design decision, and the next test is the place to settle it, rather than leaving it to chance.
Notice what happened: each test forced one decision, and the function grew only as far as the tests required.
Benefits people claim
Supporters of TDD point to several benefits. These are claimed advantages that many practitioners report, but evidence on how large or reliable they are varies, and results seem to depend a lot on the team and the work.
- Focus. Each step is small, so you always know what you are trying to do.
- Design pressure. Code that is awkward to test is often awkward to use. Writing the test first can expose a clumsy interface before it is built.
- A safety net. The suite grows alongside the code, so refactoring is less frightening.
- Fewer untested features. Because nothing is written without a failing test, there is little code that no test touches. This helps coverage numbers, though coverage alone does not guarantee good tests.
- Clear definition of progress. A failing test is a visible to-do item.
Where it fits poorly
TDD is not suited to every task, and it is reasonable to use it selectively.
- Exploratory work. When you do not yet know what you are building, such as a prototype or a quick experiment, writing tests first for an interface that will change in an hour adds friction.
- Code with heavy external behaviour. User interfaces, hardware control and graphics can be hard to describe as small, automated, repeatable checks.
- Poorly understood requirements. A test needs an expected answer. If nobody knows the right behaviour yet, the first job is to clarify the need, perhaps through requirements work, not to write assertions.
- Large design questions. Small tests help local design. They do not by themselves tell you how to structure a whole system.
- Legacy code with no tests. Adding tests first is still useful, but the code may need some restructuring before a test can reach it.
- Time-limited throwaway scripts. Where the code will be run once and discarded, the cost may outweigh the benefit.
A common mistake is to treat the method as a rule. Many experienced engineers use test-first for logic-heavy code and write tests afterward, or lighter checks, elsewhere.
Common pitfalls
- Tests that mirror the implementation. If a test knows exactly how the code works inside, any reorganisation breaks it. Test behaviour, not internal steps.
- Skipping the refactor step. Without cleaning up, the code can accumulate duplication.
- Large steps. If you write ten tests and then the code, you have lost the tight feedback loop.
- Assuming tests are enough. Passing tests show that the cases you thought of work. They do not cover the cases you did not think of; the broader ideas in software testing still apply, including integration and system checks.
Getting started
Try it on a small, self-contained function with clear rules, such as formatting, parsing or a calculation. Do three or four cycles and notice how it feels. If it helps, continue; if it does not suit the work, keep the habit of writing tests alongside code. The value comes from having reliable, readable checks, and TDD is one way to get there, not the only one.