Skip to main content

Class 5 - Conditionals, Tests, and print

Date: September 17, 2026

⚠️Homework
Homework 1 due September 20, 2026

Skills: 0, 1, 2​

Notes (review before class)​

Tests​

Keep the examples a function was written from (Class 4), since software evolves: a function may stop matching them, on purpose or by mistake.

def moon_weight(earth_weight: float) -> float:
"""Compute weight on moon from weight on earth"""
return earth_weight * 1/6

def test_moon_weight():
assert moon_weight(100) == 100 * 1/6
assert moon_weight(150) == 150 * 1/6
assert moon_weight(90) == 90 * 1/6
  • A test function's name starts with test_; it goes right after the function it checks.
  • Each assert checks that the expression after it is True; == checks that two values are equal.
  • PLL runs the tests every time you run the file, and reports failures. A test function stops at its first failing assert.
  • Try: change the body to return earth_weight * 1/3 and run the file, to see what a failing test looks like. Then fix it, break a test instead, and compare.

Writing Good Tests​

def test_pen_cost():
assert pen_cost(3, "wow") == 3 * (0.25 + len("wow")*0.02)
assert pen_cost(10, "smile") == 10 * (0.25 + len("smile")*0.02)
assert pen_cost(5, "") == 5 * 0.25
assert pen_cost(0, "huskies") == 0
  • The last two are special (yet valid) inputs: an empty message, and no pens. (Ignore nonsensical ones, like negative pens, for now.)
  • The right of == just has to evaluate to the expected answer, written in some meaningful way.
  • Never run the function to get a test's answer! If a wrong body produces the answers, the tests can't catch it.
  • Examples that repeat the body catch little, but later ones will be much simpler than the body: professionals write large collections of tests.
  • Working on concrete values first checks that you understand the problem: if you can't write examples, you probably can't write the function.
  • Tests are step 3 of the design recipe: (1) type annotations, (2) docstring, (3) tests, (4) the body.

Choosing Test Inputs​

add_shipping adds shipping to an order: $4 for $10 or less, $8 above. Tests first, with strategically chosen inputs:

def test_add_shipping():
assert add_shipping(10) == 10 + 4
assert add_shipping(3.95) == 3.95 + 4
assert add_shipping(20) == 20 + 8
assert add_shipping(10.01) == 10.01 + 8
  • 10 is at the boundary between the charges; 10.01 is just over it. Boundaries are a common source of bugs (mistakes that make a program misbehave).
  • Whole and decimal numbers, and examples for each charge.
  • 4 and 8 appear only in the answers: which one depends on the input.

Conditionals​

An if statement asks a question about the input:

def add_shipping(order_amt: float) -> float:
"""add shipping costs to order total"""
if order_amt <= 10:
return order_amt + 4
else:
return order_amt + 8
  • If the answer to the question (order_amt <= 10) is true, Python runs the code under the if; if false, the code under else (the else clause).
  • The if and else lines end with a colon; each case is indented under its line.

Booleans​

3.95 <= 10 is True and 20 <= 10 is False: the only two values of the type bool (for Boolean, after George Boole). (order_amt <= 10 at the prompt is an error: order_amt only exists inside add_shipping.)

1 == 1            # True
"cat" == "CAT" # False
"a" < "b" # True
"that" < "this" # True
"a" >= "C" # True
"a" >= "A" # True
  • == checks equality (unlike =, which associates a name with a value).
  • Strings are case-sensitive.
  • <, >=, etc. order strings alphabetically, following ASCII, an old convention: every uppercase letter comes before every lowercase one (letters outside ASCII are far more complicated).
  • Any two values can be compared: "a" == 1 is False, not an error, so comparing a number with a string by mistake quietly gives nonsense.
  • Most kinds of data have operations producing booleans; in checks whether one string appears inside another:
wm = "will.i.am"
"will" in wm # True
"Will" in wm # False (note the capital W)

and, or, and not combine booleans (vote if a citizen and old enough; board a bus with a ticket or on a free-ride day; drive if not inebriated):

