Most Python developers know they should write tests. Fewer actually do it, and the usual reason is friction. The standard unittest module works, but it is verbose, boilerplate-heavy, and the error messages can be cryptic.
pytest removes that friction. You write plain functions and use normal assert statements. pytest gives you rich failure reports, powerful fixtures, and a plugin ecosystem that handles everything from coverage to parallel execution.
This guide assumes you know basic Python. By the end, you will have written real tests, organized them cleanly, and learned the patterns that professional teams use every day.
1. Why pytest Instead of unittest?
Python ships with unittest, which is inspired by Java's JUnit. It works, but it forces you into classes and special assertion methods:
import unittest
class TestMath(unittest.TestCase):
def test_add(self):
self.assertEqual(2 + 2, 4)
if __name__ == "__main__":
unittest.main()
pytest lets you write the same test as a simple function:
def test_add():
assert 2 + 2 == 4
Both pass. The pytest version has less boilerplate, better failure messages, and does not need a if __name__ == "__main__" guard. When a test fails, pytest shows the exact line, the values involved, and a short context snippet.
2. Install pytest and Run Your First Test
Create a virtual environment, then install pytest:
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install pytest
Save this as test_wallet.py:
class Wallet:
def __init__(self, balance=0):
self.balance = balance
def add_cash(self, amount):
if amount < 0:
raise ValueError("Amount must be non-negative")
self.balance += amount
def spend_cash(self, amount):
if amount < 0:
raise ValueError("Amount must be non-negative")
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
def test_add_cash_updates_balance():
wallet = Wallet(10)
wallet.add_cash(5)
assert wallet.balance == 15
def test_spend_cash_updates_balance():
wallet = Wallet(20)
wallet.spend_cash(8)
assert wallet.balance == 12
Run the tests:
pytest
You should see two green dots. If a test fails, pytest prints the failing assertion and the actual value, which makes debugging fast.
3. The Magic of Plain assert
pytest rewrites assert statements so that failures are informative. Compare these two failure messages for the same broken test:
# With unittest:
AssertionError: 16 != 15
# With pytest:
assert wallet.balance == 15
AssertionError: assert 16 == 15
For collections and dictionaries, pytest diffs the expected and actual output. This is especially useful when testing API responses or parsed data:
def test_parse_user():
raw = {"name": "Alex", "role": "editor"}
assert raw["name"] == "Alex"
assert raw["role"] == "admin" # fails with a clear diff
4. Run Tests Selectively
As your project grows, you will not always want to run every test. pytest has you covered:
# Run a specific file
pytest test_wallet.py
# Run a specific test function
pytest test_wallet.py::test_add_cash_updates_balance
# Run tests matching a keyword
pytest -k "cash"
# Verbose output with full test names
pytest -v
# Stop at the first failure
pytest -x
# Run the last failed test only
pytest --lf
These flags save hours during development. -x is useful when you are chasing a single bug. -k lets you run a subset without reorganizing files.
5. Use Fixtures for Reusable Setup
Every test above created its own Wallet instance. That is fine for two tests, but it becomes repetitive. Fixtures let you define setup once and inject it into any test:
import pytest
@pytest.fixture
def empty_wallet():
return Wallet()
@pytest.fixture
def funded_wallet():
return Wallet(100)
def test_empty_wallet_balance(empty_wallet):
assert empty_wallet.balance == 0
def test_funded_wallet_can_spend(funded_wallet):
funded_wallet.spend_cash(30)
assert funded_wallet.balance == 70
The fixture name becomes the argument name. pytest sees the argument, runs the fixture, and passes the returned object into the test. Fixtures can also handle cleanup with yield:
@pytest.fixture
def temp_database():
db = create_test_db()
yield db
db.destroy() # runs after the test finishes
This pattern replaces manual setup and teardown and keeps tests focused on behavior, not plumbing.
6. Parametrize Tests for Multiple Inputs
When one test needs to run with several inputs, do not copy and paste the function. Use @pytest.mark.parametrize:
@pytest.mark.parametrize("amount,expected", [
(0, 0),
(1, 1),
(50, 50),
(100, 100),
])
def test_add_cash_many_amounts(empty_wallet, amount, expected):
empty_wallet.add_cash(amount)
assert empty_wallet.balance == expected
pytest runs this test once for each tuple. If one case fails, the report tells you exactly which input caused the failure. This keeps the test suite compact without losing coverage.
7. Test That Exceptions Are Raised
Good tests verify not just the happy path but also the error path. Use pytest.raises as a context manager:
import pytest
def test_cannot_spend_more_than_balance(empty_wallet):
with pytest.raises(ValueError, match="Insufficient funds"):
empty_wallet.spend_cash(10)
def test_cannot_add_negative_amount(empty_wallet):
with pytest.raises(ValueError, match="non-negative"):
empty_wallet.add_cash(-5)
The match argument checks that the error message contains the given text. This prevents tests from passing on the wrong exception.
8. Organize Tests with conftest.py
When fixtures are shared across multiple test files, move them into a file named conftest.py in the same directory. pytest loads it automatically:
# conftest.py
import pytest
@pytest.fixture
def empty_wallet():
return Wallet()
@pytest.fixture
def funded_wallet():
return Wallet(100)
Now any test file in that directory can use empty_wallet and funded_wallet without importing them. This is the standard way to share setup in pytest projects.
A typical project layout looks like this:
myproject/
├── src/
│ └── wallet.py
├── tests/
│ ├── conftest.py
│ ├── test_wallet.py
│ └── test_api.py
└── pyproject.toml
9. Common Mistakes and How to Avoid Them
9.1. Writing tests that depend on each other
Each test should start from a clean state. If test B only passes because test A ran first, your suite is fragile. Use fixtures to reset state before every test.
9.2. Testing implementation details
Test behavior, not internal structure. A test that asserts wallet.transactions == [] is more brittle than one that asserts wallet.balance == 0. The second test still passes if you later refactor how transactions are stored.
9.3. Ignoring slow tests
Mark long-running tests so you can skip them during rapid development:
import pytest
@pytest.mark.slow
def test_large_dataset_processing():
...
Run everything except slow tests:
pytest -m "not slow"
9.4. Forgetting to test the error path
Developers often write tests for the success case and skip failures. Exceptions are part of your public API. Test them.
9.5. Using print instead of pytest output
When debugging, use pytest -s to show print statements, but do not leave them in committed tests. Rely on assertions and failure messages instead.
10. pytest vs unittest vs doctest
| Feature | pytest | unittest | doctest |
|---|---|---|---|
| Syntax | Plain functions + assert | Classes + assert methods | Docstring examples |
| Fixtures | Powerful, scope-aware | setUp/tearDown | None |
| Plugins | Rich ecosystem | Limited | None |
| Failure output | Detailed diffs | Basic | Basic |
| Best for | General testing | Standard library only | Documentation examples |
For new projects, pytest is the default choice in the Python community. It is what most open-source projects and professional teams use.
Frequently Asked Questions
Do I need to learn unittest before pytest?
No. You can start with pytest directly. It is simpler and more widely used in modern Python projects.
Can pytest run my old unittest tests?
Yes. pytest can discover and run tests written with unittest without modification. This makes migration easy.
How do I generate a coverage report?
Install pytest-cov, then run pytest --cov=myproject. Add --cov-report=html for a browsable report.
Should I put tests inside or outside my package?
Outside. Keep tests in a top-level tests/ directory. This avoids shipping test code in production and makes the project structure clear.