Skip to main content
Software Testing · lesson 7 of 8
Software Testing intermediate Lesson 7 of 8

Test-Driven Development: Red, Green, Refactor with pytest

Build a slugify function test-first: real red runs, a faked green that a second test breaks, and a refactor the suite proves safe, or catches.

By SkillByExample · Published

testing tdd pytest refactoring python

Test-driven development (TDD) flips the usual order: you write a failing test, then the least code that makes it pass, then you clean up with the tests still passing. Red, green, refactor. This lesson builds one small function, slugify, that way, and every run below is real.

The task and the test list

slugify turns a blog post title into the part of a URL that names it: "TDD by Example" becomes tdd-by-example. Before writing any code, write a test list: the examples you already know the answer to.

  • "Hello World" → hello-world
  • "TDD by Example" → tdd-by-example
  • "What's new?" → whats-new
  • "a -- b" → a-b
  • " Hi! " → hi

The list isn’t a contract. You’ll pick one item at a time, and you’ll add items as the code shows you cases you hadn’t thought of. Every run uses Python 3.13 and pytest 9.1.1 (pip install pytest), with pytest -q for short output. The runs below were made in a python:3.13-slim container from a folder called /lab, so that’s the path in the tracebacks; yours will show your own folder.

1. Red: the test comes first

Start with the simplest item on the list, in a test file and nothing else.

# test_slugify.py
from slugify import slugify


def test_lowercases_and_joins_words_with_hyphens():
    assert slugify("Hello World") == "hello-world"
$ pytest -q

==================================== ERRORS ====================================
_______________________ ERROR collecting test_slugify.py _______________________
ImportError while importing test module '/lab/test_slugify.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/local/lib/python3.13/importlib/__init__.py:88: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_slugify.py:2: in <module>
    from slugify import slugify
E   ModuleNotFoundError: No module named 'slugify'
=========================== short test summary info ============================
ERROR test_slugify.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.13s

This is red, but not a useful red. The test never ran: pytest couldn’t import the module, so this failure says nothing about the behaviour. Writing the test first did make one decision, though: the function lives in slugify.py, is called slugify, and takes one string. Give it an empty body so the test can run and fail on its assertion.

# slugify.py
def slugify(title):
    return ""
$ pytest -q
F                                                                        [100%]
=================================== FAILURES ===================================
_________________ test_lowercases_and_joins_words_with_hyphens _________________

    def test_lowercases_and_joins_words_with_hyphens():
>       assert slugify("Hello World") == "hello-world"
E       AssertionError: assert '' == 'hello-world'
E         
E         - hello-world

test_slugify.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_slugify.py::test_lowercases_and_joins_words_with_hyphens - Assert...
1 failed in 0.09s

Now it’s the right red: the test ran, called the function, and failed because the answer is wrong. A test you have seen fail for the right reason is one you can trust to catch that bug later.

2. Green: the least code that passes

The least code that passes isn’t the real algorithm. It’s the answer.

# slugify.py
def slugify(title):
    return "hello-world"
$ pytest -q
.                                                                        [100%]
1 passed in 0.05s

Green. Returning a constant looks like cheating, and that’s the point. TDD calls this “fake it”: it proves the test, the import and the function are wired together, in seconds. It’s safe because the fake can’t survive the next test.

3. A second test breaks the fake

Take the next item from the list. A second example with a different answer is called triangulation: one example can be faked, two can’t.

# test_slugify.py
from slugify import slugify


def test_lowercases_and_joins_words_with_hyphens():
    assert slugify("Hello World") == "hello-world"


def test_works_for_any_title():
    assert slugify("TDD by Example") == "tdd-by-example"
$ pytest -q
.F                                                                       [100%]
=================================== FAILURES ===================================
___________________________ test_works_for_any_title ___________________________

    def test_works_for_any_title():
>       assert slugify("TDD by Example") == "tdd-by-example"
E       AssertionError: assert 'hello-world' == 'tdd-by-example'
E         
E         - tdd-by-example
E         + hello-world

