Python Test Driven Development: Red-Green-Refactor Workflow

Python TDD, short for test driven development, means you write the test before the code. You watch the test fail, write just enough code to make it pass, then clean up. This post teaches the Red-Green-Refactor cycle by building a real password validator one tiny step at a time, so you feel the rhythm instead of just reading about it.

“Make it work, make it right, make it fast, in that order.”

Kent Beck, TDD By Example

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 16 minutes

Most of us write the code first and the tests later, if we write tests at all. Python test driven development turns that around. You write a small failing test, watch it go red, then write only the code needed to turn it green. The first time you try it, your brain protests. Testing something that does not exist yet feels silly. Then a few cycles in, something clicks. You are not really testing your code. You are designing it, one decision at a time, with the test holding you to your word.

Think about assembling flat-pack furniture. You do not start by drilling random holes. You read step one, do exactly that, and check it before moving to step two. Each instruction is a tiny promise: after this step, the shelf should stand on its own. TDD works the same way. Each test is one instruction that says what the next small piece should do, and you do not move on until that piece holds.

TDD is not about chasing 100% code coverage or writing tests just to have tests. It is a thinking tool. The test makes you decide what a function should do before you fuss over how it does it. That constraint tends to produce simpler code, cleaner interfaces, and fewer bugs, because every line you write exists for one reason: to make a test pass.

One full cycle takes 2 to 10 minutes. Red means write a failing test. Green means make it pass with the simplest code you can. Refactor means tidy up without changing behavior. Then you go again. Below, you build a password validator with strict TDD so you can watch the loop in action.

Write just enoughcode to passImprove design,remove duplicationNext feature oredge caseCycle RhythmEach cycle:2-10 minutesCommit aftereach GREENNever refactoron REDRules Per PhaseRED: Test MUST failfor the right reasonGREEN: Simplest fixNo cleverness yetREFACTOR: No newfunctionality added🔴 REDWrite a failing testRun it and watch it FAIL🟢 GREENWrite minimum codeto make it pass🔵 REFACTORClean up code and testsAll tests still passPython TDD: The Red-Green-Refactor Cycle and Rules for Each Phase

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram shows the heartbeat of Python test driven development: red (write a failing test), green (write the minimum code to make it pass), then refactor (clean up without changing behavior). You spin that loop again for each new rule or bug fix, and a solid test suite grows almost by accident. It feels slow at the start. The payoff is that bugs show up the second you create them, not days later when you have forgotten what you changed.

The Red-Green-Refactor Cycle

Every Python TDD cycle has exactly three phases, always in the same order. No shortcuts, no reordering. Skip the red step and you never find out whether your test can actually fail, which means it might be testing nothing at all.

If the loop still feels abstract, picture a tailor fitting a kurta. First she marks the exact spots where it does not fit (red). Then she stitches only those spots, nothing extra (green). Finally she presses the seams and trims loose threads without changing the fit (refactor). Mark, stitch, finish, in that order, every single time.

PhaseWhat You DoRule
REDWrite a test that describes new behaviorTest MUST fail. If it passes, your test is wrong.
GREENWrite the simplest code that makes the test passNo cleverness. No optimization. Just make it pass.
REFACTORClean up code AND tests while keeping all tests greenNo new functionality. Only restructure.

TDD Session: Building a Password Validator

Now build a password validator with strict Python TDD. Every step shows the phase, the test, the code change, and the real pytest result. These are not made-up outputs. Each one came from running pytest 9.1.1 on Python 3.14.6, so what you see is exactly what your terminal would print.

Cycle 1: Empty Password Is Invalid

🔴 RED: test_password.py

from password_validator import validate_password

def test_empty_password_is_invalid():
    result = validate_password("")
    assert result.is_valid is False
    assert "Password cannot be empty" in result.errors

▶ pytest output: collection error (the module does not exist yet)

cycle1/test_password.py:1: in <module>
    from password_validator import validate_password
E   ModuleNotFoundError: No module named 'password_validator'
=========================== short test summary info ===========================
ERROR cycle1/test_password.py
!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
============================== 1 error in 0.53s ===============================

🟢 GREEN: password_validator.py (the smallest code that passes)

from dataclasses import dataclass, field

@dataclass
class ValidationResult:
    is_valid: bool = True
    errors: list[str] = field(default_factory=list)

def validate_password(password: str) -> ValidationResult:
    errors = []
    if not password:
        errors.append("Password cannot be empty")
    return ValidationResult(is_valid=len(errors) == 0, errors=errors)

▶ pytest output: PASSED

test_password.py::test_empty_password_is_invalid PASSED                  [100%]

============================== 1 passed in 0.04s ==============================

