Writing Simple Tests to Check Your Functions
Learning Objectives
- You can write a small test that calls a function with known inputs and checks the result.
- You can use
assertto express what you expect. - You can apply the edge-case checklist from the previous part to your own functions.
- You can explain why testing functions is easier than testing whole scripts.
Once a function does one thing, you can ask whether it does that thing correctly. This chapter introduces the simplest possible way to ask: write a piece of code that calls the function with a known input and checks that the result is what you expected. That piece of code is called a test.
Testing in industry is a large topic with frameworks, conventions, and an entire vocabulary of its own. This chapter teaches only the core idea, in the simplest form Python provides.
A Test Checks Behavior Against Expectations
A test is a piece of code that:
- Calls a function with a known input.
- Compares the result to what you expected.
- Tells you if those two things are different.
That is the whole concept. Everything more elaborate than that — test runners, fixtures, mocks, coverage reports — is a refinement of the same idea. You will meet some of those in later programming courses. For this course, the simple version is enough.
The Python tool for the third step is the assert statement.
Expressing Expectations with assert
The statement assert says “this expression should be true”. If the expression is true, nothing happens — the program continues normally. If the expression is false, Python raises an AssertionError and stops.
assert 2 + 2 == 4 # nothing happensassert 2 + 2 == 5 # AssertionError: program stops hereThere is no output if things are going well. However, if a test fails, there is immediate feedback about what went wrong.
You can attach a message that describes what was being checked, so a failure tells you what went wrong:
assert 2 + 2 == 5, "addition is broken"When this fails, Python prints AssertionError: addition is broken, which is a lot more useful than a bare AssertionError.
Assert behavior
0 / 5 points
What happens when an assert condition evaluates to True?
A First Test
Take the double function from the previous chapter:
def double(n): return n * 2
assert double(5) == 10assert double(0) == 0assert double(-3) == -6print("double passes.")When you run this program, it prints double passes. and ends. The three assertions did nothing visible, because they all evaluated to True. The cheerful print at the end is your confirmation that all the assertions ran without any of them failing.
Now suppose you make a typo in the function:
def double(n): return n + 2 # bug: should be n * 2Running the same tests now produces:
AssertionErrorat the first failing line (assert double(5) == 10). Python stops there — it does not even reach the other assertions or the print. You know immediately that double(5) is not 10. From there you can investigate. In this case, the bug is in the function; in other cases, the bug might be in the test. Either way, you have a concrete starting point.
Write tests for triple
0 / 10 points
Define a function named triple(n) that returns n * 3.
Then write at least three assert statements below the function:
- one positive case,
- one zero case,
- and one negative case.
After the assertions, print triple passes.
No user input is needed. If every assertion passes, the program should produce this output when it is run:
Required output when all tests pass:
triple passes.What Makes a Good Set of Test Inputs
A test is only as useful as the cases it checks. Testing double(5) == 10 tells you the function works on 5. It tells you nothing about how it handles 0, negative numbers, or very large numbers.
Here is where the Verifying Behavior chapter from the previous part is useful. There, you learned to look for the cases that AI-generated code is likely to miss — the empty input, the single-element input, the boundary value, zero, negative numbers, all-equal inputs, and unusual formats. Those same cases are what you write tests for in your own functions. The list does not change; only the lens does. The shift is from finding bugs in someone else’s code to preventing bugs in your own.
For any function you write, the question is which rows of that checklist apply. A function that takes a list cares about the empty and single-element cases. A function with a threshold cares about boundary values. A function that takes a number cares about zero and negative inputs. The checklist is the prompt; which rows you act on depends on the function.
Testing a List Function
Here is total_minutes from the previous chapter, with a set of tests:
def total_minutes(minutes_list): total = 0 for m in minutes_list: total += m return total
assert total_minutes([]) == 0assert total_minutes([45]) == 45assert total_minutes([45, 30, 60]) == 135assert total_minutes([0, 0, 0]) == 0print("total_minutes passes.")Four cases, one per concern:
- The empty case (
[]) — exactly the case the checklist demanded in the previous part. - The single-element case (
[45]). - The obvious case (
[45, 30, 60]). - The all-zero case, which catches a class of accumulator bugs where the starting value was wrong.
Once these tests pass, you know something specific. You know total_minutes handles the empty list — it does not crash, and it returns the right answer. You know it sums correctly on the obvious case. You know zeros do not throw it off. The next time you change the function, running the tests tells you immediately whether you broke anything.
Tests are fast, automatic, and check exactly the cases you decided mattered. You can run the tests after every change to the code, and if they all pass, you can be confident that your change did not break anything.
Test total reading minutes
0 / 10 points
Define a function named total_reading_minutes(minutes_list).
The function must return the total of all values in the list and return 0 for an empty list. Do not print inside the function.
Add at least three assertions below the function, including the empty-list case and a single-element case.
No user input is needed. The tests will load your program and call the function directly.
Example calls and return values:
total_reading_minutes([]) -> 0total_reading_minutes([20]) -> 20total_reading_minutes([10, 15, 5]) -> 30Testing a Classifier
The classify_session function — a small if/elif/else returning a label — is a particularly good target for tests, because it has obvious edge cases at every boundary.
def classify_session(minutes): if minutes < 15: return "short" elif minutes <= 90: return "solid" else: return "long"
# Obvious casesassert classify_session(10) == "short"assert classify_session(45) == "solid"assert classify_session(120) == "long"
# Boundary values -- exactly at each thresholdassert classify_session(14) == "short"assert classify_session(15) == "solid"assert classify_session(90) == "solid"assert classify_session(91) == "long"
print("classify_session passes.")The boundary tests are doing real work. Without them, the obvious cases would pass even if the function had < where it should have <=. With them, any off-by-one in the comparison operators is caught immediately.
Notice what happened here. Writing the tests forced you to decide what should happen at each boundary. Is
15short or solid? Is90solid or long? You cannot write the test without making those choices explicit, which means you cannot leave them implicit in the function. This is one of the under-appreciated benefits of testing: it forces design decisions into the light.
Test a quick review classifier
0 / 10 points
Define a function named is_quick_review(minutes).
The function must:
- return
Truewhenminutesis less than 20, - return
Falsewhenminutesis 20 or more, - use
return, notprint, - and include assertions for at least one obvious case and the boundary values 19 and 20.
No user input is needed. Do not print from inside the function. The tests will load your program and call the function directly.
Example calls and return values:
is_quick_review(10) -> Trueis_quick_review(19) -> Trueis_quick_review(20) -> FalseWhy Tests on Functions Are Tractable
Testing a long script that does many things is hard. If the script’s behavior depends on user input, the order of operations, and the state of several variables at once, there are many ways for it to be wrong.
Testing a short function, on the other hand, is easy. The function does one thing. Its behavior depends only on its arguments. You can list the inputs you care about, write down what the result should be for each, and assert it.
This is one of the strongest practical arguments for breaking a program into functions. The functions become tractable, in a way the long script was not. The study log was untestable as a whole. The same logic, broken into total_minutes, longest_session, and classify_session, becomes testable one piece at a time.
This effect compounds. The more you split a program into small named pieces, the more of it you can test. The more you can test, the more confidently you can change the code without breaking it. The more confidently you can change the code, the easier it is to clean up when it gets messy. Each habit makes the next one easier.
Explain why pure functions are easier to test
0 / 10 points
Compare these two pieces of code:
def total_pages_read(pages_list): total = 0 for pages in pages_list: total += pages return total
def ask_pages(): pages = int(input("Pages: ")) print(pages)Explain why total_pages_read is easier to test with assert statements than ask_pages. In 3-6 sentences, compare their inputs, outputs, and predictability.
What Tests Can and Can’t Do
Tests have real limits, and it is worth being honest about them.
Tests can only check the cases you write. A function that passes all its tests can still be wrong on a case nobody thought of. The checklist helps you think of the obvious classes of cases, but there will always be cases outside the checklist that you might miss. Testing reduces the number of bugs; it does not eliminate them.
Tests check behavior, not intent. If you write a function that is supposed to find the longest session but accidentally returns the shortest, and you write tests that check what your function actually does rather than what it was supposed to do, the tests will pass. Tests are only useful when the assertions reflect the intent, not the implementation.
Tests for input/output functions are harder. A function that uses input(...) cannot be tested with a simple assert get_session() == ..., because the function waits for the user. Industrial code uses techniques called mocking to test these functions. This course does not teach mocking. The practical answer in this part is: write tests for the pure helper functions, and for the input/output parts, trust them by running the program manually.
Limits of tests
0 / 5 points
Which statement about passing tests is most accurate?
Check Your Understanding
- What does
assertdo when the expression after it isTrue? What does it do when the expression isFalse? - Look at the test set for
total_minutesabove. If someone accidentally initializedtotalastotal = 1, which assertion would isolate this bug most clearly? - Why does writing tests force design decisions into the light?
- Write a set of tests for the following function. Include at least one boundary test and one edge case.
def is_short_session(minutes): return minutes < 15- What is one thing that testing can never catch, even if all your tests pass?
AI Study Aid
Create a chapter diagram
We're looking into to what extent AI-generated diagrams could help with studying.
Use this study aid to generate an AI-generated visual summary of the material you just studied.
Each diagram style emphasizes the content in a different way, so you can choose the focus
that feels most useful.
Using the diagram generator is voluntary and does not affect your course progress. We encourage
you to try it out and see if it helps, but it's totally up to you! Your answers help us understand
how to make better study aids in the future.