Unit Testing with pytest
On this page
Writing 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.pypytest 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 + bThis 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) == 5What This Test Does
- It imports the
addfunction. - 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 pytestTo get a more detailed view, use:
python -m pytest -vExample 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) == 0A 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) == 11Pytest 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"How This Works
@pytest.fixturetells pytest thatsample_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) == expectedHow 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 pytestVerbose output:
python -m pytest -vRun a single file:
python -m pytest test_discount.pyRun one test function:
python -m pytest test_discount.py::test_apply_discountRunning 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)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 -= amountThis 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 == 100What 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.