(1 < 2) and (3 < 2)  # False
(3 < 2) or (1 < 2) # True
not (1 < 2) # False

Asking Multiple Questions​

For a third charge ($12, over $30), elif ("else if") adds a question:

def add_shipping(order_amt: float) -> float:
"""add shipping costs to order total"""
if order_amt <= 10:
return order_amt + 4
elif order_amt <= 30:
return order_amt + 8
else:
return order_amt + 12
  • Python asks the questions in order, and runs the code for the first that is True (or else, if none are). Any number of elifs is allowed.
  • "Between $10 and $30" is implicit: order_amt <= 30 is only asked if order_amt <= 10 was False. elif (order_amt > 10) and (order_amt <= 30): makes it explicit (comparisons happen before and, so the parentheses just make the order clear).
  • Both versions are correct; add tests for the new charge, like assert add_shipping(30) == 30 + 8 and assert add_shipping(32) == 32 + 12.
  • A redundant condition like order_amt > 10 shows readers which case it covers, and keeps a mistake in an earlier question (now, or after a later change) from silently giving surprising output.

Try: write show_ad, which takes a user's age and hair color and decides whether to show them an ad for a skateboarding park: True if they're between 9 and 18 with pink or purple hair. Write it once with if, once with only boolean operations.

Harms from reducing people to simple data. show_ad encodes a stereotype: many skateboarders aren't young, or don't have colored hair. Real systems deciding who sees an ad, is hired, gets a loan, or is released from jail also compare data about people with criteria. Ask what data should represent people, and what stereotypes it might encode: decisions based on it can be inaccurate, and harmful.

Evaluating by Reducing Expressions​

Substitution (Class 4) works with if too, step by step, as in algebra. Pay is $10 an hour up to 40 hours, and $15 for each extra hour:

def wage(hours: float) -> float:
"""pay for hours worked: $10 an hour, and $15 an hour after 40"""
if hours <= 40:
return hours * 10
elif hours > 40:
return 40*10 + (hours-40)*15
    wage(45)
=> if 45 <= 40:
return 45 * 10
elif 45 > 40:
return 40*10 + (45-40)*15
=> if False:
return 45 * 10
elif 45 > 40:
return 40*10 + (45-40)*15
=> if 45 > 40:
return 40*10 + (45-40)*15
=> if True:
return 40*10 + (45-40)*15
=> return 40*10 + (45-40)*15
=> return 400 + 5*15
=> return 475
  • 45 replaces hours; a False question's case is dropped and the next question tried; a True one's case is all that's left.
  • This is the best way to think about how Python evaluates, and to work through a program by hand or in your head.

Composing Functions​

To price pens with shipping, send pen_cost's output into add_shipping: we compose the functions, directly, or by naming the intermediate result:

add_shipping(pen_cost(10, "bravo"))
pens = pen_cost(10, "bravo")
add_shipping(pens)
  • Both give 7.5: a function's inputs are evaluated before the call, so 3.5 is substituted for order_amt either way.
  • The second also stores 3.5 in the directory as pens: more steps, but some find it easier to read.
  • A function's body can compose functions too: a function that needs another's result calls it (a helper), rather than repeating its code.

Local Names and the Directory​

A name defined in a body can make it more readable:

def pen_cost(num_pens: int, message: str) -> float:
"""total cost for pens, each 25 cents
plus 2 cents per message character"""
message_cost = len(message) * 0.02
return num_pens * (0.25+message_cost)

pens = pen_cost(10, "bravo") now makes two directory entries, message_cost (inside the body of pen_cost) and pens, but using message_cost afterwards is a NameError:

pens = pen_cost(10, "bravo")
cheap_message = message_cost > 0.5
add_shipping(pens)
  • Entries made inside a function are local (private) to its body: each call gets a local directory that other code can't see (a library function's body may not even be visible).
  • A body can use its local names (message_cost) and the global directory (len), but never another call's local names.
  • When a call finishes, its local directory disappears.

Debugging with print​

Debugging is finding and removing bugs. print shows values while code runs: any number of them, separated by commas, shown with spaces between (so a label can go before a value):

