Python Basics series — Part 9 (final part). This post wraps up the series that began with Part 1: What Every Beginner Should Know and continued with variables and data types, lists and dictionaries, if statements, user input and while loops, functions, classes, and files and exceptions. This last part covers testing your code.

How do you know your code works? So far in this series, you’ve checked by running it and looking at the output. That’s fine for a ten-line script. But as programs grow, every change risks breaking something that used to work — and checking everything by hand after every change quickly becomes impossible.

Automated tests solve this. A test is a small piece of code that runs part of your program with known inputs and checks that the result is what you expect. Once you’ve written a test, it can check your code again in a fraction of a second, as often as you like. Change a function, run your tests, and you’ll know immediately whether you broke anything.

In this final post of the series, you’ll learn to write tests with pytest, the most popular testing tool in Python. You’ll test functions, exceptions, classes, and code that works with files — all using examples from earlier parts of the series.

The assert Statement

Tests are built on Python’s assert statement. It checks that something is true. If it is, nothing happens. If it isn’t, Python raises an AssertionError:

total = 5 * 340

assert total == 1700        # True: nothing happens
assert total == 2000        # False: AssertionError

An assert is a statement of fact about your code: “at this point, total must be 1700.” A test is simply a function full of these statements, run by a tool that reports which ones held up.

Installing pytest

Python includes a testing module called unittest, but most Python developers use pytest, which is simpler to write and gives much more helpful output. It’s a separate package, so install it in your project’s virtual environment:

python3 -m venv .venv
source .venv/bin/activate      # On Windows: .venv\Scripts\activate
pip install pytest

Using uv? Run uv add --dev pytest instead. See Python uv: A Faster Way to Manage Projects and Dependencies.

Your First Test

Let’s test a small function. Save this in a file called names.py:

def format_name(first, last):
    """Return a neatly formatted full name."""
    full_name = f"{first} {last}"
    return full_name.title()

Now create a second file in the same folder, called test_names.py:

from names import format_name


def test_first_last_name():
    """Do names like 'maria lopez' work?"""
    formatted = format_name("maria", "lopez")
    assert formatted == "Maria Lopez"

The test imports the function, calls it with known input, and asserts what the result should be. Two naming rules make it work:

  • The test file name must start with test_ (or end with _test.py).
  • Each test function name must start with test_.

pytest uses those names to find your tests automatically. Run it from the folder with your files:

pytest
============================= test session starts ==============================
platform darwin -- Python 3.12.9, pytest-9.1.1, pluggy-1.7.0
rootdir: /Users/you/python_testing
collected 1 item

test_names.py .                                                          [100%]

============================== 1 passed in 0.01s ===============================

The first few lines show your Python and pytest versions, so they’ll look slightly different on your computer. The important parts are collected 1 item (pytest found one test), the dot after test_names.py (one test passed), and the summary at the bottom: 1 passed.

When a Test Fails

Tests are most valuable when they catch a mistake. Suppose you decide format_name() should also handle middle names, and change it like this:

def format_name(first, middle, last):
    """Return a neatly formatted full name."""
    full_name = f"{first} {middle} {last}"
    return full_name.title()

Middle names work now — but run the tests again:

============================= test session starts ==============================
platform darwin -- Python 3.12.9, pytest-9.1.1, pluggy-1.7.0
rootdir: /Users/you/python_testing
collected 1 item

test_names.py F                                                          [100%]

=================================== FAILURES ===================================
_____________________________ test_first_last_name _____________________________

    def test_first_last_name():
        """Do names like 'maria lopez' work?"""
>       formatted = format_name("maria", "lopez")
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E       TypeError: format_name() missing 1 required positional argument: 'last'

test_names.py:6: TypeError
=========================== short test summary info ============================
FAILED test_names.py::test_first_last_name - TypeError: format_name() missing...
============================== 1 failed in 0.03s ===============================

The F means a test failed. pytest then shows exactly what went wrong: the name of the failing test, the line marked with > where it failed, and the error on the lines starting with E. You’ll recognize this TypeError from Part 6: the function now requires three arguments, so every existing call with two arguments is broken.