What happened here: The first run could not even import the module, so pytest stopped before running a single test. That counts as red, and it is the right kind of red. The test failed for the reason you expected, that the code does not exist yet. Then you wrote the smallest thing that makes it pass: a tiny ValidationResult dataclass and a function that flags an empty password. Notice you did not add a length check or a digit check. There is no test asking for those yet, so writing them now would be guessing. In TDD you only build what a failing test demands.

Cycle 2: Minimum Length Check

🔴 RED: add a test for minimum length

def test_short_password_is_invalid():
    result = validate_password("abc")
    assert result.is_valid is False
    assert "Password must be at least 8 characters" in result.errors

def test_valid_length_password():
    result = validate_password("abcdefgh")
    assert result.is_valid is True

🟢 GREEN: add the length check

def validate_password(password: str) -> ValidationResult:
    errors = []
    if not password:
        errors.append("Password cannot be empty")
    elif len(password) < 8:
        errors.append("Password must be at least 8 characters")
    return ValidationResult(is_valid=len(errors) == 0, errors=errors)

▶ pytest output: all three pass

test_password.py::test_empty_password_is_invalid PASSED                  [ 33%]
test_password.py::test_short_password_is_invalid PASSED                  [ 66%]
test_password.py::test_valid_length_password PASSED                      [100%]

============================== 3 passed in 0.08s ==============================

What happened here: The new test_short_password_is_invalid went red first, because the old code accepted any non-empty password. You added one elif branch, no more, and the bar went green again. The test_valid_length_password test pins down the happy path: an eight-character password with nothing else wrong should be valid. Hold that thought. In the next cycle it becomes a small trap, and that trap is the whole lesson of this post.

Cycle 3: Require a Digit

🔴 RED: a password with no digit should fail

def test_password_without_digit_is_invalid():
    result = validate_password("abcdefgh")
    assert result.is_valid is False
    assert "Password must contain at least one digit" in result.errors

🟢 GREEN: add the digit check

def validate_password(password: str) -> ValidationResult:
    errors = []
    if not password:
        errors.append("Password cannot be empty")
    else:
        if len(password) < 8:
            errors.append("Password must be at least 8 characters")
        if not any(c.isdigit() for c in password):
            errors.append("Password must contain at least one digit")
    return ValidationResult(is_valid=len(errors) == 0, errors=errors)

▶ pytest output: the new test passes, but an old one breaks

test_password.py ..F.                                                    [100%]

================================== FAILURES ===================================
_________________________ test_valid_length_password __________________________

    def test_valid_length_password():
        result = validate_password("abcdefgh")
>       assert result.is_valid is True
E       AssertionError: assert False is True
E        +  where False = ValidationResult(is_valid=False, errors=['Password must contain at least one digit']).is_valid

test_password.py:15: AssertionError
=========================== short test summary info ===========================
FAILED test_password.py::test_valid_length_password - AssertionError: assert ...
========================= 1 failed, 3 passed in 0.19s =========================

What happened here: The digit rule did its job, so test_password_without_digit_is_invalid went green. But adding that rule quietly broke test_valid_length_password from cycle 2, because "abcdefgh" has no digit anymore and is now invalid. This is not a bug in your code. The old test simply expressed an out-of-date idea of what valid means. A red bar like this is TDD doing you a favor: it caught a contradiction the instant you introduced it, instead of letting it slip into production.

🔵 REFACTOR: replace the stale test with one that reflects the new rules

# Delete test_valid_length_password and replace it with this:
def test_valid_password():
    result = validate_password("secure99")
    assert result.is_valid is True
    assert result.errors == []

▶ pytest output: green again

test_password.py::test_empty_password_is_invalid PASSED                  [ 25%]
test_password.py::test_short_password_is_invalid PASSED                  [ 50%]
test_password.py::test_password_without_digit_is_invalid PASSED          [ 75%]
test_password.py::test_valid_password PASSED                             [100%]

============================== 4 passed in 0.07s ==============================

What happened here: You swapped the stale "abcdefgh" test for one that uses "secure99", a password that satisfies every rule so far: long enough and contains digits. The bar is green again, and the suite now tells the truth about what your validator does. One nuance: the diagram says never refactor on red, and that rule is about production code. Here the code was already correct and the stale test was the thing that was wrong, so fixing the test is exactly how you get back to green. Updating a test to match new requirements is normal and healthy. It only becomes a smell when you change a test just to silence it without understanding why it failed.

The Catch: A Green Test Can Turn Red Later

