Python Programming from First Principles: Learn to Code, Think Computationally, and Solve Real-World Problems
PDFEPUB
BOOK 000 · CHAPTER 8 · Unit Testing with`pytest`

Unit Testing with pytest

On this pageWriting Your First Pytest TestRunning the TestAssertions in PytestExample: Testing a Multiplication FunctionA Failure ExampleUsing Fixtures: Preparing Test EnvironmentsExample: A Simple FixtureParametrized TestsExample: Testing Multiple Discount ScenariosRunning Pytest in Different EnvironmentsRunning from the TerminalRunning Inside Jupyter NotebookA More Realistic Testing ExampleApplication Code: bank.pyTest File: test_bank.py

pytest is a third-party framework that runs ordinary test functions using Python’s assert statement. We will use it to check results, inspect an intentional failure and reuse test data with fixtures. Install it in your active environment with python -m pip install pytest, then save the examples as the named files. Run python -m pytest to use that same interpreter.

Writing Your First Pytest Test

Just like any well-organized project, pytest relies on a clear naming convention to find tests automatically. Test files must be named:

test_*.py   or   *_test.py

and pytest looks for functions beginning with test_.

This simple convention means you rarely have to register tests manually.

Let’s build a small example from scratch.

Example: Testing a Simple Function

First, create a small module that we want to test. Save the following as calculator.py:

# calculator.py
def add(a, b):
    return a + b
Listing 412 — Writing Your First Pytest Test

This module contains one function,add(a, b), which returns the sum of its two arguments.

Next, we write a test file that imports this function and checks whether it behaves correctly.

Create a second file named test_calculator.py:

# test_calculator.py
from calculator import add

def test_add_numbers():
    assert add(2, 3) == 5
Listing 413 — Writing Your First Pytest Test

What This Test Does

  • It imports the add function.
  • It defines a test function test_add_numbers().
  • It uses a plain assert to check that add(2, 3) evaluates to 5.

If the assertion passes, pytest considers the test successful; if not, pytest shows you exactly why.

Running the Test

You can now run the test suite using the terminal. From the same directory as your test files, execute:

python -m pytest
Listing 414 — Running the Test

To get a more detailed view, use:

python -m pytest -v
Listing 415 — Running the Test

Example Output

test_calculator.py .

1 passed in ...s

Pytest automatically located the file, discovered the test function, executed it, and reported the result.

Notice what you did not have to write:

  • no test classes
  • no assertEqual()
  • no boilerplate

Pytest keeps your tests short and easy to read.

Assertions in Pytest

Unlike unittest, which requires calling specialized methods like assertEqual or assertTrue, pytest uses standard Python assert statements. This simplicity does not reduce clarity — in fact, when an assertion fails, pytest produces a rich explanation showing the values inside the expression.

Let’s look at a slightly larger test module.

Example: Testing a Multiplication Function

Save this file as test_math_ops.py:

# test_math_ops.py
def multiply(a, b):
    return a * b

def test_multiply_positive():
    assert multiply(3, 4) == 12

def test_multiply_negative():
    assert multiply(-2, 5) == -10

def test_multiply_zero():
    assert multiply(0, 99) == 0
Listing 416 — Example: Testing a Multiplication Function

A Failure Example

After defining multiply above, deliberately run this incorrect assertion by itself, or place it in a separate test function to inspect pytest’s failure report:

assert multiply(3, 4) == 11
Listing 417 — A Failure Example

Pytest provides a detailed explanation:

>       assert multiply(3, 4) == 11
E       assert 12 == 11
E        +  where 12 = multiply(3, 4)

This introspection — showing actual values, expected values, and the expression evaluated — is one of pytest’s greatest strengths.

Using Fixtures: Preparing Test Environments

As your tests grow, you will need to create repeatable setups: fake users, sample accounts, temporary files, or database connections. Pytest provides fixtures, reusable functions that prepare data or environments for your tests.

Fixtures are small, powerful helpers that keep tests clean and eliminate duplication.

Example: A Simple Fixture

Create a file named test_user.py:

# test_user.py
import pytest

@pytest.fixture
def sample_user():
    return {"name": "Alice", "role": "admin"}

def test_user_has_name(sample_user):
    assert sample_user["name"] == "Alice"

def test_user_role_is_admin(sample_user):
    assert sample_user["role"] == "admin"
Listing 418 — Example: A Simple Fixture