Without the test, you might not have noticed until a user did. This is exactly what tests are for: catching the side effects of a change.

Fix the Code, Not the Test

When a test fails, your first instinct should be to fix the code, not to change the test so it passes. The test describes behavior you decided was correct; the code no longer matches it. Here, the fix is to make the middle name optional with a default value, so two-part names keep working:

def format_name(first, last, middle=""):
    """Return a neatly formatted full name."""
    if middle:
        full_name = f"{first} {middle} {last}"
    else:
        full_name = f"{first} {last}"
    return full_name.title()

Now add a second test for the new feature in test_names.py:

def test_first_last_middle_name():
    """Do names like 'maria elena lopez' work?"""
    formatted = format_name("maria", "lopez", "elena")
    assert formatted == "Maria Elena Lopez"

Run pytest again:

test_names.py ..                                                         [100%]

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

Two dots, two passing tests. The old behavior still works, and the new behavior is checked too.

When the Value Is Wrong

The other common kind of failure is an assert that doesn’t hold. Suppose you write a test for the quote calculator from earlier in the series, but you get the expected rush price wrong:

from quote import calculate_quote


def test_rush_fee():
    assert calculate_quote(5, rush=True) == 2000

pytest shows both sides of the comparison:

    def test_rush_fee():
>       assert calculate_quote(5, rush=True) == 2000
E       assert 2125.0 == 2000
E        +  where 2125.0 = calculate_quote(5, rush=True)

The function returned 2125.0, and the test expected 2000. Now you have to decide which one is wrong. Five pages at $1,700 plus a 25% rush fee is $2,125 — so in this case, the test had the mistake. When a failure points to a wrong expectation, fixing the test is the right call. The key is to decide deliberately, never just to make the failure go away.

Testing More Situations

Good tests check more than the obvious case. Here’s the quote function we’ll test, saved as quote.py:

HOURLY_RATE = 85
HOURS_PER_PAGE = 4


def calculate_quote(pages, rush=False):
    """Return the price for a website with the given number of pages."""
    if pages < 1:
        raise ValueError("A project needs at least 1 page.")
    total = pages * HOURS_PER_PAGE * HOURLY_RATE
    if rush:
        total = total * 1.25
    return total

And here’s test_quote.py, which checks a normal case, an optional argument, and an error case:

import pytest

from quote import calculate_quote


def test_one_page():
    assert calculate_quote(1) == 340


def test_rush_adds_25_percent():
    assert calculate_quote(5, rush=True) == 2125


def test_zero_pages_raises_error():
    with pytest.raises(ValueError):
        calculate_quote(0)

Testing That an Exception Is Raised

In Part 8, you learned to raise exceptions when a function gets bad input. That behavior deserves a test, too. pytest.raises() passes only if the code inside the with block raises the exception you name. If calculate_quote(0) ever stopped raising ValueError, this test would fail.

Testing Decimal Numbers with pytest.approx

As you saw in Part 2, floats aren’t perfectly precise. That can make a correct calculation fail an exact comparison:

import pytest


def add_tax(price, rate=0.0775):
    return price * (1 + rate)


def test_add_tax():
    assert add_tax(19.99) == pytest.approx(21.539225)

19.99 * 1.0775 should be 21.539225, but Python actually calculates 21.539224999999995. An exact == comparison fails, even though the function is correct. pytest.approx() compares the numbers with a tiny tolerance, so the test passes. Use it whenever you compare floats.

Running One Test with Many Inputs

When you want to check the same thing with several different inputs, you don’t need to copy the test. @pytest.mark.parametrize runs one test function once for each set of values you give it:

@pytest.mark.parametrize("pages, expected", [
    (1, 340),
    (5, 1700),
    (10, 3400),
])
def test_prices_for_page_counts(pages, expected):
    assert calculate_quote(pages) == expected

The first argument names the parameters, and the list gives one tuple of values per run. pytest reports each one as a separate test, so if one input fails, you’ll know exactly which.

Testing Classes with Fixtures

To test a class from Part 7, you need an object to test. Here’s a simplified version of the Project class, saved as project.py:

