Unit Testing — Services, Repositories and Pure Functions

Unit tests target the smallest testable pieces of code — service functions, repository helpers, Pydantic validators, and utility functions — in complete isolation from the database and HTTP layer. A unit test that mocks the database session runs in milliseconds and gives precise failure information: the function itself is broken, not the database or the network. Writing unit tests alongside service and repository code makes the codebase more reliable and documents expected behaviour as executable specifications.

Testing Service Functions

# tests/unit/test_post_service.py
import pytest
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from app.services.post_service import PostService
from app.models.post import Post
from app.models.user import User

@pytest.fixture
def mock_db():
    """A MagicMock that looks like a SQLAlchemy Session."""
    return MagicMock()

@pytest.fixture
def mock_user() -> User:
    user = User()
    user.id   = 1
    user.role = "user"
    return user

@pytest.fixture
def mock_admin() -> User:
    user = User()
    user.id   = 2
    user.role = "admin"
    return user

@pytest.fixture
def existing_post() -> Post:
    post = Post()
    post.id        = 42
    post.title     = "Original Title"
    post.slug      = "original-title"
    post.author_id = 1
    post.deleted_at = None
    return post

class TestPostServiceOwnership:

    def test_require_owner_allows_author(self, mock_db, mock_user, existing_post):
        """The post author can edit their own post."""
        svc = PostService(mock_db)
        # Should not raise
        svc.require_owner(existing_post, mock_user)

    def test_require_owner_allows_admin(self, mock_db, mock_admin, existing_post):
        """An admin can edit any post."""
        svc = PostService(mock_db)
        svc.require_owner(existing_post, mock_admin)   # no exception

    def test_require_owner_raises_403_for_other_user(self, mock_db, existing_post):
        """A non-owner non-admin cannot edit the post."""
        other = User(); other.id = 99; other.role = "user"
        svc   = PostService(mock_db)
        with pytest.raises(HTTPException) as exc:
            svc.require_owner(existing_post, other)
        assert exc.value.status_code == 403
Note: MagicMock() creates an object that accepts any attribute access or method call without error, returning another MagicMock. This makes it easy to simulate a database session: mock_db.get.return_value = existing_post tells the mock to return existing_post whenever mock_db.get() is called. For more complex queries, mock the specific method that the service calls: mock_db.scalars.return_value.first.return_value = existing_post.
Tip: Use pytest.mark.parametrize to test a function with multiple inputs cleanly. Instead of writing five nearly identical test functions for five slug formats, write one parametrized test. This reduces test code volume, makes it easy to add new test cases, and produces clear individual test names in the output showing exactly which case failed.
Warning: Over-mocking can produce tests that pass even when the real code is broken. If you mock the database, you are not testing whether your query is correct — only whether the service calls the right method on the session object. Use unit tests for business logic (validation, ownership checks, transformations) and integration tests for database queries. A service’s get_or_404 method is better tested as an integration test against a real database, not a unit test against a mock.

Parametrized Tests

import pytest
from app.auth.password import verify_password, hash_password
from app.schemas.post import PostCreate
from pydantic import ValidationError

class TestPasswordHashing:

    def test_hash_is_not_plaintext(self):
        h = hash_password("mysecret")
        assert h != "mysecret"
        assert h.startswith("$2b$")   # bcrypt prefix

    def test_verify_correct_password(self):
        h = hash_password("correct_password")
        assert verify_password("correct_password", h) is True

    def test_verify_wrong_password(self):
        h = hash_password("correct_password")
        assert verify_password("wrong_password", h) is False

    def test_two_hashes_of_same_password_differ(self):
        h1 = hash_password("same_password")
        h2 = hash_password("same_password")
        assert h1 != h2   # different salts

class TestPostCreateValidation:

    @pytest.mark.parametrize("slug,valid", [
        ("valid-slug",      True),
        ("also-valid-123",  True),
        ("UPPERCASE",       False),  # slug must be lowercase
        ("has spaces",      False),
        ("has_underscore",  False),
        ("a",               False),  # too short (min 3)
        ("",                False),  # empty
    ])
    def test_slug_validation(self, slug: str, valid: bool):
        data = {"title": "Test Post", "body": "Content here.", "slug": slug}
        if valid:
            post = PostCreate(**data)   # should not raise
            assert post.slug == slug
        else:
            with pytest.raises(ValidationError):
                PostCreate(**data)

    @pytest.mark.parametrize("title,expected_stripped", [
        ("  Padded Title  ",   "Padded Title"),
        ("\tTabbed\n",         "Tabbed"),
        ("No padding",         "No padding"),
    ])
    def test_title_is_stripped(self, title, expected_stripped):
        post = PostCreate(title=title, body="Long enough body text.", slug="test")
        assert post.title == expected_stripped

Mocking External Dependencies

from unittest.mock import AsyncMock, patch
import pytest

class TestEmailService:

    @pytest.mark.asyncio
    async def test_welcome_email_called_with_correct_args(self):
        with patch("app.tasks.email.fast_mail.send_message") as mock_send:
            mock_send.return_value = None   # suppress actual send

            from app.tasks.email import send_welcome_email_task
            await send_welcome_email_task("user@example.com", "Alice")

            mock_send.assert_called_once()
            call_args = mock_send.call_args[0][0]   # MessageSchema
            assert "Alice" in str(call_args.template_body)

    @pytest.mark.asyncio
    async def test_email_failure_does_not_raise(self):
        """Email errors should be caught — they run in background tasks."""
        with patch("app.tasks.email.fast_mail.send_message",
                   side_effect=Exception("SMTP down")):
            from app.tasks.email import send_welcome_email_task
            # Should NOT raise — background tasks log errors, don't propagate
            await send_welcome_email_task("user@example.com", "Alice")

Common Mistakes

Mistake 1 — Mocking at the wrong level (mock the import, not the definition)

❌ Wrong — mocking where the function is defined, not where it is used:

with patch("app.auth.password.hash_password"):   # wrong — patch where it's used
    ...   # the import in post_service.py is not affected

✅ Correct — patch the name in the module that imports it:

with patch("app.services.post_service.hash_password"):   # ✓ patched where used

Mistake 2 — Not using pytest.raises for expected exceptions

❌ Wrong — try/except hides assertion failures:

try:
    service.require_owner(post, other_user)
    assert False, "Should have raised"   # easy to miss
except HTTPException:
    pass

✅ Correct:

with pytest.raises(HTTPException) as exc:
    service.require_owner(post, other_user)
assert exc.value.status_code == 403   # ✓ clear and precise

Quick Reference

Pattern Code
Mock session db = MagicMock(); db.get.return_value = post
Assert exception with pytest.raises(HTTPException) as exc:
Parametrize @pytest.mark.parametrize("input,expected", [...])
Patch import with patch("module.where.used.function_name"):
Async mock AsyncMock(return_value=...)
Side effect mock.side_effect = Exception("fail")

🧠 Test Yourself

You want to test 8 combinations of valid/invalid slugs. Using pytest.mark.parametrize instead of 8 separate test functions gives what advantage?