test_slugify.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_slugify.py::test_works_for_any_title - AssertionError: assert 'he...
1 failed, 1 passed in 0.09s

The fake returns hello-world for every title, and the second test says so. Now write real logic, but still only enough for these two tests.

# slugify.py
def slugify(title):
    return title.lower().replace(" ", "-")
$ pytest -q
..                                                                       [100%]
2 passed in 0.05s

Both pass. The code doesn’t handle punctuation yet, and that’s fine: no test asks it to.

4. Punctuation, and a test that knows too much

The third item has an apostrophe and a question mark.

# test_slugify.py
from slugify import slugify


def test_lowercases_and_joins_words_with_hyphens():
    assert slugify("Hello World") == "hello-world"


def test_works_for_any_title():
    assert slugify("TDD by Example") == "tdd-by-example"


def test_drops_punctuation():
    assert slugify("What's new?") == "whats-new"
$ pytest -q
..F                                                                      [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________

    def test_drops_punctuation():
>       assert slugify("What's new?") == "whats-new"
E       assert "what's-new?" == 'whats-new'
E         
E         - whats-new
E         + what's-new?
E         ?     +     +

test_slugify.py:14: AssertionError
=========================== short test summary info ============================
FAILED test_slugify.py::test_drops_punctuation - assert "what's-new?" == 'wha...
1 failed, 2 passed in 0.10s

The ? markers under the diff point at the two extra characters. To make it pass, keep only letters, digits and spaces, in a small helper. Then, as many people do, write a test for the helper too, in its own file.

# slugify.py
def slugify(title):
    kept = _keep_slug_chars(title.lower())
    return kept.replace(" ", "-")


def _keep_slug_chars(text):
    return "".join(ch for ch in text if ch.isalnum() or ch == " ")
# test_internals.py
from slugify import _keep_slug_chars


def test_keep_slug_chars_drops_punctuation():
    assert _keep_slug_chars("what's new?") == "whats new"
$ pytest -q
....                                                                     [100%]
4 passed in 0.10s

Four green: three behaviour tests and the helper test. Keep an eye on test_internals.py. It tests how slugify is built, not what it does, and that matters in step 6.

5. Collapsing runs, and a test that passes the first time

Next item: runs of spaces and hyphens should collapse to one hyphen. Add the test to test_slugify.py.

# test_slugify.py
from slugify import slugify


def test_lowercases_and_joins_words_with_hyphens():
    assert slugify("Hello World") == "hello-world"


def test_works_for_any_title():
    assert slugify("TDD by Example") == "tdd-by-example"


def test_drops_punctuation():
    assert slugify("What's new?") == "whats-new"


def test_collapses_runs_of_spaces_and_hyphens():
    assert slugify("a  --  b") == "a-b"
$ pytest -q
....F                                                                    [100%]
=================================== FAILURES ===================================
__________________ test_collapses_runs_of_spaces_and_hyphens ___________________

    def test_collapses_runs_of_spaces_and_hyphens():
>       assert slugify("a  --  b") == "a-b"
E       AssertionError: assert 'a----b' == 'a-b'
E         
E         - a-b
E         + a----b

test_slugify.py:18: AssertionError
=========================== short test summary info ============================
FAILED test_slugify.py::test_collapses_runs_of_spaces_and_hyphens - Assertion...
1 failed, 4 passed in 0.15s

The hyphens were dropped as punctuation, leaving four spaces, and each became a hyphen. str.split() with no argument splits on any run of whitespace and ignores whitespace at the ends, so joining its pieces fixes this.

# slugify.py
def slugify(title):
    kept = _keep_slug_chars(title.lower())
    return "-".join(kept.split())


def _keep_slug_chars(text):
    return "".join(ch for ch in text if ch.isalnum() or ch == " ")
$ pytest -q
.....                                                                    [100%]
5 passed in 0.13s

Now the last item, trimming the ends. Add test_trims_the_ends to test_slugify.py.

# test_slugify.py
from slugify import slugify


def test_lowercases_and_joins_words_with_hyphens():
    assert slugify("Hello World") == "hello-world"


def test_works_for_any_title():
    assert slugify("TDD by Example") == "tdd-by-example"


def test_drops_punctuation():
    assert slugify("What's new?") == "whats-new"


def test_collapses_runs_of_spaces_and_hyphens():
    assert slugify("a  --  b") == "a-b"


def test_trims_the_ends():
    assert slugify("  Hi!  ") == "hi"
$ pytest -q
......                                                                   [100%]
6 passed in 0.08s

It passed on its first run, and no new code was written. That’s not a TDD win. The split() from the last step already trims the ends, so this test drove nothing. Keep it, because it documents a rule a reader of the tests would ask about, but know that a test you’ve never seen fail hasn’t proved it can. To check, break the code on purpose (change split() to split(" ")) and confirm the test goes red before you put it back.

6. Refactor: the suite says whether you broke it

Every item on the list passes, so this is the moment to clean up. A helper, a generator and a join can be one regular expression: find the runs of letters and digits, and join them with hyphens. The helper goes away.

# slugify.py
import re


def slugify(title):
    words = re.findall(r"[a-z0-9]+", title.lower())
    return "-".join(words)
$ pytest -q

==================================== ERRORS ====================================
______________________ ERROR collecting test_internals.py ______________________
ImportError while importing test module '/lab/test_internals.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/local/lib/python3.13/importlib/__init__.py:88: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
test_internals.py:2: in <module>
    from slugify import _keep_slug_chars
E   ImportError: cannot import name '_keep_slug_chars' from 'slugify' (/lab/slugify.py)
=========================== short test summary info ============================
ERROR test_internals.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.19s

The helper test broke, and it did more damage than failing: an import error during collection stops the whole run, so the five behaviour tests didn’t run either. You can’t tell whether the refactor kept the behaviour. The helper test checked a function that was always an implementation detail, and a refactor is exactly when implementation details change. Delete test_internals.py, since the behaviour tests already cover what it checked, and run again.

$ pytest -q
..F..                                                                    [100%]
=================================== FAILURES ===================================
____________________________ test_drops_punctuation ____________________________

    def test_drops_punctuation():
>       assert slugify("What's new?") == "whats-new"
E       AssertionError: assert 'what-s-new' == 'whats-new'
E         
E         - whats-new
E         + what-s-new
E         ?     +

test_slugify.py:14: AssertionError
=========================== short test summary info ============================
FAILED test_slugify.py::test_drops_punctuation - AssertionError: assert 'what...
1 failed, 4 passed in 0.09s

The behaviour tests caught a real change. The regex treats the apostrophe as a separator, so what's became two words. The old code deleted the apostrophe and kept whats whole. A refactor is supposed to change no behaviour, so this one isn’t finished: drop apostrophes before splitting.

# slugify.py
import re


def slugify(title):
    words = re.findall(r"[a-z0-9]+", title.lower().replace("'", ""))
    return "-".join(words)
$ pytest -q
.....                                                                    [100%]
5 passed in 0.07s

Green again, with shorter code and five tests that describe what slugify does without caring how. That’s the whole cycle: each test was red before it was green, except the one that showed you it wasn’t needed, and the tests caught the refactor’s mistake. Across three runs of every step, only the timings changed (0.04 to 0.21 s).

Where ATDD and BDD fit

TDD works at the level of one function and one developer. Acceptance-test-driven development (ATDD) applies the same test-first rule one level up: the team agrees on acceptance criteria for a feature, writes them as tests, and builds until they pass. Behaviour-driven development (BDD) writes those criteria as Given/When/Then scenarios in plain language (Given a post titled "What's new?" / When it is published / Then its URL ends in /whats-new), run by tools such as pytest-bdd or Cucumber. The inner loop that makes each scenario pass is still red, green, refactor.

Common gotchas

  • Red for the wrong reason. An import error or a typo in the test is red, but it tests nothing. Get to a red that fails on the assertion (step 1).
  • Skipping the refactor. Green with messy code is only two thirds of the cycle. The tests exist so the clean-up is safe.
  • Tests tied to internals. A test that imports a private helper breaks on every refactor and, as an import error, can stop the whole suite (step 6). Test through the public function.
  • A test that never failed. If a new test passes at once, check it can fail by breaking the code on purpose (step 5).
  • Steps too big. If a green takes more than a few minutes, the test asked for too much. Pick a smaller item from the list.

Practice

Each task starts from the finished slugify.py and test_slugify.py of step 6, not from the previous task’s answer: before each one, delete the previous task’s test file and put step 6’s slugify.py back. That’s why the solutions below each change step 6’s code on its own (task 2’s slugify.py has no unicodedata line, for example) and why every red run shows 5 passed next to the new failure. Write the test first, see it fail, then make it pass.

  1. Before the refactor, slugify("Café Crème") returned café-crème; after it, the result is different. Predict what the refactored version returns, then decide what it should return, and write that as a test.
Solution

Put both versions side by side and run them on the same title.

# compare.py - slugify before the refactor (step 5) and after it (step 6), on the same accented title
import re


def slugify_before(title):  # step 5: str.isalnum() keeps any letter, accented or not
    kept = "".join(ch for ch in title.lower() if ch.isalnum() or ch == " ")
    return "-".join(kept.split())


def slugify_after(title):  # step 6: the regex only knows a-z and 0-9
    words = re.findall(r"[a-z0-9]+", title.lower().replace("'", ""))
    return "-".join(words)


print("before:", slugify_before("Café Crème"))
print("after: ", slugify_after("Café Crème"))
$ python compare.py
before: café-crème
after:  caf-cr-me

The two versions really do disagree. str.isalnum() keeps accented letters; the regex [a-z0-9] doesn’t.

No test covered accents, so the refactor changed this behaviour without a single red. For a URL, plain ASCII is the safer choice. Write that down as a test:

# test_slugify_unicode.py
from slugify import slugify


def test_folds_accents_to_plain_letters():
    assert slugify("Café Crème") == "cafe-creme"
$ pytest -q
.....F                                                                   [100%]
=================================== FAILURES ===================================
_____________________ test_folds_accents_to_plain_letters ______________________

    def test_folds_accents_to_plain_letters():
>       assert slugify("Café Crème") == "cafe-creme"
E       AssertionError: assert 'caf-cr-me' == 'cafe-creme'
E         
E         - cafe-creme
E         ?    -   ^
E         + caf-cr-me
E         ?       ^

test_slugify_unicode.py:6: AssertionError
=========================== short test summary info ============================
FAILED test_slugify_unicode.py::test_folds_accents_to_plain_letters - Asserti...
1 failed, 5 passed in 0.13s

unicodedata.normalize("NFKD", ...) splits é into e plus a combining accent, and encoding to ASCII with "ignore" drops the accent.

# slugify.py
import re
import unicodedata


def slugify(title):
    plain = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode()
    words = re.findall(r"[a-z0-9]+", plain.lower().replace("'", ""))
    return "-".join(words)
$ pytest -q
......                                                                   [100%]
6 passed in 0.17s

The lesson: a refactor is only as safe as the tests are complete. Your test list missed a case, and the gap let behaviour change silently.

  1. A title with no letters or digits, such as "?!", should raise ValueError instead of returning an empty slug.
Solution
# test_slugify_errors.py
import pytest

from slugify import slugify


def test_rejects_a_title_with_no_letters_or_digits():
    with pytest.raises(ValueError, match="no letters or digits"):
        slugify("?!")
$ pytest -q
.....F                                                                   [100%]
=================================== FAILURES ===================================
________________ test_rejects_a_title_with_no_letters_or_digits ________________

    def test_rejects_a_title_with_no_letters_or_digits():
>       with pytest.raises(ValueError, match="no letters or digits"):
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       Failed: DID NOT RAISE ValueError

test_slugify_errors.py:8: Failed
=========================== short test summary info ============================
FAILED test_slugify_errors.py::test_rejects_a_title_with_no_letters_or_digits
1 failed, 5 passed in 0.17s

DID NOT RAISE is the red: slugify("?!") quietly returned "".

# slugify.py
import re


def slugify(title):
    words = re.findall(r"[a-z0-9]+", title.lower().replace("'", ""))
    if not words:
        raise ValueError(f"title has no letters or digits: {title!r}")
    return "-".join(words)
$ pytest -q
......                                                                   [100%]
6 passed in 0.12s

match= is a regular expression searched in the error message, so the test checks it’s the right ValueError, not any ValueError.

  1. Add an optional max_length: a slug longer than that is cut at the last hyphen that fits, never in the middle of a word. Write two tests, one long title and one short, before any code.
Solution
# test_slugify_length.py
from slugify import slugify


def test_cuts_a_long_title_at_a_word_boundary():
    title = "Test-driven development by example in Python"
    assert slugify(title, max_length=20) == "test-driven"


def test_leaves_a_short_title_alone():
    assert slugify("Hello World", max_length=20) == "hello-world"
$ pytest -q
.....FF                                                                  [100%]
=================================== FAILURES ===================================
__________________ test_cuts_a_long_title_at_a_word_boundary ___________________

    def test_cuts_a_long_title_at_a_word_boundary():
        title = "Test-driven development by example in Python"
>       assert slugify(title, max_length=20) == "test-driven"
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: slugify() got an unexpected keyword argument 'max_length'

test_slugify_length.py:7: TypeError
_______________________ test_leaves_a_short_title_alone ________________________

    def test_leaves_a_short_title_alone():
>       assert slugify("Hello World", max_length=20) == "hello-world"
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: slugify() got an unexpected keyword argument 'max_length'

test_slugify_length.py:11: TypeError
=========================== short test summary info ============================
FAILED test_slugify_length.py::test_cuts_a_long_title_at_a_word_boundary - Ty...
FAILED test_slugify_length.py::test_leaves_a_short_title_alone - TypeError: s...
2 failed, 5 passed in 0.20s

Both fail with TypeError, because the parameter doesn’t exist yet. The tests designed the interface: max_length is a keyword with a default, so the five old tests keep working unchanged.

# slugify.py
import re


def slugify(title, max_length=None):
    words = re.findall(r"[a-z0-9]+", title.lower().replace("'", ""))
    slug = "-".join(words)
    if max_length is not None and len(slug) > max_length:
        slug = slug[: max_length + 1].rsplit("-", 1)[0]
    return slug
$ pytest -q
.......                                                                  [100%]
7 passed in 0.16s

Cutting at max_length + 1 keeps a hyphen that falls exactly on the limit, so rsplit can drop the partial word after it. One case is still untested: a single word longer than max_length. That’s the next item for your test list.

Frequently Asked Questions

What are the three steps of test-driven development?
Red, green, refactor. Write one small test for behaviour that doesn't exist yet and watch it fail (red). Write the least code that makes it pass (green). Then clean up the code, and the tests, while every test stays passing (refactor). Repeat with the next test from your list.
Why write the test before the code instead of after?
A test written first has to fail before it can pass, which proves it can catch the bug it is meant to catch. A test written after the code often passes on its first run, and a test that has never failed may not be checking anything. Writing the test first also makes you decide how the function is called before you decide how it works.
Is it cheating to return a hard-coded value to make a test pass?
No, it's a deliberate TDD move called 'fake it'. Returning the expected constant gets you to green fast and shows the test is wired up. The next test, with a different input, fails against the constant and forces the real logic. Using two or more examples to force a general solution is called triangulation.
Should I write tests for private helper functions in TDD?
Usually not. A test that imports a private helper is tied to how the code is built, not to what it does, so a refactor that removes or renames the helper breaks the test even though behaviour is unchanged. Test through the public function; if a helper is complex enough to need its own tests, make it public.
What is the difference between TDD, ATDD and BDD?
TDD works at the level of a unit: a developer writes a small failing test, then the code. ATDD (acceptance-test-driven development) starts one level up, from acceptance criteria agreed with the business before the feature is built. BDD writes those criteria as Given/When/Then scenarios in plain language, run by tools such as pytest-bdd or Cucumber. All three write the test first; they differ in who writes it and how big a piece of behaviour it covers.