Powerful, and Thorough Unit Tests with pytest

A practical guide for writing clean, maintainable, and effective unit tests in Python using the pytest framework.

Powerful, and Thorough Unit Tests with pytest

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:


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:

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

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
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:


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