Here is the one thing that trips up almost everyone new to Python TDD. A test that passes today is not a test that is correct forever. Cycle 3 showed it live: test_valid_length_password was happily green for a whole cycle, then turned red the moment you added a rule it never knew about. People panic when this happens. They assume they broke something. Usually they did the opposite: they made the rules stricter, and an old assumption no longer fits. It is like a monthly bus pass after the city redraws the routes. The pass did not break, the rules around it moved, and now it needs updating.

The flip side of this catch is sneakier and more dangerous: a test that passes for the wrong reason. Look at this one.

❌ A test that passes even when the code is broken

def test_short_password():
    result = validate_password("abc")
    # Oops: no assert. This "passes" no matter what validate_password does.
    result.is_valid

This test will go green forever, even if validate_password returns garbage, because it never actually checks anything. That is exactly why the red step is non-negotiable. If you never watch a test fail first, you never learn whether it can fail at all. A green bar feels reassuring, but a green bar from a test that cannot fail is worse than no test, because it sells you false confidence. Always make the test go red for the reason you expect before you write the code that turns it green.

The Three Rules of TDD

Robert C. Martin (“Uncle Bob”) wrote down three rules that keep TDD honest. They sound strict, almost fussy, like wearing a seatbelt for a two-minute drive, but together they are what stop you from quietly drifting back into code-first habits.

  1. Don’t write production code unless it makes a failing test pass.
  2. Don’t write more of a test than is sufficient to fail. (A compilation error counts as failure. In Python that means an import or syntax error, like the ModuleNotFoundError in cycle 1.)
  3. Don’t write more production code than is sufficient to pass the current failing test.

When You Will Reach for TDD

Rules are easier to remember once you can picture the moments they pay off. Here are three places where TDD earns its keep in real work.

  • Rules that grow over time. The password validator is the perfect example. Today it checks length and a digit. Next sprint someone wants an uppercase letter, then a block on the 100 most common passwords. Each new rule is one red test, then one green change. The suite remembers every rule so you never reintroduce an old hole.
  • Fixing a reported bug. Say a user named Aditi reports that her discount code with trailing spaces gets rejected. Before touching the code, write a failing test that reproduces her exact complaint: feed in "SAVE10 " and assert it should be accepted. Watch it go red. Now you know the test actually catches the bug. Fix the code, watch it go green, and that bug can never silently come back.
  • Tricky logic with lots of edge cases. Parsing dates, calculating tax brackets, splitting a bill with rounding. These are the functions where you think you covered every case and then case seven bites you in production. Writing each edge case as a test first forces you to name them out loud before you code, and the named cases stay covered forever.

When NOT to Use TDD

Python TDD is a tool, not a religion. It works poorly when you do not yet know what you are building. Nobody frames a napkin sketch, and prototypes are the same: they exist to be thrown away, so testing them first is wasted polish. That makes exploratory prototyping, throwaway one-off scripts, and user interface (UI) layout tweaks awkward fits. The same goes for code wrapped tightly around things you cannot easily fake, like a live payment gateway or a flaky third-party service. Reach for TDD when correctness really matters: business logic, algorithms, data transformations, and anything you would hate to see break at 2 a.m.

Common Mistakes

❌ Mistake 1: Writing too much production code at once

# BAD: implementing the entire class before any tests.
# You are coding first, testing later. That is not TDD.

# GOOD: one test, one tiny piece of code. Repeat.

❌ Mistake 2: Tests that test implementation, not behavior

# BAD: testing internal implementation details
def test_uses_regex_for_validation():
    # Do not test HOW it validates, test WHAT it validates
    pass

# GOOD: testing behavior (input gives output)
def test_password_with_special_char_is_valid():
    result = validate_password("secure99!")
    assert result.is_valid is True

Why this matters: Tests tied to implementation break every time you refactor, even when behavior is unchanged. If your test checks that you used a regex, swapping the regex for a simple loop turns it red for no good reason, and soon you stop trusting your suite. Test what the function promises, not how it keeps the promise. The good test above just checks that "secure99!" is valid, which stays true no matter how you rewrite the insides.

Wrapping Up

You now know the full Python TDD loop: write a failing test (red), write the minimum code to pass it (green), then clean up code and tests without changing behavior (refactor). You built a real password validator one rule at a time, watched an old green test correctly turn red when the rules got stricter, and saw why a test that cannot fail is worse than no test at all. Next up in the series, you will learn how to fake the parts you cannot control, like APIs and databases, in Python Mocking & Patching with unittest.mock.

For every other Python and AI/ML topic, from beginner basics to production skills, browse the full Python + AI/ML tutorial series home.

Frequently Asked Questions

What is Test-Driven Development (TDD)?

TDD is a software development practice where you write a failing test first, then write the minimum code to make it pass, then refactor. The Red-Green-Refactor cycle repeats for every new behavior you add.

