Chapter 7 TDD Tutorial
Let’s roll up our sleeves and put what we’ve learned into action by defining a function that’ll convert meters into feet and using Test-Driven Development (TDD). TDD helps us catch mistakes early and gives us confidence that our code works as expected. It’s like having a checklist that proves each part of our program is doing its job. We’re going to break it down into baby steps to make sure we really get it.
We’ll kick things off by reading a quick explanation of our function provided as a docstring.
It tells us what our function is supposed to do.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
"""
passHints
For an effective learning experience, it’s best to implement the solution on your own by following the outlined steps. Begin with the provided template in the exercises set, located at Preparation/Feet.
To execute the source code for each step, ensure you include the doctest module and enable test mode by adding the following code block at the end of your source file:
7.1 Core functionality
Now, let’s give our brand-new function a quick check-up with a functional test. Don’t worry if our function is still empty—it’s all part of the plan. We’re going to start by defining a test case for 1.0.
This is like asking, “Hey, if I hand you 1.0 meter, what’s that in feet?”
We’re setting up this question first, and then we’ll build up our function to give us the answer (3.28084).
Exercise 7.1
Practice defining test case (one_as_float):>>>
Solution. Here we have our first test case.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
>>> meters2feet(1.0) # <--- added in this step -------------------
3.28084
"""
pass## Finding tests in meters2feet
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## **********************************************************************
## Line 2, in meters2feet
## Failed example:
## meters2feet(1.0)
## Expected:
## 3.28084
## Got nothing
Right now, our function is like a musician on stage without a sheet of music—it’s not going to pass the test because it doesn’t have the notes to play. It’s going to fail the test case (0/1), simply because we haven’t provided any guidance yet.
But let’s take a tiny step forward. To get past this test, we’ll just make our function print out a specific number that we already know is the answer. It’s like giving our musician a single note to play.
Solution. Here we have a code just to pass the first test case.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
>>> meters2feet(1.0)
3.28084
"""
print(3.28084) # <--- added in this step -------------------------## Finding tests in meters2feet
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## ok
Okay, our function managed to pass the initial test (1/1), but we’re just getting started. Right now, our function is pretty limited—it’s like a musician who can only play one note. To really test its range, we need to introduce more scenarios.
Let’s stretch its capabilities by adding test cases for additional numbers.
We’ll start with 0 (because we can’t ignore the lowest value) and 1/3.28084 (the number that converts meters to feet). After setting up these new test cases, we’ll update our function so it can accurately handle this data.
Exercise 7.2
Practice defining test case (zero_as_int):>>>
>>>
Solution. Now, we have two more test cases and code to pass all three test cases.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
Examples:
specific values
>>> meters2feet(0) # <--- added in this step ---------------------
0.0
>>> meters2feet(1/3.28084) # <--- added in this step -------------
1.0
>>> meters2feet(1.0)
3.28084
"""
print(3.28084 * a_distance) # <--- modified in this step ---------## Finding tests in meters2feet
## Trying:
## meters2feet(0)
## Expecting:
## 0.0
## ok
## Trying:
## meters2feet(1/3.28084)
## Expecting:
## 1.0
## ok
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## ok
So far, it looks like we’re on a roll (3/3), but hold on—our function is only printing the result, not actually handing it back to us. It’s like a musician who plays a tune but never records it; we can’t use that melody anywhere else. We need to make sure our function is not just performing, but also giving us a recording we can play back later.
To do this, we’ll add a test case to check the type of value returned by our function. According to our docstring, it should be handing us a float. For this test, you can use any number you like, but let’s keep it simple and go with 1.
Exercise 7.3
Practice defining test case (return_type_is_float):>>>
Solution. Now, we have one more test case to check if the type of returned value is a floating point number.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
Examples:
specific values
>>> meters2feet(0)
0.0
>>> meters2feet(1/3.28084)
1.0
>>> meters2feet(1.0)
3.28084
type of returned value
>>> type(meters2feet(1)) is float # <--- added in this step ----
True
"""
print(3.28084 * a_distance)## Finding tests in meters2feet
## Trying:
## meters2feet(0)
## Expecting:
## 0.0
## ok
## Trying:
## meters2feet(1/3.28084)
## Expecting:
## 1.0
## ok
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## ok
## Trying:
## type(meters2feet(1)) is float
## Expecting:
## True
## **********************************************************************
## Line 8, in meters2feet
## Failed example:
## type(meters2feet(1)) is float
## Expected:
## True
## Got:
## 3.28084
## False
With this new test case in the suite, our function is going to stumble (3/4). To get back on track, we need to return the calculated value rather than just printing it.
Solution. Now, we replace print with return.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
Examples:
specific values
>>> meters2feet(0)
0.0
>>> meters2feet(1/3.28084)
1.0
>>> meters2feet(1.0)
3.28084
type of returned value
>>> type(meters2feet(1)) is float
True
"""
return 3.28084 * a_distance # <--- modified in this step ---------## Finding tests in meters2feet
## Trying:
## meters2feet(0)
## Expecting:
## 0.0
## ok
## Trying:
## meters2feet(1/3.28084)
## Expecting:
## 1.0
## ok
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## ok
## Trying:
## type(meters2feet(1)) is float
## Expecting:
## True
## ok
Fantastic! With that change, our function is now passing every test case (4/4).
7.2 Error handling
To ensure our function not only works correctly but also communicates effectively when things go wrong,
we need to incorporate error handling. This means preparing our function to deal with inputs that don’t make sense, like negative distances. We’ll begin by addressing an invalid value. We need to create a test case for this scenario and then enhance our function so that it raises a ValueError with a specific message: ‘The distance must be a non-negative number.’
In doctest, to indicate that we’re expecting an error, we must include the first and the last line of the traceback. Let’s define a test case for the input -0.1 and make sure our function raises the error as expected.
Exercise 7.4
Practice defining test case (ValueError):>>>
Traceback (most recent call last):
Solution. We are extending the code and the test case to include a reasonably small negative number.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
Examples:
invalid argument value
>>> meters2feet(-0.1) # <--- added in this step ------------------
Traceback (most recent call last):
ValueError: The distance must be a non-negative number.
specific values
>>> meters2feet(0)
0.0
>>> meters2feet(1/3.28084)
1.0
>>> meters2feet(1.0)
3.28084
type of returned value
>>> type(meters2feet(1)) is float
True
"""
# --- added in this step -----------------------------------------
if a_distance < 0:
raise ValueError("The distance must be a non-negative number.")
# ----------------------------------------------------------------
return 3.28084 * a_distance## Finding tests in meters2feet
## Trying:
## meters2feet(-0.1)
## Expecting:
## Traceback (most recent call last):
## ValueError: The distance must be a non-negative number.
## ok
## Trying:
## meters2feet(0)
## Expecting:
## 0.0
## ok
## Trying:
## meters2feet(1/3.28084)
## Expecting:
## 1.0
## ok
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## ok
## Trying:
## type(meters2feet(1)) is float
## Expecting:
## True
## ok
Great job! Our function is now gracefully handling negative numbers and passing all the test cases (5/5).
Next on our list is to tackle the issue of wrong input types. We want our function to be versatile enough to accept both floats and integers, which are the numeric types, but we draw the line at strings. If someone tries to pass a string to our function, it should firmly but politely refuse by raising a TypeError with the message ‘The distance must be a number.’ Let’s set up a test case for when someone calls it with the string ‘1’.
Exercise 7.5
Practice defining test case (TypeError):>>>
Traceback (most recent call last):
Solution. Let us try to do it this way.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
Examples:
invalid argument type
>>> meters2feet("1") # <--- added in this step -------------------
Traceback (most recent call last):
TypeError: The distance must be a number.
invalid argument value
>>> meters2feet(-0.1)
Traceback (most recent call last):
ValueError: The distance must be a non-negative number.
specific values
>>> meters2feet(0)
0.0
>>> meters2feet(1/3.28084)
1.0
>>> meters2feet(1.0)
3.28084
type of returned value
>>> type(meters2feet(1)) is float
True
"""
if a_distance < 0:
raise ValueError("The distance must be a non-negative number.")
# --- added in this step -----------------------------------------
if type(a_distance) is not float and type(a_distance) is not int:
raise TypeError("The distance must be a number.")
# ----------------------------------------------------------------
return 3.28084 * a_distance## Finding tests in meters2feet
## Trying:
## meters2feet("1")
## Expecting:
## Traceback (most recent call last):
## TypeError: The distance must be a number.
## **********************************************************************
## Line 2, in meters2feet
## Failed example:
## meters2feet("1")
## Expected:
## Traceback (most recent call last):
## TypeError: The distance must be a number.
## Got:
## Traceback (most recent call last):
## File "/usr/lib/python3.13/doctest.py", line 1398, in __run
## exec(compile(example.source, filename, "single",
## ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
## compileflags, True), test.globs)
## ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
## File "<doctest meters2feet[0]>", line 1, in <module>
## meters2feet("1")
## ~~~~~~~~~~~^^^^^
## File "<string>", line 40, in meters2feet
## TypeError: '<' not supported between instances of 'str' and 'int'
## Trying:
## meters2feet(-0.1)
## Expecting:
## Traceback (most recent call last):
## ValueError: The distance must be a non-negative number.
## ok
## Trying:
## meters2feet(0)
## Expecting:
## 0.0
## ok
## Trying:
## meters2feet(1/3.28084)
## Expecting:
## 1.0
## ok
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## ok
## Trying:
## type(meters2feet(1)) is float
## Expecting:
## True
## ok
Oops, looks like our function hit a sour note on one of the tests (5/6). Can you guess what happened?
Solution. For our function to hit the right notes, we need to adjust the order of our checks. First, we’ll check the type of the input to make sure it’s a number. If it is a number, then we’ll move on to check the value of the input to ensure it’s non-negative.
That’s a solid principle to follow. When we’re setting up checks in our code, we should indeed first verify the types, then assess the properties or content. This ensures that we’re working with the right kind of data before we start evaluating its specifics.
7.3 Complete solution
The definition we have now is considered a complete solution for our example, as it has successfully passed all the test cases (6/6). If you’re interested in expanding its capabilities, you could introduce new test cases and adjust the function accordingly to meet these additional requirements.
Solution. Now, we have a version with desired functionality.
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
Examples:
invalid argument type
>>> meters2feet("1")
Traceback (most recent call last):
TypeError: The distance must be a number.
invalid argument value
>>> meters2feet(-0.1)
Traceback (most recent call last):
ValueError: The distance must be a non-negative number.
specific values
>>> meters2feet(0)
0.0
>>> meters2feet(1/3.28084)
1.0
>>> meters2feet(1.0)
3.28084
type of returned value
>>> type(meters2feet(1)) is float
True
"""
# --- order swapped in this step ---------------------------------
if type(a_distance) is not float and type(a_distance) is not int:
raise TypeError("The distance must be a number.")
if a_distance < 0:
raise ValueError("The distance must be a non-negative number.")
# ----------------------------------------------------------------
return 3.28084 * a_distance
if __name__ == "__main__":
import doctest # importing doctest module
doctest.testmod() # activating test mode## Finding tests in meters2feet
## Trying:
## meters2feet("1")
## Expecting:
## Traceback (most recent call last):
## TypeError: The distance must be a number.
## ok
## Trying:
## meters2feet(-0.1)
## Expecting:
## Traceback (most recent call last):
## ValueError: The distance must be a non-negative number.
## ok
## Trying:
## meters2feet(0)
## Expecting:
## 0.0
## ok
## Trying:
## meters2feet(1/3.28084)
## Expecting:
## 1.0
## ok
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## ok
## Trying:
## type(meters2feet(1)) is float
## Expecting:
## True
## ok
7.4 Clean solution
With our test cases in place, we can begin refactoring our solution to achieve cleaner and more maintainable code. At any point during this process, we can rerun the test cases to ensure that our program remains correct and functions as expected. One improvement we’ll make is to eliminate the ‘magic number’ in our code by defining a constant named METER2FEET_RATIO.
This makes the code clearer for others (and our future selves) to understand.
Solution. This a clean solution, which is still passing all our test cases.
METER2FEET_RATIO = 3.28084 # <--- added in this step --------------------------
def meters2feet(a_distance):
"""
Calculates feet based on a distance (parameter) given in meters.
:param a_distance: distance in meters
:type a_distance: float
:return: distance in feet
:rtype: float
Examples:
invalid argument type
>>> meters2feet("1")
Traceback (most recent call last):
TypeError: The distance must be a number.
invalid argument value
>>> meters2feet(-0.1)
Traceback (most recent call last):
ValueError: The distance must be a non-negative number.
specific values
>>> meters2feet(0)
0.0
>>> meters2feet(1/METER2FEET_RATIO) # <--- updated in this step -----------
1.0
>>> meters2feet(1.0)
3.28084
type of returned value
>>> type(meters2feet(1)) is float
True
"""
if type(a_distance) is not float and type(a_distance) is not int:
raise TypeError("The distance must be a number.")
if a_distance < 0:
raise ValueError("The distance must be a non-negative number.")
return METER2FEET_RATIO * a_distance # <--- updated in this step -----------
if __name__ == "__main__":
import doctest # importing doctest module
doctest.testmod() # activating test mode## Finding tests in meters2feet
## Trying:
## meters2feet("1")
## Expecting:
## Traceback (most recent call last):
## TypeError: The distance must be a number.
## ok
## Trying:
## meters2feet(-0.1)
## Expecting:
## Traceback (most recent call last):
## ValueError: The distance must be a non-negative number.
## ok
## Trying:
## meters2feet(0)
## Expecting:
## 0.0
## ok
## Trying:
## meters2feet(1/METER2FEET_RATIO)
## Expecting:
## 1.0
## ok
## Trying:
## meters2feet(1.0)
## Expecting:
## 3.28084
## ok
## Trying:
## type(meters2feet(1)) is float
## Expecting:
## True
## ok
Think of this like being a musician: just because a song sounds okay the first time you play it doesn’t mean you’re done—you can keep practicing and refining it until it really sings. The same goes for code!