A test-driven workflow and a quality gate

Intermediate45 min

Grow a function one failing test at a time, measure what your tests miss with coverage, and collect the checks into one command that runs before every commit and in CI.

What you will learn
  • Follow the red, green, refactor loop on a small function
  • Turn a bug report into a regression test before fixing it
  • Read a coverage report with missing lines and branches
  • Put your project's checks behind one command, a pre-commit hook and a CI job
On this page

Writing tests after the code is better than not writing them, but it has a weakness: you tend to test what the code already does. Writing the test first reverses that. You state what you want, watch the test fail, and then write just enough code to make it pass. This is test-driven development (TDD), and the fundamentals lesson on the testing mindset introduces the idea. Here you will do it in Python, and then wrap the tests in the checks that keep a project healthy.

Red, green, refactor

The loop has three beats:

  1. Red. Write one small test for behaviour that does not exist yet. Run it and see it fail, for the reason you expect.
  2. Green. Write the least code that makes it pass, even if it looks naive.
  3. Refactor. With the tests as a safety net, clean up. Run them again.

The task: parse_duration("1h30m") should return the number of seconds. Start with the simplest case, seconds only. The function exists only as a stub that returns the wrong thing:

c35_red.py
import unittest
def parse_duration(text):
return 0 # a stub: it exists, but it does not work yet
class ParseDurationTests(unittest.TestCase):
def test_seconds_only(self):
self.assertEqual(parse_duration("90s"), 90)
if __name__ == "__main__":
unittest.main()
Terminal window
$ python -m unittest -v c35_red.py
test_seconds_only (c35_red.ParseDurationTests.test_seconds_only) ... FAIL
======================================================================
FAIL: test_seconds_only (c35_red.ParseDurationTests.test_seconds_only)
----------------------------------------------------------------------
Traceback (most recent call last):
File "c35_red.py", line 10, in test_seconds_only
self.assertEqual(parse_duration("90s"), 90)
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 0 != 90
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)

That is red, and for the right reason: the function returned 0, the test wanted 90. A test that fails for a different reason (a typo, an import error) proves nothing yet.

Now the least code that works. It does not parse hours or minutes, because no test asks for them:

c35_green.py
import unittest
def parse_duration(text):
# the least code that makes the one test pass
return int(text[:-1])
class ParseDurationTests(unittest.TestCase):
def test_seconds_only(self):
self.assertEqual(parse_duration("90s"), 90)
if __name__ == "__main__":
unittest.main()
Terminal window
$ python -m unittest -v c35_green.py
test_seconds_only (c35_green.ParseDurationTests.test_seconds_only) ... ok
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK

It feels almost silly, and that is the point. The next test, "1m30s", forces a real parser into existence. You repeat the loop for "1h30m", "1h1m1s", capital letters, and then the error cases. After a few rounds the function looks like this. It uses the re module for pattern matching, which the standard library tour introduces:

c35_duration.py
"""parse_duration, grown one failing test at a time."""
import re
_PART = re.compile(r"(\d+)([hms])")
_SECONDS = {"h": 3600, "m": 60, "s": 1}
def parse_duration(text):
"""Return the number of seconds in text such as '1h30m' or '90s'."""
text = text.strip().lower()
parts = _PART.findall(text)
if not parts or "".join(n + unit for n, unit in parts) != text:
raise ValueError(f"not a duration: {text!r}")
return sum(int(n) * _SECONDS[unit] for n, unit in parts)

And its tests, which now record every decision you made along the way. First the three units and their combinations:

c35_tdd_final.py
import unittest
from c35_duration import parse_duration
class ParseDurationTests(unittest.TestCase):
def test_seconds_only(self):
self.assertEqual(parse_duration("90s"), 90)
def test_minutes_and_seconds(self):
self.assertEqual(parse_duration("1m30s"), 90)
def test_hours_and_minutes(self):
self.assertEqual(parse_duration("1h30m"), 5400)
def test_all_three_units(self):
self.assertEqual(parse_duration("1h1m1s"), 3661)

The rest cover tolerance and rejection:

