test_models.py — Тестування моделей¶
Що живе в моделі (і що можна зламати)¶
Питання: Чому тестувати моделі? Це ж просто поля і зв'язки.
Відповідь: Модель містить бізнес-правила. Якщо їх порушити — додаток поводиться неправильно мовчки.
Note модель
├── priority: validators=[MinValueValidator(1), MaxValueValidator(4)]
│ Якщо хтось видалить validators → форма прийме priority=99
│ → Відображення пріоритету зламається (KeyError в PRIORITY_CHOICES)
│
├── group: ForeignKey(Group, on_delete=SET_NULL)
│ Якщо змінити на CASCADE → видалення групи видалить всі нотатки!
│ → Юзери втратять дані, дізнаються в production
│
├── notebook: ForeignKey(Notebook, on_delete=SET_NULL)
│ Якщо CASCADE → видалення записника видалить нотатки
│
├── is_pinned: BooleanField(default=False)
│ Якщо default стане True → всі нові нотатки стануть закріпленими
│ → Сортування зламається
│
├── __str__: "📌 {title}" або "{title}"
│ Якщо зміниться формат → Django Admin і логи показують неправильно
│
└── Tag: UniqueConstraint(fields=['user', 'name'])
Якщо прибрати → два теги 'python' в одного юзера → баги у формах
Структура tests/ пакету¶
notes_app/
├── models.py
├── views.py
├── forms.py
├── services.py
├── selectors.py
├── consumers.py
└── tests/ ← пакет (директорія з __init__.py)
├── __init__.py ← ОБОВ'ЯЗКОВИЙ! Порожній.
│ Без нього Django не знайде тести → "Found 0 test(s)"
├── test_models.py ← Unit: constraints, __str__, SET_NULL
├── test_services.py ← Unit: бізнес-логіка, security
├── test_forms.py ← Unit: валідація, queryset filtering
├── test_views.py ← Integration: HTTP, ownership, redirects
├── test_consumers.py ← Consumer: WebSocket lifecycle
└── test_selenium.py ← E2E: браузер
Чому tests/ пакет, а не один tests.py?
# Варіант 1: один файл tests.py
notes_app/tests.py ← все в одному → стає 3000+ рядків, важко орієнтуватись
# Варіант 2: пакет tests/ (наш варіант)
notes_app/tests/
test_models.py ← тільки моделі
test_services.py ← тільки сервіси
test_forms.py ← тільки форми
test_views.py ← integration
test_consumers.py ← WebSocket
test_selenium.py ← E2E
Переваги:
- Запускаємо окремо: manage.py test notes_app.tests.test_services
- Кожен файл = один шар системи → легко знайти тест
- CI може паралельно запускати різні файли
Як тестувати validators через full_clean()¶
# ВАЖЛИВО: validators не запускаються при .save() або .objects.create()!
# Вони запускаються тільки через full_clean() або ModelForm.
# НЕПРАВИЛЬНО (validators не перевіряться):
note = Note.objects.create(user=user, title='Test', priority=5)
# ← Django просто збереже priority=5! (validators проігноровані)
# ← CheckConstraint у БД може спрацювати → IntegrityError
# ← Але validators (MinValueValidator, MaxValueValidator) — ні!
# ПРАВИЛЬНО — через full_clean():
note = Note(user=user, title='Test', priority=5) # ← НЕ зберігаємо одразу
note.full_clean() # ← тут запускаються validators → ValidationError
# full_clean() виконує:
# 1. validate_unique() — UniqueConstraint
# 2. clean_fields() — field validators (MinValueValidator, MaxValueValidator...)
# 3. clean() — custom model validation
# У тесті:
def test_priority_above_4_raises_validation_error(self):
note = Note(user=self.user, title='Test', priority=5)
with self.assertRaises(ValidationError) as ctx:
note.full_clean()
self.assertIn('priority', ctx.exception.message_dict)
# ↑ перевіряємо що помилка саме у полі priority
Тест SET_NULL — захист від CASCADE¶
def test_note_group_becomes_null_when_group_deleted(self):
"""
Перевіряємо що Note.group = ForeignKey(Group, on_delete=SET_NULL)
працює правильно: видалення групи → нотатка стає особистою.
Якщо хтось змінить на CASCADE → цей тест ПРОВАЛИТЬСЯ і покаже:
DoesNotExist: Note matching query does not exist.
→ ми дізнаємось ДО деплою що зміна зломала дані юзерів.
"""
group = Group.objects.create(name='Family')
note = Note.objects.create(user=self.user, title='Family note', group=group)
group.delete() # видаляємо групу
note.refresh_from_db() # ← ОБОВ'ЯЗКОВО! читаємо свіжий стан з БД
self.assertIsNone(note.group)# нотатка збереглась, group = NULL
Чому
refresh_from_db()? Післяgroup.delete()об'єктnoteу пам'яті ще зберігає стару reference.refresh_from_db()перечитує з БД.
Повний код test_models.py (notes_chat_app)¶
# notes_app/tests/test_models.py
from django.contrib.auth.models import Group, User
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.test import TestCase
from notes_app.models import (
ChatMessage, Note, Notebook, Reminder,
ShopItem, ShoppingList, Tag, TodoItem, TodoList,
)
class TagModelTest(TestCase):
def setUp(self):
self.alice = User.objects.create_user('alice', password='pass123')
self.bob = User.objects.create_user('bob', password='pass123')
# ── __str__ ────────────────────────────────────────────────────────────
def test_str_returns_hash_name(self):
tag = Tag.objects.create(user=self.alice, name='python')
self.assertEqual(str(tag), '#python')
# ── UniqueConstraint ───────────────────────────────────────────────────
def test_unique_constraint_same_user_same_name_raises(self):
"""Два однакові теги в одного юзера → IntegrityError."""
Tag.objects.create(user=self.alice, name='work')
with self.assertRaises(IntegrityError):
Tag.objects.create(user=self.alice, name='work')
def test_unique_constraint_different_users_same_name_ok(self):
"""Різні юзери можуть мати однакові назви тегів."""
Tag.objects.create(user=self.alice, name='work')
Tag.objects.create(user=self.bob, name='work') # ← OK
self.assertEqual(Tag.objects.count(), 2)
class NoteModelTest(TestCase):
def setUp(self):
self.alice = User.objects.create_user('alice', password='pass123')
self.group = Group.objects.create(name='Family')
# ── Defaults ───────────────────────────────────────────────────────────
def test_default_priority_is_1(self):
note = Note.objects.create(user=self.alice, title='Test')
self.assertEqual(note.priority, 1)
def test_default_is_pinned_is_false(self):
note = Note.objects.create(user=self.alice, title='Test')
self.assertFalse(note.is_pinned)
def test_default_is_archived_is_false(self):
note = Note.objects.create(user=self.alice, title='Test')
self.assertFalse(note.is_archived)
# ── Validators (тільки через full_clean!) ─────────────────────────────
def test_priority_above_4_raises_validation_error(self):
"""
validators=[MinValueValidator(1), MaxValueValidator(4)] перевіряє full_clean().
NOT перевіряє .objects.create() — Django обходить validators при прямому записі.
"""
note = Note(user=self.alice, title='Test', priority=5)
with self.assertRaises(ValidationError) as ctx:
note.full_clean()
self.assertIn('priority', ctx.exception.message_dict)
def test_priority_0_raises_validation_error(self):
note = Note(user=self.alice, title='Test', priority=0)
with self.assertRaises(ValidationError):
note.full_clean()
def test_priority_4_is_valid(self):
"""Граничне значення 4 — валідне."""
note = Note(user=self.alice, title='Test', priority=4)
note.full_clean() # ← не повинно кинути виняток
# ── SET_NULL: захист від CASCADE ──────────────────────────────────────
def test_note_group_becomes_null_when_group_deleted(self):
"""
Документує і захищає поведінку SET_NULL:
якщо хтось змінить на CASCADE → групові нотатки видаляться → дані юзерів втрачені.
Цей тест впаде (DoesNotExist) і запобіжить деплою.
"""
note = Note.objects.create(user=self.alice, title='Family note', group=self.group)
self.group.delete()
note.refresh_from_db() # ← ОБОВ'ЯЗКОВО! Python-об'єкт застарів
self.assertIsNone(note.group) # нотатка збереглась, group = NULL ✓
def test_note_notebook_becomes_null_when_notebook_deleted(self):
"""Видалення записника → нотатки стають без записника (не видаляються)."""
nb = Notebook.objects.create(user=self.alice, title='Work')
note = Note.objects.create(user=self.alice, title='Work note', notebook=nb)
nb.delete()
note.refresh_from_db()
self.assertIsNone(note.notebook) # SET_NULL на notebook FK ✓
# ── __str__ ────────────────────────────────────────────────────────────
def test_str_pinned_note_has_pin_emoji(self):
note = Note.objects.create(user=self.alice, title='Important', is_pinned=True)
self.assertIn('Important', str(note))
def test_str_unpinned_note(self):
note = Note.objects.create(user=self.alice, title='Normal')
self.assertEqual(str(note), 'Normal')
class NotebookModelTest(TestCase):
def setUp(self):
self.alice = User.objects.create_user('alice', password='pass123')
def test_default_is_not_default(self):
nb = Notebook.objects.create(user=self.alice, title='Work')
self.assertFalse(nb.is_default)
def test_str_returns_title(self):
nb = Notebook.objects.create(user=self.alice, title='My Notebook')
self.assertEqual(str(nb), 'My Notebook')
class ChatMessageModelTest(TestCase):
"""Специфічно для notes_chat_app: ChatMessage і GROUP CASCADE."""
def setUp(self):
self.alice = User.objects.create_user('alice', password='pass123')
self.group = Group.objects.create(name='Team')
def test_chat_message_cleared_when_group_deleted(self):
"""
ChatMessage.group = FK(Group, CASCADE):
Видалення групи → всі повідомлення чату видаляються.
(На відміну від Note.group = SET_NULL)
"""
ChatMessage.objects.create(
group=self.group, author=self.alice, content='Hello!'
)
self.assertEqual(ChatMessage.objects.count(), 1)
self.group.delete()
self.assertEqual(ChatMessage.objects.count(), 0) # CASCADE ✓
def test_chat_message_deleted_when_author_deleted(self):
"""
ChatMessage.author = FK(User, CASCADE):
Видалення юзера → всі його повідомлення видаляються разом.
"""
ChatMessage.objects.create(
group=self.group, author=self.alice, content='I will be deleted'
)
self.assertEqual(ChatMessage.objects.count(), 1)
self.alice.delete()
# CASCADE: повідомлення видалено разом з автором
self.assertEqual(ChatMessage.objects.count(), 0)
Ключові assert методи у тестах моделей¶
self.assertEqual(str(tag), '#python') # рівність рядків
self.assertFalse(note.is_pinned) # перевірка False
self.assertIsNone(note.group) # перевірка None
self.assertIsNotNone(note.created_at) # перевірка що не None
self.assertIn('Milk', str(item)) # підрядок у рядку
self.assertGreaterEqual(updated, old) # порівняння дат
# Для перевірки винятків:
with self.assertRaises(IntegrityError):
Tag.objects.create(user=user, name='duplicate')
with self.assertRaises(ValidationError) as ctx:
note.full_clean()
self.assertIn('priority', ctx.exception.message_dict)
Запуск тестів моделей¶
# crispy_notes_project:
python manage.py test hello_app.tests.test_models -v 2
# notes_chat_app (Docker):
docker compose run --rm web python manage.py test notes_app.tests.test_models -v 2
Далі¶
- test_services.py — бізнес-логіка, persistence, security, транзакції