SchoolWhool
Quizzes and saved progress, freeSign in freeSign in

Python 5.6Written lesson8 minLesson 6 of 7 in Module 5

Testing with assert and pytest

Check that code does what it should, automatically and every time it changes.

Free sample. Everything in this module opens with a free account.Video in production.

Read the lesson: Testing with assert and pytest

01Regressions: checking answers automatically

In the previous lesson, you found and fixed a bug in the count_words function. Remember what was happening? The function was skipping the first word in every list. And you tracked that bug down using a list with just one word in it: "great".

The function is fixed now. But here is the problem: what is stopping someone from making the same mistake again? Maybe next month, you change the loop while adding a new feature, and without realizing it, you bring back that exact bug.

Right now, the only way to catch it is for someone to run the code, look at the output, and remember what the correct answer should be. That is not very reliable.

So what if the project could check its own answers? Every time you change the code, you run one command, and Python tells you whether everything still works as expected. That is exactly what automated tests are for, and that is what this lesson is about.

02assert

Before getting into testing tools, start with something Python already gives you: the assert statement. It is one of the simplest ways to check whether something is true.

You write assert, followed by a condition. If the condition is True, Python simply continues. But if the condition is False, Python raises an AssertionError. A statement like this is called an assertionKey termA statement that a condition is True; assert raises AssertionError when it is not.: you are stating what should be true at that point in the program.

A true condition

Try a simple example:

assert 2 + 2 == 4
Predict first

What do you think happens when you run this? Does Python print True, or does something else happen? Think it through before reading on.

I have a guess. Show meHide the answer

Nothing happens. Nothing is printed at all, and that is exactly what you want. Because the condition is true, the assertion passes, and Python continues without printing anything.

A false condition

Now change it:

assert 2 + 2 == 5
AssertionError

This time the condition is false, so Python raises an AssertionError.

That is the basic idea behind assertions. You tell Python what should be true, and if it is not, Python lets you know.

Asserting on count_words

Now use this with the count_words function. Remember the smallest example from the debugging lesson? You passed in a list containing just one word, "great", and the correct result was a dictionary containing "great" with a count of 1. Here is the fixed function:

def count_words(tokens, stop_words=frozenset()):
    counts = {}
    for token in tokens:
        if token not in stop_words:
            counts[token] = counts.get(token, 0) + 1
    return counts

Turn that example into an assertion:

assert count_words(["great"]) == {"great": 1}

With the fixed function, this passes. Nothing is printed.

But what if someone brings back the old bug? Remember, the old version started its loop at index 1, so it skipped the first word:

def count_words(tokens, stop_words=frozenset()):
    counts = {}
    for i in range(1, len(tokens)):
        token = tokens[i]
        if token not in stop_words:
            counts[token] = counts.get(token, 0) + 1
    return counts

With that version, count_words(["great"]) returns an empty dictionary, and the same assertion fails immediately:

assert count_words(["great"]) == {"great": 1}
AssertionError

So nobody needs to inspect the output and remember the right answer. You have written the expected result directly into the code. That is your first automated check.

From an assertion to a test

You can organize this a little better. Instead of scattering assertions throughout the project, put them inside functions. Create a function called test_count_words_counts_the_first_word, and put the assertion you just wrote inside it:

def test_count_words_counts_the_first_word():
    assert count_words(["great"]) == {"great": 1}

That is your first testKey termA function that runs code and asserts what it should give.: a function that checks whether one particular behavior works as expected.

Notice the function name. It starts with test_. That matters, because the testing tool you are about to use looks for functions with names like that. The rest of the name says exactly what is being checked: that count_words includes the first word. That is useful, because when a test fails, its name immediately gives you a clue about what went wrong.

03Test files and pytest discovery

Where should these tests go? You could keep them in the same file as your functions, but as the project grows, that would get messy. So create a separate folder called tests. It goes inside the project, alongside the src folder.

Inside tests, create a file called test_text_tools.py. It holds the tests for the text-processing functions. At the top, it imports tokenise, count_words and top_words from the review_stats package. Then come the test functions:

  • one checks that tokenise handles capital letters and punctuation correctly,
  • another checks what happens when you pass it an empty review,
  • then the first-word test for count_words,
  • a test for stop words,
  • and finally, two tests for top_words.
import pytest

from review_stats.text_tools import count_words, tokenise, top_words


def test_tokenise_removes_punctuation_and_case():
    assert tokenise("  Great kettle! Boils fast.  ") == ["great", "kettle", "boils", "fast"]


def test_tokenise_empty_review():
    assert tokenise("") == []


def test_count_words_counts_the_first_word():
    assert count_words(["great"]) == {"great": 1}


def test_count_words_leaves_out_stop_words():
    assert count_words(["it", "is", "great"], frozenset({"it", "is"})) == {"great": 1}


def test_top_words_keeps_ties_in_order():
    counts = {"tea": 3, "water": 2, "juice": 1, "coffee": 2}
    assert top_words(counts, 3) == [("tea", 3), ("water", 2), ("coffee", 2)]


def test_top_words_rejects_a_negative_n():
    with pytest.raises(ValueError):
        top_words({"tea": 3}, -1)

Some of these lines, like pytest.raises, are explained in the next section.

Running the tests with pytest

So now there are six tests. How do you run them all? Remember the development tools you installed when you set up the project? One of them was pytestKey termA tool that finds the test functions in a project and runs them all., and that is the tool that runs your tests. Open the terminal, in the project folder, and type:

uv run pytest

pytest looks through the project for test files. By default, it recognizes filenames that start with test_ or end with _test.py. Inside those files, it looks for functions whose names start with test_. Finding tests this way, by their names, is called test discovery. Then pytest runs those functions and checks whether each one passes or fails.

Here is what it prints. The first lines after the banner show your platform, your Python and pytest versions, and your project folder; they are left out here, because yours will differ. The time at the end will differ too.

============================= test session starts ==============================
collected 6 items

tests/test_text_tools.py ......                                          [100%]

============================== 6 passed in 0.02s ===============================

pytest tells you it collected six tests. Then you see the test file, followed by six dots. Each dot represents a test that passed. And at the bottom: 6 passed.

That is it. You just checked six different behaviors of your code with one command. And you can run that same command whenever you change something, instead of calling every function by hand and inspecting its output.

04Normal, edge and error cases

Here is an important question: what should you actually test? You cannot just write random assertions and hope you have covered everything.

Normal cases

A good place to start is the normal cases: the inputs your functions are expected to handle every day.

Think about tokenise. A normal review might contain capital letters, punctuation, and spaces at the beginning or end. So the first test uses a review like that:

tokenise("  Great kettle! Boils fast.  ")
['great', 'kettle', 'boils', 'fast']

The test checks that the result contains the words "great", "kettle", "boils" and "fast". That tells you the function handles the kind of text you expect it to receive.

Edge cases

But normal cases are not enough. You also need to think about edge cases. An edge caseKey termAn input at the limit of what the code handles: an empty list, one item, an empty string. is an input near the boundary of what a function is supposed to handle.

For example, what if the review is an empty string? tokenise should return an empty list:

tokenise("")
[]

What if there is only one word? That is another useful case. And remember, it was a single-word input that helped you catch the bug in count_words.

Ties

There is another edge case that is easy to overlook: ties. Say you are using top_words, and two words have exactly the same count. Which one should come first?

In this implementation, top_words relies on Python's stable sorting behavior, which you met in the lambdas lesson. A stable sort preserves the relative order of items that compare equally. So one of the tests checks the ordering when "water" and "coffee" have the same count:

counts = {"tea": 3, "water": 2, "juice": 1, "coffee": 2}
top_words(counts, 3)
[('tea', 3), ('water', 2), ('coffee', 2)]

"water" stays before "coffee", and the test makes sure that behavior does not accidentally change.

Error cases

Besides normal cases and edge cases, there is one more category to test: error cases. Remember what you did in the exceptions lesson? You changed top_words so that if someone passes a negative value for n, it raises a ValueError:

