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/.
Fixtures: The Killer Feature
Fixtures are pytest’s way of providing setup data to tests. Declare them once, request them as function parameters — pytest wires up dependencies automatically:
# File: conftest.py — fixtures available to all tests in this directory
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "name": "Alice", "age": 30}
@pytest.fixture
def db_session():
session = create_test_session()
yield session
session.rollback()
session.close()
# File: test_users.py
def test_get_user(sample_user):
assert sample_user["name"] == "Alice"
def test_save_user(db_session, sample_user):
db_session.add(sample_user)
db_session.flush()
assert db_session.get_user(1) is not None
The yield pattern separates setup from teardown. Code before yield runs before the test; code after runs after — even if the test fails.
Fixture Scopes
By default fixtures rebuild for every test. For expensive resources (database connections, web drivers), set a wider scope:
@pytest.fixture(scope="session")
def engine():
# Created once for the entire test run
return create_engine("sqlite:///test.db")
@pytest.fixture(scope="module")
def schema(engine):
# Created once per test file
Base.metadata.create_all(engine)
yield
Base.metadata.drop_all(engine)
@pytest.fixture(scope="function") # default — one per test
def fresh_user(engine, schema):
with engine.connect() as conn:
conn.execute("INSERT INTO users ...")
yield {"id": last_id}
Scopes: function (default), class, module, session. Pick the widest that’s still safe for test isolation.
Parametrization
Test the same logic with multiple inputs via @pytest.mark.parametrize:
@pytest.mark.parametrize("input,expected", [
(1, 1),
(5, 120),
(10, 3628800),
(0, 1),
])
def test_factorial(input, expected):
assert factorial(input) == expected
# Multiple params combine into a matrix
@pytest.mark.parametrize("payment_method", ["card", "paypal", "bank"])
@pytest.mark.parametrize("amount", [10, 100, 1000])
def test_checkout(payment_method, amount):
# 9 tests total: 3 methods x 3 amounts
process_payment(payment_method, amount)
Marks: skip, xfail, slow
Custom marks tag tests for selective running:
import pytest
@pytest.mark.skip(reason="API endpoint not yet deployed")
def test_new_endpoint():
...
@pytest.mark.skipif(sys.version_info < (3, 11), reason="Requires 3.11+")
def test_new_feature():
...
@pytest.mark.xfail(reason="Known bug, fix in PR #234")
def test_buggy():
assert broken_thing() == "expected"
@pytest.mark.slow
def test_full_integration():
... # 30 seconds
# Run only fast tests by default: pytest -m "not slow"
# Run only slow: pytest -m slow
Mocking with monkeypatch and mocker
For tests that need to replace dependencies (external APIs, system calls, current time):
def test_api_call(monkeypatch):
monkeypatch.setenv("API_KEY", "test-key")
monkeypatch.setattr("mymodule.requests.get", lambda url: FakeResp())
assert fetch_data() == {"status": "ok"}
# With pytest-mock (more powerful)
def test_with_mocker(mocker):
mock_send = mocker.patch("mymodule.send_email")
mock_send.return_value = True
result = signup_user("alice@example.com")
mock_send.assert_called_once_with("alice@example.com", "Welcome")
Test Discovery and Configuration
pytest auto-discovers tests in files starting with test_ or ending in _test.py, classes named Test*, and functions named test_*. Configure in pyproject.toml:
# pyproject.toml
[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts = "-ra --strict-markers --cov=myapp"
markers = [
"slow: slow tests (deselect with -m 'not slow')",
"integration: tests that hit real external services",
]
Common Pitfalls
- Shared state between tests. Module-level globals that tests modify make order-dependent bugs. Use fixtures with function scope to reset state per test.
- Fixture scope mismatch. A session-scoped fixture that mutates state breaks isolation. Mutable fixtures should be function-scoped.
- Catching too-broad exceptions.
assert exc.valueinstead ofwith pytest.raises(SpecificError)swallows bugs. - Slow imports in conftest. pytest imports conftest.py before any test runs. Heavy imports there slow every pytest invocation.
- Ignoring -ra output. The summary section at the end shows skipped, xfailed, and warning details. Read it — it's where flaky tests hide.
FAQ
Q: pytest or unittest?
A: pytest — better fixtures, parametrization, plugin ecosystem, simpler assertion syntax. unittest is fine for tiny projects or if you can't add dependencies.
Q: How do I run only failing tests?
A: pytest --lf (last-failed). Combine with --ff (failed-first) for fast iteration while debugging.
Q: How do I measure code coverage?
A: pip install pytest-cov then pytest --cov=myapp --cov-report=html. The HTML report tells you which lines weren't hit.
Q: How do I test async code?
A: pip install pytest-asyncio. Mark tests with @pytest.mark.asyncio and use async def. Fixtures can be async too.
Q: How do I parallelize tests?
A: pip install pytest-xdist then pytest -n auto uses all CPU cores. Works great for independent unit tests; less great for tests that share databases.
Wrapping Up
pytest's superpower is fixtures + parametrization — together they remove almost all test boilerplate. Add pytest-cov for coverage, pytest-mock for mocking, pytest-xdist for parallelism. The ecosystem is huge (over 1000 plugins) but you usually need only those four to cover 95% of testing needs. Master fixtures first; everything else flows from there.