c35_tdd_final.py (continued)
def test_capitals_and_spaces_are_tolerated(self):
self.assertEqual(parse_duration(" 2H "), 7200)
def test_rejects_text_that_is_not_a_duration(self):
for bad in ["", "abc", "10", "5x", "1h junk"]:
with self.subTest(bad=bad):
with self.assertRaises(ValueError):
parse_duration(bad)
if __name__ == "__main__":
unittest.main()
Terminal window
$ python -m unittest -v c35_tdd_final.py
test_all_three_units (c35_tdd_final.ParseDurationTests.test_all_three_units) ... ok
test_capitals_and_spaces_are_tolerated (c35_tdd_final.ParseDurationTests.test_capitals_and_spaces_are_tolerated) ... ok
test_hours_and_minutes (c35_tdd_final.ParseDurationTests.test_hours_and_minutes) ... ok
test_minutes_and_seconds (c35_tdd_final.ParseDurationTests.test_minutes_and_seconds) ... ok
test_rejects_text_that_is_not_a_duration (c35_tdd_final.ParseDurationTests.test_rejects_text_that_is_not_a_duration) ... ok
test_seconds_only (c35_tdd_final.ParseDurationTests.test_seconds_only) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.001s
OK

The test names read like a specification. The refactor beat is where you tidy the regular expression, secure in knowing the six tests will tell you if you break something.

TDD is a habit, not a law. It pays most for logic with clear inputs and outputs, like parsers, and least for exploratory code.

A bug report is a free test

When a user reports a bug, resist the urge to open the code. First write a test that reproduces it and fails. Only then fix the code. Two things follow: you have proved you understood the bug, and it can never come back unnoticed. This is a regression test.

The exercise at the end of this lesson walks through one.

What do your tests miss? Coverage

A passing suite says nothing about the code your tests never ran. Coverage tools record which lines executed during the tests. The common one is coverage.py, used through the pytest-cov plugin. These are third-party packages:

Terminal window
$ python -m pip install coverage pytest-cov

Run the bank tests from the previous lessons with the plugin’s report. --cov-branch also tracks whether both outcomes of each if were seen, and term-missing lists the lines that never ran:

Terminal window
$ python -m pytest -q --cov=bank --cov-branch --cov-report=term-missing
...... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
-----------------------------------------------------
bank.py 17 2 8 2 84% 11, 22
-----------------------------------------------------
TOTAL 17 2 8 2 84%
6 passed in 0.03s

All six tests pass and 84 per cent of bank.py was exercised. The Missing column is the useful part. Lines 11 and 22 are the two raise ValueError lines for a negative opening balance and a non-positive withdrawal. No test ever reached them. Add one test for each and the report changes:

