Beginner
Introduction
Writing unit tests is one of the most important practices in modern software development, yet many beginners skip it thinking it slows them down. The truth is the opposite — testing saves time by catching bugs early, making refactoring safer, and helping you understand your own code better. In this guide, you will learn how pytest makes testing so simple that you will actually enjoy writing tests.
If you are worried that testing is complex or requires special knowledge, put that fear to rest. pytest is designed to be intuitive and beginner-friendly. You will write test functions that look almost identical to regular Python functions, using plain assertions instead of cryptic methods. No need to memorize a dozen different assertion types or inherit from test base classes.
In this tutorial, we will start with a quick working example so you see testing in action immediately. Then we will explore what pytest is, install it, write various types of tests, and work through a complete real-world example. By the end, you will understand how to test your Python code effectively and confidently.
Quick Example
Let us jump straight into a working pytest test. This minimal example shows how simple testing can be:
# test_quick.py
def add(a, b):
return a + b
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -2) == -3
def test_add_mixed():
assert add(5, -2) == 3
Output:
$ pytest test_quick.py
============================= test session starts ==============================
collected 3 items
test_quick.py::test_add_positive_numbers PASSED [ 33%]
test_quick.py::test_add_negative_numbers PASSED [ 66%]
test_quick.py::test_add_mixed PASSED [100%]
============================== 3 passed in 0.02s ===============================
That is it! Three tests passing. Notice there is no special TestCase class to inherit from, no setUp methods, and no assertEquals calls. Just plain functions and simple assertions. This simplicity is what makes pytest so powerful.
What is pytest and Why Use It?
pytest is a testing framework that makes writing and running tests in Python delightfully simple. It is now the de facto standard for Python testing, used by companies like Mozilla, Stripe, and countless open-source projects. pytest shines because it reduces boilerplate, makes test discovery automatic, and provides powerful features like fixtures and parametrization built in.
Python comes with a built-in testing module called unittest, which is powerful but verbose. It requires you to create classes, inherit from TestCase, and use assertion methods like assertEqual. By contrast, pytest uses simple functions and the plain assert statement. Here is a comparison:
| Feature | unittest | pytest |
|---|---|---|
| Test discovery | Requires naming convention | Automatic (test_* or Test*) |
| Assertions | assertEqual, assertTrue, etc. | Plain assert statement |
| Class requirement | Must inherit from TestCase | Simple functions |
| Setup/teardown | setUp/tearDown methods | Fixtures (more flexible) |
| Parametrization | Use subTest or external tools | @pytest.mark.parametrize |
| Learning curve | Moderate | Gentle |
For beginners, pytest removes friction. You write less boilerplate, learn fewer concepts, and get productive faster. For experienced developers, pytest provides industrial-strength capabilities through fixtures, parametrization, and its plugin ecosystem.
Installing pytest
Before we write any tests, we need to install pytest. Open your terminal and run:
# install_pytest.sh
pip install pytest
Output:
Collecting pytest
Downloading pytest-7.4.0-py3-none-any.whl (298 kB)
Successfully installed pytest-7.4.0
Verify the installation:
# verify.sh
pytest --version
Output:
pytest 7.4.0
You are ready to start writing tests. If you are using a virtual environment (recommended), activate it before running pip install.
Writing Basic Tests
Test files in pytest must be named test_*.py or *_test.py so pytest can discover them automatically. A basic test is simply a function starting with test_ that uses assert statements:
# test_calculator.py
def multiply(a, b):
return a * b
def test_multiply_basic():
result = multiply(3, 4)
assert result == 12
def test_multiply_by_zero():
result = multiply(5, 0)
assert result == 0
def test_multiply_negatives():
result = multiply(-2, -3)
assert result == 6
Output:
$ pytest test_calculator.py -v
======================== test session starts ==========================
collected 3 items
test_calculator.py::test_multiply_basic PASSED [ 33%]
test_calculator.py::test_multiply_by_zero PASSED [ 66%]
test_calculator.py::test_multiply_negatives PASSED [100%]
======================== 3 passed in 0.01s ===========================
The -v flag shows verbose output with each test listed individually. Each test is independent — they run in any order and share no state.
Mastering Assertions
The assert statement is the heart of testing. Here are the most common patterns:
# test_assertions.py
def test_equality():
assert 5 == 5
assert "hello" == "hello"
assert [1, 2, 3] == [1, 2, 3]
def test_truthiness():
assert True
assert not False
assert [1, 2, 3] # non-empty list is truthy
assert not [] # empty list is falsy
def test_membership():
assert 2 in [1, 2, 3]
assert "key" in {"key": "value"}
def test_type_checking():
assert isinstance(5, int)
assert isinstance("hello", str)
Output:
$ pytest test_assertions.py -v
======================== test session starts ==========================
test_assertions.py::test_equality PASSED [ 25%]
test_assertions.py::test_truthiness PASSED [ 50%]
test_assertions.py::test_membership PASSED [ 75%]
test_assertions.py::test_type_checking PASSED [100%]
======================== 4 passed in 0.01s ===========================
When an assertion fails, pytest provides detailed error messages showing exactly what went wrong, including the values on both sides of the comparison.
Using Fixtures
Fixtures are reusable pieces of test setup. Instead of repeating setup code in every test, define it once and inject it where needed. Think of fixtures as the pytest way of doing setup and teardown:
# test_database.py
import pytest
class Database:
def __init__(self):
self.connected = False
self.data = {}
def connect(self):
self.connected = True
def disconnect(self):
self.connected = False
def store(self, key, value):
if not self.connected:
raise RuntimeError("Not connected")
self.data[key] = value
def retrieve(self, key):
if not self.connected:
raise RuntimeError("Not connected")
return self.data.get(key)
@pytest.fixture
def db():
database = Database()
database.connect()
yield database # code after yield runs as teardown
database.disconnect()
def test_store_and_retrieve(db):
db.store("name", "Alice")
assert db.retrieve("name") == "Alice"
def test_store_overwrites(db):
db.store("age", 25)
db.store("age", 26)
assert db.retrieve("age") == 26
def test_retrieve_nonexistent(db):
assert db.retrieve("missing") is None
Output:
$ pytest test_database.py -v
======================== test session starts ==========================
test_database.py::test_store_and_retrieve PASSED [ 33%]
test_database.py::test_store_overwrites PASSED [ 66%]
test_database.py::test_retrieve_nonexistent PASSED [100%]
======================== 3 passed in 0.01s ===========================
The fixture uses yield instead of return. Code before yield runs before the test (setup), code after yield runs after (teardown). Each test gets a fresh database connection, so tests never interfere with each other.
Parametrized Tests
Parametrization runs the same test with different input values — DRY in action:
# test_parametrize.py
import pytest
def is_even(num):
return num % 2 == 0
@pytest.mark.parametrize("number,expected", [
(2, True),
(4, True),
(1, False),
(3, False),
(0, True),
(-2, True),
])
def test_is_even(number, expected):
assert is_even(number) == expected
Output:
$ pytest test_parametrize.py -v
======================== test session starts ==========================
test_parametrize.py::test_is_even[2-True] PASSED [ 16%]
test_parametrize.py::test_is_even[4-True] PASSED [ 33%]
test_parametrize.py::test_is_even[1-False] PASSED [ 50%]
test_parametrize.py::test_is_even[3-False] PASSED [ 66%]
test_parametrize.py::test_is_even[0-True] PASSED [ 83%]
test_parametrize.py::test_is_even[-2-True] PASSED [100%]
======================== 6 passed in 0.01s ===========================
pytest creates one test per parameter set and labels each one, making it easy to identify which specific input caused a failure.
Testing Exceptions
Sometimes correct behavior means raising an exception. Use pytest.raises to verify exceptions:
# test_exceptions.py
import pytest
def validate_age(age):
if not isinstance(age, int):
raise TypeError("Age must be an integer")
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age must be realistic")
return True
def test_valid_age():
assert validate_age(25) is True
def test_negative_age():
with pytest.raises(ValueError, match="cannot be negative"):
validate_age(-5)
def test_invalid_type():
with pytest.raises(TypeError, match="must be an integer"):
validate_age("twenty-five")
Output:
$ pytest test_exceptions.py -v
======================== test session starts ==========================
test_exceptions.py::test_valid_age PASSED [ 33%]
test_exceptions.py::test_negative_age PASSED [ 66%]
test_exceptions.py::test_invalid_type PASSED [100%]
======================== 3 passed in 0.01s ===========================
The match parameter verifies the exception message using regex, ensuring not just the right type but the right message is raised.
Mocking Basics
Mocking replaces real dependencies with controlled fakes so you can test in isolation:
# test_mocking.py
from unittest.mock import Mock, patch
def fetch_user(user_id):
import requests
response = requests.get(f"https://jsonplaceholder.typicode.com/users/{user_id}")
return response.json()
def test_fetch_user_with_mock():
with patch("requests.get") as mock_get:
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "Alice"}
mock_get.return_value = mock_response
result = fetch_user(1)
assert result["name"] == "Alice"
mock_get.assert_called_once_with(
"https://jsonplaceholder.typicode.com/users/1"
)
Output:
$ pytest test_mocking.py -v
======================== test session starts ==========================
test_mocking.py::test_fetch_user_with_mock PASSED [100%]
======================== 1 passed in 0.01s ===========================
The patch context manager replaces the real requests.get with a mock. The mock tracks how it was called and what it returns, letting you test API-dependent code without network requests.
Real-Life Example: Testing a Shopping Cart
Here is a complete shopping cart with comprehensive tests demonstrating fixtures, parametrization, and exception testing together:
# shopping_cart.py
class Product:
def __init__(self, name, price):
self.name = name
self.price = price
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, product, quantity=1):
if quantity <= 0:
raise ValueError("Quantity must be positive")
self.items.append({"product": product, "quantity": quantity})
def remove_item(self, product_name):
self.items = [i for i in self.items if i["product"].name != product_name]
def get_total(self):
return sum(i["product"].price * i["quantity"] for i in self.items)
def apply_discount(self, percent):
if percent < 0 or percent > 100:
raise ValueError("Discount must be between 0 and 100")
return self.get_total() * (1 - percent / 100)
def is_empty(self):
return len(self.items) == 0
# test_shopping_cart.py
import pytest
from shopping_cart import Product, ShoppingCart
@pytest.fixture
def cart():
return ShoppingCart()
@pytest.fixture
def laptop():
return Product("Laptop", 999.99)
@pytest.fixture
def mouse():
return Product("Mouse", 29.99)
def test_cart_starts_empty(cart):
assert cart.is_empty()
assert cart.get_total() == 0
def test_add_single_item(cart, laptop):
cart.add_item(laptop)
assert not cart.is_empty()
assert cart.get_total() == 999.99
def test_add_multiple_items(cart, laptop, mouse):
cart.add_item(laptop)
cart.add_item(mouse)
assert cart.get_total() == 1029.98
@pytest.mark.parametrize("quantity", [0, -1, -10])
def test_invalid_quantity(cart, laptop, quantity):
with pytest.raises(ValueError, match="must be positive"):
cart.add_item(laptop, quantity=quantity)
def test_remove_item(cart, laptop, mouse):
cart.add_item(laptop)
cart.add_item(mouse)
cart.remove_item("Mouse")
assert cart.get_total() == 999.99
@pytest.mark.parametrize("discount,expected", [
(10, 900), (50, 500), (100, 0),
])
def test_apply_discount(cart, discount, expected):
cart.add_item(Product("Item", 1000))
assert cart.apply_discount(discount) == pytest.approx(expected)
Output:
$ pytest test_shopping_cart.py -v
======================== test session starts ==========================
test_shopping_cart.py::test_cart_starts_empty PASSED [ 8%]
test_shopping_cart.py::test_add_single_item PASSED [ 16%]
test_shopping_cart.py::test_add_multiple_items PASSED [ 25%]
test_shopping_cart.py::test_invalid_quantity[0] PASSED [ 33%]
test_shopping_cart.py::test_invalid_quantity[-1] PASSED [ 41%]
test_shopping_cart.py::test_invalid_quantity[-10] PASSED [ 50%]
test_shopping_cart.py::test_remove_item PASSED [ 58%]
test_shopping_cart.py::test_apply_discount[10-900] PASSED [ 66%]
test_shopping_cart.py::test_apply_discount[50-500] PASSED [ 75%]
test_shopping_cart.py::test_apply_discount[100-0] PASSED [ 83%]
======================== 10 passed in 0.02s ===========================
This example ties together everything: fixtures for reusable setup, parametrization for multiple cases, and exception testing for error handling. Notice we test both happy paths and error paths.
Frequently Asked Questions
How do I run a single test file?
Use pytest followed by the filename: pytest test_calculator.py. To run a specific function: pytest test_calculator.py::test_multiply_basic.
How do I run tests matching a pattern?
Use the -k flag: pytest -k "multiply". This runs all tests with “multiply” in their name.
What does the -v flag do?
The -v (verbose) flag shows each test individually. Use -vv for even more detail including assertion introspection.
Can I stop on the first failure?
Yes, use pytest -x. This stops as soon as one test fails, useful for quick feedback during development.
How do I see print output from my tests?
By default pytest captures print statements. Use pytest -s to show all output during test execution.
What is the difference between a fixture and a helper function?
Fixtures are managed by pytest and support setup/teardown via yield. Helper functions are regular Python functions. Use fixtures for shared setup, helpers for reusable test logic.
How do I test async functions?
Install pytest-asyncio (pip install pytest-asyncio), then mark tests with @pytest.mark.asyncio and use async def.
Conclusion
You now have a solid foundation in pytest. You understand how to write tests with assertions, use fixtures for setup and teardown, parametrize tests for multiple cases, verify exceptions are raised correctly, and mock external dependencies. More importantly, you understand that testing does not have to be complicated.
Start by writing tests for new code, then gradually add tests to existing code. For more advanced topics, visit the official pytest documentation at https://docs.pytest.org/.