class Project:
    """A website project with a list of pages."""

    def __init__(self, client):
        self.client = client
        self.pages = []

    def add_page(self, name):
        """Add a page, ignoring blanks and duplicates."""
        name = name.strip().title()
        if name and name not in self.pages:
            self.pages.append(name)

    def page_count(self):
        return len(self.pages)

Several tests need the same starting object. Instead of building it in every test, write a fixture: a function marked with @pytest.fixture that creates what your tests need. Any test that lists the fixture’s name as a parameter receives a fresh copy of it:

import pytest

from project import Project


@pytest.fixture
def project():
    """A project with two pages already added."""
    project = Project("Bright Bakery")
    project.add_page("home")
    project.add_page("menu")
    return project


def test_pages_are_title_case(project):
    assert project.pages == ["Home", "Menu"]


def test_duplicate_pages_are_ignored(project):
    project.add_page("Home")
    assert project.page_count() == 2


def test_blank_pages_are_ignored(project):
    project.add_page("   ")
    assert project.page_count() == 2

Each test gets its own new project, so one test changing its project can never affect another. Tests that don’t depend on each other are much easier to trust.

Testing Code That Uses Files

Code that reads and writes files is tricky to test: you don’t want tests creating files all over your project, or depending on files left over from the last run. pytest has a built-in fixture for this, tmp_path, which gives each test its own empty temporary folder as a Path object (from Part 8).

Here are two functions that save and load clients, saved as clients.py. Notice that they take the file path as a parameter — that’s what makes them easy to test:

import json


def save_clients(clients, path):
    """Save a list of clients to a JSON file."""
    with open(path, "w", encoding="utf-8") as file:
        json.dump(clients, file, indent=2)


def load_clients(path):
    """Return the saved clients, or an empty list if the file doesn't exist."""
    try:
        with open(path, encoding="utf-8") as file:
            return json.load(file)
    except FileNotFoundError:
        return []

And the tests, in test_clients.py:

from clients import load_clients, save_clients


def test_save_and_load(tmp_path):
    path = tmp_path / "clients.json"
    clients = [{"name": "Bright Bakery", "budget": 2500}]

    save_clients(clients, path)

    assert load_clients(path) == clients


def test_missing_file_returns_empty_list(tmp_path):
    assert load_clients(tmp_path / "nope.json") == []

The first test checks a round trip: what you save is exactly what you load back. The second checks the error handling from Part 8. pytest creates the temporary folders and cleans them up for you.

Running Your Tests

With all of these files in one folder, a single pytest command runs every test. Add -v (verbose) to see each test’s name:

pytest -v
collected 11 items

test_clients.py::test_save_and_load PASSED                               [  9%]
test_clients.py::test_missing_file_returns_empty_list PASSED             [ 18%]
test_project.py::test_pages_are_title_case PASSED                        [ 27%]
test_project.py::test_duplicate_pages_are_ignored PASSED                 [ 36%]
test_project.py::test_blank_pages_are_ignored PASSED                     [ 45%]
test_quote.py::test_one_page PASSED                                      [ 54%]
test_quote.py::test_rush_adds_25_percent PASSED                          [ 63%]
test_quote.py::test_zero_pages_raises_error PASSED                       [ 72%]
test_quote.py::test_prices_for_page_counts[1-340] PASSED                 [ 81%]
test_quote.py::test_prices_for_page_counts[5-1700] PASSED                [ 90%]
test_quote.py::test_prices_for_page_counts[10-3400] PASSED               [100%]

============================== 11 passed in 0.04s ==============================

Notice that the parametrized test appears three times, once for each set of inputs. A few other useful options:

pytest test_quote.py                  # run only the tests in one file
pytest -k rush                        # run only tests with "rush" in their name
pytest -x                             # stop at the first failure

What Should You Test?

