Quick Answer

Write a failing test, write the least code to pass it, then refactor. Writing the test first forces you to decide what the function should do before deciding how, and it guarantees the test can actually fail.

Red, green, refactor

Three steps, repeated in short loops:

  • Red — write a test for behaviour that does not exist yet. Run it. It must fail.
  • Green — write the simplest code that makes it pass. Not the elegant version; the simplest.
  • Refactor — improve the code now that the test protects you.

The red step is not a formality. A test that has never failed proves nothing — it might pass because of a typo in the assertion, or because it never runs. Watching it fail first is what verifies the test itself.

The instruction to write the simplest code is also deliberate. It keeps you from building features nothing has asked for, which is where most unnecessary complexity comes from.

A worked example

A library late fee: ten rupees a day, capped at two hundred. Tests first:

# test_fees.py
from fees import late_fee

def test_no_fee_when_not_late():
    assert late_fee(0) == 0

def test_negative_days_is_not_a_refund():
    assert late_fee(-3) == 0

def test_charges_per_day():
    assert late_fee(5) == 50

def test_fee_is_capped():
    assert late_fee(100) == 200

Running these now fails immediately — there is no fees module. That is red.

# fees.py
def late_fee(days_late, daily_rate=10, cap=200):
    if days_late <= 0:
        return 0
    return min(days_late * daily_rate, cap)
$ python -m pytest -q
....                                          [100%]
4 passed in 0.06s

Notice what writing tests first produced. The second test — that negative days are not a refund — is an edge case that would very likely have been missed writing the function first, because you are thinking about the happy path while implementing. Writing tests forces you to think about inputs rather than logic.

It changes the design, not just the coverage

This is the argument that convinces people, and it is not about catching bugs.

To write a test first, you must decide what the function is called, what it takes and what it returns — before implementing. You are consuming your own API before building it, which surfaces awkwardness immediately.

Code that is hard to test is usually hard to use. A function needing six setup objects, a live database and a network call is telling you it does too much and depends on too much. Writing the test first makes that pain arrive early, when changing the design is cheap.

This is why TDD tends to produce smaller functions with explicit dependencies — the same direction as dependency inversion. The tests are almost a side effect; the design pressure is the main benefit.

What makes a test worth having

  • Test behaviour, not implementation. Assert the returned fee, not that a private helper was called. Tests coupled to internals turn red on every refactor while catching nothing.
  • One reason to fail per test. When it goes red, the name should tell you what broke.
  • Descriptive names. test_fee_is_capped beats test_2. The name is what you read in the failure output.
  • No shared mutable state. Tests that must run in a particular order are fragile and fail confusingly in parallel.
  • Fast. A suite taking ten minutes stops being run, and an unrun test is worthless.

The most common failure mode in student projects is testing that the code does what it does — asserting internals rather than outcomes. Such tests break constantly during refactoring, which teaches people that tests are a burden.

Where TDD does not fit

Being honest about this makes the rest more credible.

Exploratory work. When you do not yet know what you are building — trying an API, prototyping a UI, exploring data — writing tests first means specifying something you have not decided. Explore, then write tests once the shape is known.

Interface-heavy code. Testing exact pixel layout or animation is expensive and brittle. Test the logic behind the UI instead.

Genuine throwaways. A script run once does not need a suite.

Where TDD is clearly worthwhile: business rules, calculations, parsers, validation, data transformation — anything with clear inputs and outputs and consequences for being wrong. The late-fee example is exactly that shape.

For student projects, testing the two or three functions containing the real logic is a reasonable target. Full coverage is not, and being able to explain why you tested what you tested is a strong interview answer. See refactoring basics for what tests enable afterwards.

Frequently Asked Questions

Why write the test before the code? Two reasons. It forces you to decide what the code should do before how, which improves the design. And it proves the test can actually fail, which a test written afterwards may never demonstrate.
Does TDD slow development down? It is slower to write and usually faster overall, because less time goes into debugging and manual re-checking. The benefit grows with how long the code will be maintained.
What is the difference between unit and integration tests? A unit test checks one piece in isolation and runs in milliseconds. An integration test checks that pieces work together, often involving a database or network, and is slower but catches wiring problems units cannot.
How much test coverage should I aim for? Coverage percentage is a weak target. Cover the logic that would be costly to get wrong, and the edge cases. High coverage of trivial getters proves very little.
Should I use TDD for a college project? For the parts with real logic — calculations, validation, business rules — yes, and it is worth being able to discuss it. Applying it to UI layout or exploratory work usually is not worth the effort.