Testing Lab — Регресійний тест на permission bug¶
Мета: навчитися писати тест, який виявляє permission bug до того, як він потрапить у production. Зрозуміти різницю між TestCase і TransactionTestCase.
Пов'язані файли: notes_app/tests/test_views.py, notes_app/tests/test_services.py, notes_app/tests/test_consumers.py.
Частина 1 — Прочитайте існуючий IDOR тест¶
Відкрийте notes_app/tests/test_views.py і знайдіть тест на IDOR (спроба доступу до чужої нотатки).
Типовий патерн:
def test_cannot_access_other_users_note(self):
alice = User.objects.create_user('alice', password='pw')
bob = User.objects.create_user('bob', password='pw')
note = Note.objects.create(user=bob, title="Bob's note", content="secret")
self.client.force_login(alice)
response = self.client.get(reverse('note_detail', kwargs={'pk': note.pk}))
self.assertEqual(response.status_code, 404)
Питання:
- Чому очікується
404, а не403? - Що відбудеться якщо selector поверне
Note.objects.get(pk=pk)без фільтрації по user? - Як
force_loginвідрізняється відclient.login(username=..., password=...)?
Частина 2 — Напишіть регресійний тест¶
Сценарій: у вас є підозра, що при редагуванні нотатки не перевіряється ownership. Напишіть тест, що підтверджує баг, а потім доводить що його виправлено.
# notes_app/tests/test_views.py — додайте клас
class NoteEditPermissionTest(TestCase):
def setUp(self):
self.alice = User.objects.create_user('alice_edit', password='pw')
self.bob = User.objects.create_user('bob_edit', password='pw')
self.bob_note = Note.objects.create(
user=self.bob, title="Bob's private note", content="top secret"
)
def test_alice_cannot_edit_bobs_note(self):
"""Alice не повинна мати змогу редагувати нотатку Bob."""
self.client.force_login(self.alice)
url = reverse('note_update', kwargs={'pk': self.bob_note.pk})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_alice_cannot_post_to_bobs_note(self):
"""POST до чужої нотатки теж має повернути 404."""
self.client.force_login(self.alice)
url = reverse('note_update', kwargs={'pk': self.bob_note.pk})
response = self.client.post(url, {'title': 'hacked', 'content': 'x', 'priority': 1})
self.assertEqual(response.status_code, 404)
# нотатка не змінилась
self.bob_note.refresh_from_db()
self.assertEqual(self.bob_note.title, "Bob's private note")
Запустіть:
Частина 3 — Тест на deletion behavior¶
Завдання: напишіть тест, що перевіряє поведінку при видаленні нотатки — Reminder-и видаляються (CASCADE), але Notebook залишається.
# notes_app/tests/test_models.py — додайте метод
def test_note_deletion_cascades_to_reminders(self):
from django.utils import timezone
note = Note.objects.create(user=self.user, title="Test", content="")
from notes_app.models import Reminder
Reminder.objects.create(note=note, remind_at=timezone.now())
note_pk = note.pk
note.delete()
self.assertFalse(Reminder.objects.filter(note_id=note_pk).exists())
Частина 4 — TransactionTestCase vs TestCase¶
Завдання: дослідіть різницю на практиці.
Скопіюйте будь-який тест з test_consumers.py і спробуйте змінити TransactionTestCase на TestCase. Запустіть:
docker compose up -d
docker compose exec web python manage.py test notes_app.tests.test_consumers -v 2
Очікуваний результат: тест або падає з DoesNotExist, або з'являється неочікувана поведінка — setUp-об'єкти недоступні consumer worker thread.
Поясніть у коментарі до коду чому так відбувається.
Частина 5 — Тест на mass assignment security¶
services.py отримує tag_ids і фільтрує їх по поточному user:
# services.py (приблизний патерн)
tags = Tag.objects.filter(user=user, pk__in=tag_ids)
note.tags.set(tags)
Завдання: напишіть тест, що перевіряє: навіть якщо у request передаються tag_ids чужих тегів, вони не встановлюються:
def test_cannot_assign_other_users_tags(self):
other_user = User.objects.create_user('other_tag_test', password='pw')
alien_tag = Tag.objects.create(user=other_user, name='alien')
# спробуйте передати alien_tag.pk у tag_ids
# після виклику service — нотатка не повинна мати цей тег
...
Критерій: тест проходить і підтверджує що чужий тег не встановлений.
Підсумок: типи тестів у проєкті¶
| Тип | Клас | Файл |
|---|---|---|
| Unit (модель, constraint) | TestCase |
test_models.py |
| Unit (сервіс, security) | TestCase |
test_services.py |
| Unit (форма, queryset) | TestCase |
test_forms.py |
| Integration (HTTP, IDOR) | TestCase |
test_views.py |
| Async (WebSocket) | TransactionTestCase |
test_consumers.py |
| E2E (браузер) | StaticLiveServerTestCase |
test_selenium.py |
Оціни та обери (рівень аналізу і оцінювання)¶
В.1. TestCase vs TransactionTestCase — у якому випадку що обрати?¶
Сценарій: ти пишеш тест для нової фічі — нагадування (Reminder). Нагадування створюється через service, далі Celery task (гіпотетично) зберігає результат у БД у окремому worker-процесі.
Оціни:
- Чому для такого сценарію TestCase міг би не спрацювати?
- Знайди у test_consumers.py коментар або патерн, що пояснює різницю.
- Якщо б ти тестував create_reminder service без Celery worker — який клас обрав би? Чому?
- Чи є performance trade-off між TestCase і TransactionTestCase? Де він помітний?
В.2. Mock vs реальна БД у service-тестах¶
Два підходи до тесту create_note:
# Підхід A: реальна БД (TestCase)
class CreateNoteTest(TestCase):
def test_note_is_saved(self):
note = services.create_note(user=self.user, title='T', content='')
self.assertEqual(Note.objects.count(), 1)
# Підхід B: mock ORM
from unittest.mock import patch
class CreateNoteTest(TestCase):
@patch('notes_app.services.Note.objects.create')
def test_note_save_called(self, mock_create):
mock_create.return_value = MagicMock(pk=1)
services.create_note(user=self.user, title='T', content='')
mock_create.assert_called_once()
Оціни:
- Що кожен підхід тестує насправді? Що кожен пропускає?
- Підхід B пройде навіть якщо create_note зламаний (збереже у неправильну таблицю). Чому?
- Знайди у test_services.py — який підхід обраний? Чому це правильне рішення для Django?
- Сформулюй правило: «я мокую коли..., і не мокую коли...»
В.3. Де тестовий охоплення найважливіше?¶
Notes Chat App має ~205 тестів у 6 файлах. Уявімо, що є 10 годин на написання нових тестів.
Оціни:
- Які два файли/модулі потребують тестів найбільше? Аргументуй через ризик помилки і вартість.
- В яких ситуаціях 100% code coverage не гарантує безпеку? Наведи конкретний приклад з проєкту.
- Порівняй цінність одного IDOR-тесту (test_views.py) і одного __str__ тесту (test_models.py). Що критичніше?
- Напиши список із 5 тестів, яких ти б додав першими, і поясни пріоритет кожного.