Is TDD slower than writing code first?

At first, yes, often around 15 to 30 percent slower. But TDD code has far fewer bugs, so you spend less time debugging later. Over a project’s lifetime TDD is usually faster, because you catch issues the instant you create them instead of during QA or in production.

Do I need 100% code coverage with TDD?

No. TDD naturally produces high coverage, often 90 percent or more, but 100 percent is not the goal. Test behavior, not every line. Some code such as logging and error messages does not need dedicated tests.

Can I use TDD with existing code that has no tests?

Yes, but start small. When you fix a bug, write a test that reproduces it first (red), then fix it (green). When you add features, TDD those. Over time coverage grows on its own without a big rewrite.

What tools do I need for TDD in Python?

Just pytest, which is all python tdd really asks for. Run pip install pytest and write test functions that use assert. Add pytest-cov for coverage reports and pytest-watcher for auto-rerun on file changes. This post was verified on pytest 9.1.1 with Python 3.14.6.

Try It Yourself

Build a fizzbuzz(n) function using strict Red-Green-Refactor, and resist the urge to write the whole thing at once. Go one tiny test at a time: first test_returns_1_for_1 (it should return "1"), then a multiple of 3 returns "Fizz", then a multiple of 5 returns "Buzz", then a multiple of both returns "FizzBuzz". Write the test, watch it go red, then add only the code that turns it green. Each cycle should take under 3 minutes. The reward is watching your function grow correct in slow motion, with the suite catching you every time you reach too far ahead.

Interview Questions on Python TDD

Interviewers rarely ask for definitions. They ask what happens in situations like these.

Q: Why must you watch a test fail before writing the code that makes it pass?

Because a test you have never seen fail is unverified. It might have a missing assert, test the wrong thing, or accidentally pass against broken code, and you would never know. Watching it fail for the expected reason, such as a ModuleNotFoundError or a specific AssertionError, proves the test can actually detect the behavior it claims to check. Only then does a green bar mean something.

Q: You write a brand new test for a feature you have not implemented yet, run pytest, and it passes immediately. What does that tell you and what do you do?

Something is wrong, because a test for nonexistent behavior must fail. Either the behavior already exists (check the code, maybe the feature was implemented earlier), the test is asserting nothing (a missing assert, or asserting a value against itself), or you ran the wrong test file or an old cached version. Debug the test itself first: break the code on purpose or invert the assertion and confirm the test goes red. Never accept an instant green without knowing why.

Q: You add a new validation rule and three older tests turn red. Your teammate Anvay wants to delete them so the pipeline goes green. What do you check first?

Check whether each failing test represents a still-valid requirement or a stale assumption. If the new rule legitimately supersedes an old expectation, like the "abcdefgh" test in this post that predated the digit rule, you update the test to match the agreed behavior. If the old test still describes a real requirement, your new rule has broken something and the code needs fixing, not the test. Deleting red tests without that analysis erases the safety net and can hide real regressions.

Q: How does the third rule of TDD, write no more production code than needed to pass the current test, prevent over-engineering?

It blocks speculative code. Every branch, parameter, and abstraction must be demanded by a failing test, so you cannot add a config option or a plugin system “just in case” because no test asked for it. That keeps the design driven by real requirements (the YAGNI principle) and means every line in the codebase is exercised by at least one test. When a future requirement really arrives, it arrives as a new red test, and only then does the code grow.

Q: Your team practices TDD, but the suite now takes 20 minutes, so developers stopped running tests between cycles. How do you restore the fast feedback loop?

Separate fast unit tests from slow integration tests, for example with pytest markers, so the TDD loop only runs the fast set in seconds while CI runs everything. During a cycle, narrow the run further with pytest -k or run just the current file, and use pytest -x to stop at the first failure. Also hunt for hidden slowness in unit tests, such as real network or database calls that should be mocked. TDD only works when red-to-green feedback takes seconds, so protecting suite speed is part of the practice, not an optimization afterthought.

Q: What is the practical difference between TDD and writing tests after the code (test-after)?

Test-after verifies code that already exists, so the tests tend to mirror whatever the implementation happens to do, including its bugs and awkward interfaces. TDD makes you decide the interface and expected behavior before implementation, which pushes you toward smaller functions and cleaner APIs, because hard-to-test designs hurt immediately. TDD also guarantees every test has been seen failing, while test-after tests are often green from birth and never proven able to fail. Both produce tests, but only TDD uses them as a design tool.

Further reading: for the full reference, see the official Python documentation.

Previous: Python: Unit Testing with pytest, Fixtures, Parametrize

Next: Python Mocking & Patching with unittest.mock

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *