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.
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_*.py or *_test.py test_ 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
A good test name explains the scenario:
test_login_with_invalid_password_raises_errortest_add_when_negative_returns_negative_sumYour test file should feel like reading specs.
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"
function – new instance per test (default)module – shared within the same filesession – 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 {}
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.
Tests should be fast, isolated, and deterministic. Avoid calling real APIs, databases, time, OS, etc.
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
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"
Marks help organize tests.
import pytest
@pytest.mark.slow
def test_heavy_computation():
...
pytest -m slow
pytest -m "not slow"
Add to pytest.ini:
[pytest]
markers =
slow: tests that are slow and excluded from normal runs
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
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.
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:
# 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