How This Works

  • @pytest.fixture tells pytest that sample_user() is a fixture.
  • Any test that needs this object simply names it as an argument.
  • Pytest automatically injects the prepared dictionary.

This keeps your test files readable and reduces repetitive setup code.

Parametrized Tests

Sometimes one test scenario isn’t enough. You want to run the same test logic over multiple inputs: different prices, various user roles, or multiple mathematical cases. Pytest includes a feature called parametrization, which makes this effortless.

Example: Testing Multiple Discount Scenarios

Save the following as test_discount.py:

# test_discount.py
import pytest

def apply_discount(price, percent):
    return price - (price * percent / 100)

@pytest.mark.parametrize(
    "price,percent,expected",
    [
        (100, 10, 90),
        (200, 50, 100),
        (50, 0, 50),
    ]
)
def test_apply_discount(price, percent, expected):
    assert apply_discount(price, percent) == expected
Listing 419 — Example: Testing Multiple Discount Scenarios

How Parametrization Works

  • The decorator lists tuples of (price, percent, expected) values.
  • Pytest generates three separate test cases.
  • Each row appears in the test output, making debugging easier.

This is especially powerful in numerical computing, data science, and machine-learning pipelines.

Running Pytest in Different Environments

Pytest works seamlessly both locally and inside notebooks.

Running from the Terminal

Run all tests:

python -m pytest
Listing 420 — Running from the Terminal

Verbose output:

python -m pytest -v
Listing 421 — Running from the Terminal

Run a single file:

python -m pytest test_discount.py
Listing 422 — Running from the Terminal

Run one test function:

python -m pytest test_discount.py::test_apply_discount
Listing 423 — Running from the Terminal

Running Inside Jupyter Notebook

Keep application code and tests in the named .py files. From a notebook, use its own interpreter to run those files:

import subprocess
import sys

subprocess.run([sys.executable, "-m", "pytest", "test_calculator.py", "-q"], check=True)
Listing 424 — Running Inside Jupyter Notebook

This runs pytest in a separate process; it does not discover unsaved test functions in the current notebook cell. The expected result is one passed test after the earlier files have been saved.

A More Realistic Testing Example

Let’s combine everything: application code, fixtures, assertions, and error handling.

Application Code: bank.py

Create a file named bank.py:

# bank.py
class Account:
    def __init__(self, name, balance=0):
        if balance < 0:
            raise ValueError("Initial balance cannot be negative")
        self.name = name
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self.balance += amount
        
    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
Listing 425 — Application Code: bank.py

This teaching model accepts numeric amounts, requires a nonnegative starting balance and positive deposits and withdrawals, and rejects withdrawals above the balance. We use integer amounts to focus on those rules.

Test File: test_bank.py

Now create test_bank.py:

# test_bank.py
import pytest
from bank import Account

@pytest.fixture
def account():
    return Account("Alice", balance=100)

def test_initial_balance(account):
    assert account.balance == 100

def test_deposit(account):
    account.deposit(50)
    assert account.balance == 150

def test_withdraw(account):
    account.withdraw(30)
    assert account.balance == 70

def test_withdraw_insufficient_funds(account):
    with pytest.raises(ValueError):
        account.withdraw(200)
    assert account.balance == 100

def test_negative_initial_balance():
    with pytest.raises(ValueError):
        Account("Alice", balance=-1)

@pytest.mark.parametrize("amount", [0, -5])
def test_invalid_deposit_preserves_balance(account, amount):
    with pytest.raises(ValueError):
        account.deposit(amount)
    assert account.balance == 100

@pytest.mark.parametrize("amount", [0, -5])
def test_invalid_withdrawal_preserves_balance(account, amount):
    with pytest.raises(ValueError):
        account.withdraw(amount)
    assert account.balance == 100
Listing 426 — Test File: test_bank.py

What These Tests Cover - The fixture creates a fresh account for every test. - The tests check normal behavior (deposit, withdraw) and error handling. - The additional cases reject invalid amounts and check that rejected operations leave the balance unchanged.

Example Output

test_bank.py .........

9 passed in ...s

A clean, readable, and expressive test suite — exactly what pytest is designed for.

Fixtures provide fresh data, parametrization checks several inputs with one test function, and assertion reports explain a mismatch. These tools help you investigate behavior; passing tests cover only the expectations and inputs you actually checked.

With error handling and testing in place, we can examine how Python coordinates work with threads, processes and asynchronous I/O.