top_words({"tea": 3}, -1)
ValueError: n must be zero or more, got -1

That is the behavior you want. But how do you test an exception? If you simply call the function inside a test and it raises an error, would that not make the test fail?

pytest has a way to handle this, called pytest.raises. Write with pytest.raises(ValueError):, and inside that block, call top_words with n set to -1:

def test_top_words_rejects_a_negative_n():
    with pytest.raises(ValueError):
        top_words({"tea": 3}, -1)

This tells pytest that you expect the code inside the block to raise a ValueError. If it does, the test passes. If it does not raise that exception, the test fails.

So you are not only checking successful results. You are also checking that your functions reject invalid inputs in the way you intended, and that is an important part of testing.

05Reading a pytest failure report

Now watch these tests catch a bug. So far, all six pass. What happens if you bring back the mistake from the debugging lesson?

Open count_words and change the loop back to the buggy version, the one that uses range(1, len(tokens)). Remember, that skips the first token. Then run the tests again:

uv run pytest
Predict first

Before looking at the result: there are six tests. How many do you think will fail because of this change? Think it through before reading on.

I have a guess. Show meHide the answer

Just one. Here is the output, again without the platform, version and folder lines:

============================= test session starts ==============================
collected 6 items

tests/test_text_tools.py ..F...                                          [100%]

=================================== FAILURES ===================================
____________________ test_count_words_counts_the_first_word ____________________

    def test_count_words_counts_the_first_word():
>       assert count_words(["great"]) == {"great": 1}
E       AssertionError: assert {} == {'great': 1}
E
E         Right contains 1 more item:
E         {'great': 1}
E         Use -v to get more diff

tests/test_text_tools.py:15: AssertionError
=========================== short test summary info ============================
FAILED tests/test_text_tools.py::test_count_words_counts_the_first_word - Ass...
========================= 1 failed, 5 passed in 0.03s ==========================

pytest shows two dots, then an F, followed by three more dots. The F means a test failed. At the bottom, you see 1 failed, 5 passed. Five tests are still passing, but one has caught the bug.

What the report tells you

This report is where pytest becomes really useful.

First, it tells you which test failed: test_count_words_counts_the_first_word. That is already a pretty good clue.

Then it shows the line containing the failed assertion, marked with >, and the values that were compared. On one side is the actual result: an empty dictionary, {}. On the other side is the expected result: a dictionary containing 'great' with a count of 1.

So pytest shows you exactly why the test failed. You expected the first word to be counted, but the function returned nothing. And because the test has a descriptive name, you can immediately tell which behavior is broken.

06Regression tests, and running tests after every change

Now fix the loop again, going back to for token in tokens:, and run the tests one more time:

uv run pytest
============================= test session starts ==============================
collected 6 items

tests/test_text_tools.py ......                                          [100%]

============================== 6 passed in 0.01s ===============================

This time, all six tests pass, so the bug is gone.

That is how you use tests in day-to-day work. You make a change to the code, you run the tests, and if they all pass, you know that the behaviors those tests cover are still working.

Regression tests

The first-word test has a special name. It is called a regression test. A regression happens when something that used to work stops working after a change. A regression testKey termA test written from a bug, so that the bug can not come back unnoticed. is there to catch that.

In this case, you found a bug, fixed it, and wrote a test to make sure that exact mistake does not come back. If someone accidentally skips the first token again, the test fails, and you know straight away.

Make the test fail

Here is another good habit to develop. Do not just write a test, see it pass, and assume it is useful. Try making it fail.

If you have written a test to catch a particular problem, temporarily introduce that problem and check whether the test actually detects it. That is exactly what you just did: you brought back the old bug, ran the tests, and confirmed that the first-word test failed. Then you fixed the code again. So now you know the test checks the behavior you intended.

07Weak tests and test discovery rules

Here is something else worth understanding: just because a test passes does not mean it is a good test. Here are two examples, run against the buggy count_words function. With the bug, this is what it returns for two words:

count_words(["great", "kettle"])
{'kettle': 1}