You don’t need to test every line. Focus your tests where they give the most confidence:

  • The main purpose of each function. If calculate_quote() gets prices wrong, nothing else matters.
  • Edge cases. Zero, one, empty lists, empty strings, the largest and smallest allowed values. Bugs love boundaries.
  • Error handling. Check that bad input raises the exception you expect, as in Part 8.
  • Bugs you’ve fixed. When you find a bug, write a test that reproduces it before fixing it. The test proves the fix works and makes sure the bug never quietly returns.

A few habits keep tests useful:

  • Test one thing per test, and give each test a name that says what it checks, like test_duplicate_pages_are_ignored. When it fails, the name alone tells you what broke.
  • Keep tests independent. Each test should set up what it needs, often with a fixture, and never rely on another test running first.
  • Test return values, not printed output. This is one more reason to have functions return results, as recommended in Part 6 — returned values are easy to check with assert.
  • Run your tests often, especially before and after every change.

A Quick Look at unittest

You’ll also see tests written with Python’s built-in unittest module, especially in older projects. It doesn’t need to be installed, but it’s more verbose: tests are methods inside a class, and you use special assertion methods instead of assert:

import unittest

from names import format_name


class TestFormatName(unittest.TestCase):
    def test_first_last_name(self):
        self.assertEqual(format_name("maria", "lopez"), "Maria Lopez")


if __name__ == "__main__":
    unittest.main()

Good news: pytest runs unittest tests too, so you can use pytest even in projects that started with unittest.

Common Beginner Mistakes

Naming files or functions so pytest can’t find them. A file called names_tests.py or a function called check_name() is silently ignored. Start both with test_. If pytest says collected 0 items, check your names.

Moving tests into a separate folder without setting it up. If you move your tests into a tests/ folder and pytest suddenly reports ModuleNotFoundError: No module named 'names', it can no longer find the code it’s testing. While you’re learning, keep each test file in the same folder as the code it tests, as in this post. When you’re ready for a tests/ folder, look up pytest’s guide to project layout.

Changing the test just to make it pass. A failing test is information. Decide whether the code or the expectation is wrong before you change anything.

Testing too much in one test. If a single test checks ten different things, a failure doesn’t tell you which one broke. Split it into smaller tests.

Comparing floats with ==. Use pytest.approx() for any result that involves decimal math.

Testing functions that only print. You can’t easily assert on printed output. Have functions return values, and test those.

Writing tests and then never running them. Tests only help if you run them. Make pytest part of your routine before you share or deploy code.

Practice Exercises

  1. Write a function is_valid_email(email) (from the exercises in Part 6) and three tests for it: one valid email, one without an @, and one without a dot after the @.
  2. Write a parametrized test for a celsius_to_fahrenheit() function using at least four temperatures, including 0 and -40.
  3. Write a function average(numbers) that raises a ValueError for an empty list, and test both a normal average and the exception.
  4. Write a BankAccount class (from the exercises in Part 7), a fixture that creates an account with a starting balance, and tests for deposits, withdrawals, and refusing to overdraw.
  5. Write a function that saves a list of tasks to a text file, one per line, and a test that uses tmp_path to check that saving and loading gives back the same list.
  6. Pick a program you wrote during this series, find one bug or edge case it doesn’t handle, write a failing test for it, and then fix the code until the test passes.

Conclusion

Tests turn “I think it works” into “I know it works.” With pytest, a test is just a function whose name starts with test_ and that uses assert to state what should be true. pytest finds and runs your tests, and when one fails, it shows you exactly where and why. pytest.raises() checks error handling, pytest.approx() handles decimals, parametrize runs one test with many inputs, fixtures give each test a fresh starting point, and tmp_path makes file code safe to test.

Wrapping Up the Python Basics Series

This post completes the Python Basics series. Over nine parts, you’ve learned how to:

That’s a solid foundation. From here, the best way to keep improving is to build things. A few good next steps:

Pick something small that would make your own work easier, write it, test it, and keep going. Thanks for following the series!

Python Basics series: Part 1: What Every Beginner Should Know · Part 2: Variables and Data Types · Part 3: Lists and Dictionaries · Part 4: If Statement · Part 5: User Input and While Loops · Part 6: Functions · Part 7: Classes · Part 8: Files and Exceptions · Part 9: Testing Your Code