Software Testing · lesson 7 of 8
Beginner
1Intermediate
4Advanced
3Also part of Software Testing
12- + Testing Python Code from Python
- + Unit Testing with JUnit 5 and Mockito from Java
- + Testing JavaScript with Jest and Vitest from JavaScript
- + Testing in Go from Go
- + Testing in Rust from Rust
- + Testing with MUnit and ScalaCheck from Scala
- + Contract Testing with OpenAPI, and Running in CI from API Testing
- + Your First Playwright Test from Playwright
- + Flaky Tests: Staleness, Retries, and CI from Selenium
- + Testing Data Pipelines from Data Engineering
- + terraform test: Catching a Wrong Assertion, Not Just a Wrong Plan from Terraform
- + Testing TypeScript from TypeScript
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
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.
- Before the refactor,
slugify("Café Crème")returnedcafé-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.
- A title with no letters or digits, such as
"?!", should raiseValueErrorinstead 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.
- 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.