The first word is missing.

A test with no assertion

The first weak test calls count_words with "great" and "kettle", but it does not contain any assertion. It just calls the function:

def test_count_words_runs():
    count_words(["great", "kettle"])
Predict first

What do you think happens when you run this test against the buggy function? Think it through before reading on.

I have a guess. Show meHide the answer

The function runs without raising an exception, so the test passes, even though the result is wrong. The test never checked the result. All it checked, indirectly, is that the function could run without an unhandled exception.

A test that compares the function with itself

The second example does have an assertion. But look at what it compares. It calls count_words with "great" and "kettle", and then compares that with the exact same function called on the exact same input:

def test_count_words_matches_itself():
    counts = count_words(["great", "kettle"])
    assert counts == count_words(["great", "kettle"])

It is comparing the function's answer with itself:

count_words(["great", "kettle"]) == count_words(["great", "kettle"])
True
Predict first

Will that test catch the bug? Think it through before reading on.

I have a guess. Show meHide the answer

No. Put both functions in a file called tests/test_weak.py and run just that file, with the bug still in place:

uv run pytest tests/test_weak.py
============================= test session starts ==============================
collected 2 items

tests/test_weak.py ..                                                    [100%]

============================== 2 passed in 0.01s ===============================

Both tests pass, even though count_words is still wrong. The first test does not check any output, and the second compares the wrong answer with the same wrong answer. Neither one can tell you that the first word is missing.

An independent expected result

That is the key lesson here: a useful test needs an expected result that you know independently of the code you are testing. For "great" and "kettle", the correct answer is {"great": 1, "kettle": 1}, and that is what the test should compare against. If the function returns something different, the test should fail.

When pytest finds no tests

There are two more things to watch out for. The first is test discovery. What happens if you rename the test file from test_text_tools.py to something like text_tools_checks.py, and run the tests again?

uv run pytest
============================= test session starts ==============================
collected 0 items

============================ no tests ran in 0.01s =============================

pytest does not find the six tests. It reports that no tests ran, because the file no longer matches the default naming pattern. So if you ever run pytest and it does not collect the tests you expected, check the filenames and function names first.

Passing tests are not a guarantee

The second thing to remember is that passing tests do not mean your code is guaranteed to be bug-free. They only tell you that the cases you actually tested behaved as expected. If there is a situation you never thought to test, a bug could still be hiding there.

That is why it is important to think about normal cases, edge cases and error cases. The more carefully you choose your test cases, the more useful your tests become.

08Recap and challenge: assert and pytest

You started with a simple question: how can a project check its own answers every time you change the code? The answer is automated testing.

  • Python's assert statement checks whether a condition is true. If it is, nothing happens. If it is not, Python raises an AssertionError.
  • A test is a function whose name starts with test_, with an assertion inside it. uv run pytest finds and runs all of those tests with one command.
  • Write tests for normal inputs, edge cases and invalid inputs. Where you expect an exception, use pytest.raises.
  • When you brought back the bug from the debugging lesson, one failing test immediately told you which behavior had broken.
  • A regression test is a test you keep to make sure an old bug does not come back.
  • A test is not useful just because it passes. It needs to compare the actual result with a correct, independently chosen expected result. Whenever possible, check that a new test actually fails when the behavior it protects is broken.

Your challenge

Remember the average_rating function from the debugging exercise in the previous lesson? Write three tests for it:

  1. One that checks a normal list of ratings.
  2. One that checks a list containing just one rating.
  3. One that checks what happens when the list is empty.

That last one is interesting, because before you write the test, you need to decide what the function is supposed to do. Should it return None? Should it return zero? Or should it raise an exception? There is no single answer until you define the function's intended behavior. So make that decision first, then write a test that checks it. Make sure all three tests run with uv run pytest.

Tests are great for checking what your code actually does when it runs. But some mistakes can be caught without running the program at all. Maybe you have imported a module you never use, or a function has a type hint saying it returns a tuple, but one of its branches returns None. Tools can often spot problems like these just by examining your code, and the next lesson shows you how to use them.