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") |