Powerful, and Thorough Unit Tests with pytest
A practical guide for writing clean, maintainable, and effective unit tests in Python using the pytest framework.
When it comes to Python testing frameworks, pytest stands out for being both simple and incredibly powerful. But pytest’s true strength isn’t just “you can use plain assert”—it shines when you adopt the right testing mindset, structure, and best practices.
This post summarizes how to write clean, readable, and maintainable unit tests that feel more like living documentation than code.
Project Structure: Keep Tests Organized and Scalable
A clean project structure makes testing predictable and maintainable:
project/
src/
myapp/
service.py
utils.py
tests/
test_service.py
test_utils.py
conftest.py # shared fixtures
Rules to follow:
- Test files should be
test_*.pyor*_test.py - Test functions should start with
test_ - Each test file should focus on one module or one feature
Writing Beautiful pytest Tests (AAA Format)
Minimal syntax + clear intention = great tests.
# test_utils.py
from myapp.utils import add
def test_add_two_positive_numbers():
# Arrange
a = 2
b = 3
# Act
result = add(a, b)
# Assert
assert result == 5
Naming matters
A good test name explains the scenario:
test_login_with_invalid_password_raises_errortest_add_when_negative_returns_negative_sum
Your test file should feel like reading specs.
Using Fixtures Effectively (Your Secret Weapon)
pytest fixtures remove duplicated setup code and make tests cleaner.
# conftest.py
import pytest
from myapp.service import UserService
from myapp.repository import InMemoryUserRepo
@pytest.fixture
def user_service():
repo = InMemoryUserRepo()
return UserService(repo)
# test_service.py
def test_create_user_success(user_service):
user = user_service.create_user("will", "password123")
assert user.username == "will"
Fixture scopes
-
function– new instance per test (default) -
module– shared within the same file -
session– shared across the entire test suite (use carefully!)
Even though a session fixture is defined in another file, you can still use it from any test:
@pytest.fixture(scope="session")
def cache():
return {}
Parametrization: One Test, Many Cases
Instead of repeating similar tests, use @pytest.mark.parametrize:
import pytest
from myapp.utils import is_even
@pytest.mark.parametrize(
"value, expected",
[
(2, True),
(4, True),
(3, False),
(0, True),
(-2, True),
],
)
def test_is_even(value, expected):
assert is_even(value) is expected
This keeps your tests concise, readable, and exhaustive.
Mocking External Dependencies (monkeypatch/mocker)
Tests should be fast, isolated, and deterministic. Avoid calling real APIs, databases, time, OS, etc.
Using monkeypatch
def test_get_current_utc_hour(monkeypatch):
class DummyDatetime:
@classmethod
def now(cls, tz=None):
from datetime import datetime, timezone
return datetime(2025, 1, 1, 10, 30, tzinfo=timezone.utc)
import myapp.time_utils as tu
monkeypatch.setattr(tu, "datetime", DummyDatetime)
assert get_current_utc_hour() == 10
Using pytest-mock (recommended)
def test_calls_external_api(mocker, user_service):
fake_response = {"status": "ok"}
mock_post = mocker.patch("myapp.service.requests.post", return_value=fake_response)
result = user_service.call_external()
mock_post.assert_called_once()
assert result == "ok"
Using Marks: slow, integration, db, etc.
Marks help organize tests.
import pytest
@pytest.mark.slow
def test_heavy_computation():
...
Run only slow tests:
pytest -m slow
Run everything except slow:
pytest -m "not slow"
Register marks to avoid warnings
Add to pytest.ini:
[pytest]
markers =
slow: tests that are slow and excluded from normal runs
Coverage: Measure What’s Actually Tested
Install:
pip install pytest-cov
Run:
pytest --cov=src/myapp --cov-report=term-missing
To fail if coverage drops below a threshold:
pytest --cov=src/myapp --cov-fail-under=80
Property-Based Testing (Hypothesis)
For rules that must always hold true (invariants), use Hypothesis.
pip install hypothesis
from hypothesis import given, strategies as st
from myapp.utils import add
@given(st.integers(), st.integers())
def test_add_commutative(a, b):
assert add(a, b) == add(b, a)
Hypothesis automatically finds edge cases you never thought of.
Testing Services and Repositories Cleanly
You can isolate business logic from real databases using “fake repos”:
class FakeUserRepo:
def __init__(self):
self.users = {}
def exists(self, username):
return username in self.users
def create(self, username, password):
user = {"username": username, "password": password}
self.users[username] = user
return user
@pytest.fixture
def user_service():
from myapp.service import UserService
return UserService(FakeUserRepo())
This pattern keeps your tests:
- fast
- predictable
- independent of external systems
Useful Commands
# basic
pytest
# verbose
pytest -v
# run a specific test
pytest tests/test_user_service.py::test_register
# skip slow tests
pytest -m "not slow"
# show missing coverage lines
pytest --cov=src/myapp --cov-report=term-missing
# rerun only last failed tests
pytest --last-failed