print("pens:", pens, "with shipping:", add_shipping(pens))
  • This shows pens: 3.5 with shipping: 7.5.
  • A print in a body shows values each time the function runs; what's printed during a test is shown with that test's results.
  • To see a question's answer, either name it (small = order_amt <= 10, a local name) and print small, or print the expression itself (print("small:", order_amt <= 10)), adding no name.

Recap (20 mins)​

You wear a sun hat when it's 80F or above, and otherwise no hat. Here is choose_hat, designed with the recipe, with a print added while testing it:

def choose_hat(temp_in_f: float) -> str:
"""determines appropriate head gear, with 80F or above a sun hat, below nothing"""
comparison = temp_in_f >= 80
print("temp_in_f:", temp_in_f, "comparison:", comparison)
if comparison:
return "sun hat"
else:
return "no hat"

def test_choose_hat():
assert choose_hat(50) == "no hat"
assert choose_hat(85) == "sun hat"
assert choose_hat(80) == "sun hat"
  • When PLL runs the tests, what does the print show, line by line?
  • Why is there a test for 80? If >= were >, which assert would fail, and what would the print have shown just before it?
  • After the file runs, what happens if you enter comparison at the prompt? What about choose_hat(95)?

Class Exercise (30 mins)​

  1. Add another possibility to choose_hat, that if the temperature is below 50, you choose a winter hat. Any time you are changing code, you should revisit each step of the design recipe, in order, and change as appropriate (type signature, doc string, tests, code). To have more than one comparison, use:
if condition:
...
elif condition:
...
elif condition: # any number of times
...
else:
...
  1. Now imagine you got new sunglasses, and want to design a function add_glasses that takes an outfit (as a string) and always adds ", and glasses" to what you will wear.
  2. Let's now design a new function, choose_outfit, that takes as input the temperature in fahrenheit, and uses the two functions you just wrote to compute a final outfit. There are at least two ways of doing this; one defines a new local name with the result of calling choose_hat; the other directly passes the result of calling choose_hat to add_glasses
  3. Now, add a new function choose_hat_or_visor that takes not only a temp_in_f, but also an additional argument, has_visor, which is a boolean that indicates whether the person owns a visor. If they do, and the temperature is above 95F, they will wear that. Your function should be able to re-use logic from choose_hat. The boolean operator and, written boolean1 and boolean2, and evaluates to True if both inputs to it are True, will probably be useful! (Python also has or, which won't be useful in this problem, and not, written not boolean1, that evaluates to False when boolean1 is True and vice versa).

Wrap-up (10 mins)​

  • There is a useful order we should carry out when defining new functions, and we can make this concrete as a Design Recipe for functions:

    • Step 1. Type Annotation - Once we have decided we need to write a function, the first thing we should think about, assuming we were not told it, is what type of inputs the function should take and what type of output it should produce. While we introduced type annotations as a way to get better error messages, and indeed they are, this use as part of a design process is indeed much more important: it helps us organize our thoughts!

    • Step 2. Doc String - After figuring out the types of data the function operates over, it is worth writing down what the function should do! This is written in English, and it doesn't need to include how the function works (and usually shouldn't); the purpose of the explanation is so that someone can understand how to use the function, not how it works internally. While this serves as useful documentation, it is even more useful as part of a design process, since like type annotations, it helps us organize our thoughts; in particular, it makes us think a little bit more concretely about how the code should work -- i.e., how is the output constructed from the input.

    • Step 3. Tests: The third step is writing tests in a test function (test_...) right after the function. These tests (or examples) serve as the most concrete step of our design process, as it's the point where we think through concrete cases of inputs, and exactly the output they should produce. It may be, as we write them, that we realize we did not think of something in the earlier steps, which is fine -- we go back and fix those and then continue forward. In addition to forming a useful step in the design process, these tests will also be used to check that our function works as expected!

    • Step 4. Code -- The last step, which we should only do after the first three, is writing down the code. There is a strong tendency for students, especially beginners, to want to jump ahead to this step, but the clarity that comes from doing the earlier steps will make writing the code easier, to the point that it is faster to not skip ahead.