pt_cov/tests/test_bank_edges.py
import pytest
from bank import Account
def test_negative_opening_balance_is_refused():
with pytest.raises(ValueError, match="cannot be negative"):
Account("Ana", -1)
def test_negative_withdrawal_is_refused():
with pytest.raises(ValueError, match="must be positive"):
Account("Ana", 10).withdraw(-5)
Terminal window
$ python -m pytest -q --cov=bank --cov-branch --cov-report=term-missing
........ [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
-----------------------------------------------------
bank.py 17 0 8 0 100%
-----------------------------------------------------
TOTAL 17 0 8 0 100%
8 passed in 0.02s

Use the number as a pointer, not a goal. A line can run without any assertion checking its result, so 100 per cent coverage does not mean 100 per cent correct. The Missing list is the valuable output: it shows you where nobody has looked.

One command for every check

By now a project has several checks: tests, doctests (see the doctest lesson), the linter and type checker from the ruff and type checking lesson. Nobody remembers to run five commands. Put them behind one. Here is a small script that does it with the standard library only. It belongs to a project folder with a module wordcount.py that has a doctest, and a test_wordcount.py file of unit tests:

c35_project/check.py
"""Run every check this project has. Exit with status 1 if any fails."""
import subprocess
import sys
CHECKS = [
("doctests", [sys.executable, "-m", "doctest", "wordcount.py"]),
("unit tests", [sys.executable, "-m", "unittest", "-q"]),
]
failed = []
for name, command in CHECKS:
result = subprocess.run(command, capture_output=True, text=True)
status = "ok" if result.returncode == 0 else "FAILED"
print(f"{name:12} {status}")
if result.returncode != 0:
failed.append(name)
print(result.stdout + result.stderr)
print("all checks passed" if not failed else f"failed: {', '.join(failed)}")
sys.exit(1 if failed else 0)
Terminal window
$ python check.py
doctests ok
unit tests ok
all checks passed

The script exits with status 1 when anything fails. That exit status is what every automation looks at, so the same command can run in three places.

By hand, before you commit. In a pre-commit hook, so a commit is refused when the checks fail. With the pre-commit tool, a local hook that runs your script looks like this:

.pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: project-checks
name: project checks
entry: python check.py
language: system
pass_filenames: false

After pre-commit install, git runs the hook on every commit. The version control lesson explains commits if you need a refresher.

In continuous integration (CI), which runs the same command on a clean machine for every push or pull request, so “it works on my laptop” stops being an excuse. For GitHub, a minimal workflow is:

.github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: python -m pip install pytest ruff
- run: python check.py

Action versions such as @v4 move on, so check each action’s page. The point is that the CI file holds almost nothing of its own. It installs the tools and calls the same script you run locally, so the two cannot drift apart.

Common pitfalls

  • Writing the test and the code together. Without seeing red first, you do not know the test can fail. A test that has never failed may check nothing.
  • A test that fails for the wrong reason. NameError or ImportError is red, but not the red you wanted. Read the message.
  • Writing a big code change to go green. Make the smallest change, then run again. Large jumps mean large debugging sessions.
  • Skipping the hook with --no-verify. If the checks are slow or flaky, fix them. A hook everyone bypasses is worse than none.
ExerciseTake one more step through the loop

parse_duration("1h 30m") currently raises ValueError, but users type a space between the parts. You want "1h 30m" to give 5400, while "1 h" (a space between a number and its unit) is still rejected. Follow the loop. First write the two tests and run them against the function from this lesson: the run should end with FAILED (errors=1). Then change the function until the run ends with OK without breaking the old cases.

Show solution

Red: the first test errors because the function does not allow a space. The second already passes, which is fine. It guards against going too far later.

c35_ex_spaces_red.py
import unittest
from c35_duration import parse_duration
class SpacesTests(unittest.TestCase):
def test_spaces_between_parts(self):
self.assertEqual(parse_duration("1h 30m"), 5400)
def test_a_space_inside_a_part_is_refused(self):
with self.assertRaises(ValueError):
parse_duration("1 h")
if __name__ == "__main__":
unittest.main()
Terminal window
$ python -m unittest -v c35_ex_spaces_red.py
test_a_space_inside_a_part_is_refused (c35_ex_spaces_red.SpacesTests.test_a_space_inside_a_part_is_refused) ... ok
test_spaces_between_parts (c35_ex_spaces_red.SpacesTests.test_spaces_between_parts) ... ERROR
======================================================================
ERROR: test_spaces_between_parts (c35_ex_spaces_red.SpacesTests.test_spaces_between_parts)
----------------------------------------------------------------------
Traceback (most recent call last):
File "c35_ex_spaces_red.py", line 8, in test_spaces_between_parts
self.assertEqual(parse_duration("1h 30m"), 5400)
~~~~~~~~~~~~~~^^^^^^^^^^
File "c35_duration.py", line 14, in parse_duration
raise ValueError(f"not a duration: {text!r}")
ValueError: not a duration: '1h 30m'
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (errors=1)

Green: split the text on whitespace, validate each word with the same pattern as before, and add the pieces together. A lone "1" or "h" is not a valid word, so "1 h" is still refused.

c35_spaces_duration.py
import re
_PART = re.compile(r"(\d+)([hms])")
_SECONDS = {"h": 3600, "m": 60, "s": 1}
def parse_duration(text):
"""Return seconds for text such as '1h30m' or '1h 30m'."""
tokens = text.strip().lower().split()
if not tokens:
raise ValueError(f"not a duration: {text!r}")
total = 0
for token in tokens:
parts = _PART.findall(token)
if not parts or "".join(n + u for n, u in parts) != token:
raise ValueError(f"not a duration: {text!r}")
total += sum(int(n) * _SECONDS[u] for n, u in parts)
return total

The tests now include the old cases, so you see them still pass:

c35_ex_spaces_green.py
import unittest
from c35_spaces_duration import parse_duration
class DurationTests(unittest.TestCase):
def test_spaces_between_parts(self):
self.assertEqual(parse_duration("1h 30m"), 5400)
def test_a_space_inside_a_part_is_refused(self):
with self.assertRaises(ValueError):
parse_duration("1 h")
def test_the_old_cases_still_pass(self):
self.assertEqual(parse_duration("1h1m1s"), 3661)
self.assertEqual(parse_duration(" 2H "), 7200)
with self.assertRaises(ValueError):
parse_duration("")
if __name__ == "__main__":
unittest.main()
Terminal window
$ python -m unittest -v c35_ex_spaces_green.py
test_a_space_inside_a_part_is_refused (c35_ex_spaces_green.DurationTests.test_a_space_inside_a_part_is_refused) ... ok
test_spaces_between_parts (c35_ex_spaces_green.DurationTests.test_spaces_between_parts) ... ok
test_the_old_cases_still_pass (c35_ex_spaces_green.DurationTests.test_the_old_cases_still_pass) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.001s
OK
ExercisePredict the Missing column

These three tests cover the valid inputs of parse_duration, which lives in duration.py (the same code as c35_duration.py above). Before running anything, say which line of duration.py the coverage report will list under Missing, and what the last line of the pytest report reads.

pt_duration/tests/test_valid.py
from duration import parse_duration
def test_seconds_only():
assert parse_duration("90s") == 90
def test_hours_and_minutes():
assert parse_duration("1h30m") == 5400
def test_capitals_and_outer_spaces_are_tolerated():
assert parse_duration(" 2H ") == 7200
Show solution

Line 14 is the raise ValueError(...) for text that is not a duration. No test passes bad input, so it never runs. Nothing else is missing, and duration.py shows one partly covered branch: the if on line 13 was never true.

Terminal window
$ python -m pytest -q --cov=duration --cov-branch --cov-report=term-missing
... [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.15.0-final-0 ________________
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------------
duration.py 9 1 2 1 82% 14
---------------------------------------------------------
TOTAL 9 1 2 1 82%
3 passed in 0.02s

The last line is 3 passed, because coverage reports gaps and does not fail the run. The fix is a test with bad input, such as pytest.raises(ValueError) around parse_duration("5x").

ExerciseA test that was never red

You are about to write parse_duration("2m"), so you start with a stub and one test. The run ends with OK, but a test written first must be red. Change the test so the run ends with FAILED (failures=1) against the stub. Then say what the stub needs to return to make it green.

c35_ex_never_red.py
import unittest
def parse_duration(text):
return 0 # a stub: the real parser is not written yet
class MinutesTests(unittest.TestCase):
def test_two_minutes(self):
result = parse_duration("2m")
self.assertIsNotNone(result)
if __name__ == "__main__":
unittest.main()
Show solution

assertIsNotNone only checks that the stub returns something, and 0 is not None. The test cannot tell a working parser from a stub. Assert the value you actually want:

c35_ex_never_red_fixed.py
import unittest
def parse_duration(text):
return 0 # a stub: the real parser is not written yet
class MinutesTests(unittest.TestCase):
def test_two_minutes(self):
self.assertEqual(parse_duration("2m"), 120)
if __name__ == "__main__":
unittest.main()
Terminal window
$ python -m unittest -v c35_ex_never_red_fixed.py
test_two_minutes (c35_ex_never_red_fixed.MinutesTests.test_two_minutes) ... FAIL
======================================================================
FAIL: test_two_minutes (c35_ex_never_red_fixed.MinutesTests.test_two_minutes)
----------------------------------------------------------------------
Traceback (most recent call last):
File "c35_ex_never_red_fixed.py", line 10, in test_two_minutes
self.assertEqual(parse_duration("2m"), 120)
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 0 != 120
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)

The stub returns 0 and the test wants 120, which is red for the right reason. Returning int(text[:-1]) * 60 turns it green, and the next test will force the real parser. The habit is to watch every new test fail once before you trust it.

Summary

  • The loop is red (a failing test, for the right reason), green (the least code that passes), refactor (tidy with the tests as a net). A bug report becomes a failing regression test first.
  • pytest-cov shows which lines and branches your tests never ran. Use the Missing column to find gaps, and do not treat the percentage as proof of quality.
  • Put every check behind one script that exits non-zero on failure, then call it from your hands, a pre-commit hook and CI.
  • The next lesson shows how to test code that talks to the network, the clock or other things you cannot control.