test_views.py — Integration тести через Test Client¶
Django Test Client¶
Питання: Що integration тест перевіряє, чого unit тест НЕ може?
self.client — це django.test.Client, вбудований HTTP клієнт для тестів.
# Автоматично доступний у кожному TestCase як self.client
from django.test import TestCase
class MyTest(TestCase):
def test_something(self):
self.client # ← готовий до використання
Він не відкриває реальний браузер. Він симулює HTTP запит всередині Django процесу: мінаючи мережу, але проходячи через весь Django middleware stack.
self.client.get('/notes/')
↓
SecurityMiddleware → перевірка HTTPS заголовків
SessionMiddleware → завантаження сесії з тестової БД
AuthenticationMiddleware → встановлення request.user
CsrfViewMiddleware → перевірка CSRF (відключена для test client POST)
note_list view → @login_required → selectors → QuerySet
HTML Response ← шаблон рендериться з реальними даними
↓
response.status_code → 200 / 302 / 404
response.content → HTML байти
response.context → контекст шаблону (для assertContains)
Що integration тест виявляє, чого unit НЕ виявить¶
| Що перевіряє | Unit (services) | Integration (views) |
|---|---|---|
@login_required захищає view |
✗ | ✓ |
URL routing (/notes/ → note_list) |
✗ | ✓ |
| redirect після POST (302 → /notes/pk/) | ✗ | ✓ |
| form errors у контексті шаблону | ✗ | ✓ |
| Bob отримує 404 при спробі доступу | ✗ | ✓ |
| HTML сторінки містить назву нотатки | ✗ | ✓ |
| Бізнес-логіка create_note() правильна | ✓ | ✓ |
force_login vs login¶
# force_login — швидко, без перевірки пароля (стандарт у тестах)
self.client.force_login(self.alice)
# → не запускає auth pipeline, не перевіряє password
# → просто встановлює session cookie → request.user = alice
# → ідеально для тестів де нас не цікавить сам процес логіну
# login — повна auth pipeline (тільки якщо тестуєш сам процес login)
success = self.client.login(username='alice', password='pass123')
# → authenticate() + login() + session
# → Використовувати тільки у test_selenium або test_login_flow
Ключові атрибути response¶
response = self.client.get(reverse('notes_app:note_list'))
response.status_code # → 200, 302, 404, 403, 500
response.context # → контекст шаблону {'notes': queryset, 'form': form}
response.content # → bytes HTML відповіді
response['Location'] # → URL для редіректу (якщо 302)
response.context['form'].errors # → {'title': ['This field is required.']}
Assert методи для HTTP тестів¶
# HTTP статус коди
self.assertEqual(response.status_code, 200) # OK
self.assertEqual(response.status_code, 302) # redirect
self.assertEqual(response.status_code, 404) # not found
self.assertEqual(response.status_code, 403) # forbidden
# Перевірка redirect
self.assertRedirects(response, reverse('notes_app:note_list'),
fetch_redirect_response=False)
# fetch_redirect_response=False — не робимо другий GET запит
# Текст у HTML
self.assertContains(response, 'Alice Note') # рядок є в HTML
self.assertNotContains(response, 'Bob Secret') # рядка НЕМАЄ в HTML
# Перевірка що URL містить login
self.assertIn('/accounts/login/', response['Location'])
# Form errors
self.assertIn('title', response.context['form'].errors)
# Наявність у БД
self.assertTrue(Note.objects.filter(title='New Note').exists())
self.assertEqual(Note.objects.count(), 1)
Структура test_views.py¶
# notes_app/tests/test_views.py
from django.contrib.auth.models import Group, User
from django.test import TestCase
from django.urls import reverse
from notes_app.models import Note, Notebook, Tag
class BaseViewTest(TestCase):
"""Базовий клас з alice і bob для всіх view тестів."""
def setUp(self):
self.alice = User.objects.create_user('alice', password='pass123')
self.bob = User.objects.create_user('bob', password='pass123')
class AuthenticationViewTest(BaseViewTest): # ← @login_required на всіх views
class NoteListViewTest(BaseViewTest): # ← multi-tenant ізоляція
class NoteDetailViewTest(BaseViewTest): # ← ownership + group access
class NoteCreateViewTest(BaseViewTest): # ← form valid/invalid, IDOR prevention
class NoteEditViewTest(BaseViewTest): # ← ownership check (чужий → 404)
class NoteDeleteViewTest(BaseViewTest): # ← ownership + cascade до БД
class NotebookViewTest(BaseViewTest): # ← ownership via get_object_or_404
class GroupViewTest(BaseViewTest): # ← membership check
class TodoListSharingViewTest(BaseViewTest): # ← share/unshare workflow
Три найважливіших паттерни¶
Паттерн 1 — @login_required: redirect незалогінених¶
class NoteListViewTest(BaseViewTest):
def test_anonymous_redirects_to_login(self):
"""Незалогінений юзер → redirect до /accounts/login/?next=/notes/"""
response = self.client.get(reverse('notes_app:note_list'))
# Не 200! → redirect на login page
self.assertEqual(response.status_code, 302)
self.assertIn('/accounts/login/', response['Location'])
def test_authenticated_gets_200(self):
self.client.force_login(self.alice)
response = self.client.get(reverse('notes_app:note_list'))
self.assertEqual(response.status_code, 200)
def test_note_list_shows_only_user_notes(self):
"""Multi-tenant: Alice не бачить нотатки Bob'а."""
Note.objects.create(user=self.bob, title="Bob's Secret Note")
self.client.force_login(self.alice)
response = self.client.get(reverse('notes_app:note_list'))
self.assertNotContains(response, "Bob's Secret Note")
Паттерн 2 — Ownership check: Bob отримує 404¶
class NoteDetailViewTest(BaseViewTest):
def test_owner_gets_200(self):
note = Note.objects.create(user=self.alice, title='Alice Note')
self.client.force_login(self.alice)
response = self.client.get(
reverse('notes_app:note_detail', args=[note.pk])
)
self.assertEqual(response.status_code, 200)
def test_non_owner_gets_404(self):
"""
Bob намагається відкрити нотатку Alice.
get_object_or_404(Note, pk=pk, user=request.user):
→ SELECT WHERE pk=alice_note.pk AND user_id=bob.id
→ Не знайдено → 404
Чому 404 а не 403?
403: "ресурс існує, але доступ заборонено" → хакер знає що id існує
404: "такого ресурсу немає" → менше інформації для атаки
"""
alice_note = Note.objects.create(user=self.alice, title='Alice Private')
self.client.force_login(self.bob)
response = self.client.get(
reverse('notes_app:note_detail', args=[alice_note.pk])
)
self.assertEqual(response.status_code, 404)
def test_group_member_can_view_shared_note(self):
"""Член групи бачить групову нотатку."""
group = Group.objects.create(name='Team')
group.user_set.add(self.alice, self.bob)
note = Note.objects.create(user=self.alice, title='Team Note', group=group)
self.client.force_login(self.bob)
response = self.client.get(
reverse('notes_app:note_detail', args=[note.pk])
)
self.assertEqual(response.status_code, 200)
Паттерн 3 — POST form: redirect після успіху, 200 при помилці¶
class NoteCreateViewTest(BaseViewTest):
def setUp(self):
super().setUp()
self.url = reverse('notes_app:note_create')
def test_valid_post_creates_note_and_redirects(self):
self.client.force_login(self.alice)
response = self.client.post(self.url, data={
'title': 'New Note',
'content': 'Content',
'priority': 2,
})
# Перевіряємо redirect після успішного POST (PRG паттерн)
self.assertEqual(response.status_code, 302)
# Перевіряємо що запис існує у БД
self.assertTrue(Note.objects.filter(title='New Note', user=self.alice).exists())
def test_invalid_post_returns_form_with_errors(self):
"""Порожній title → форма не валідна → 200 (не redirect)."""
self.client.force_login(self.alice)
response = self.client.post(self.url, data={
'title': '', # порожній → invalid
'priority': 1,
})
self.assertEqual(response.status_code, 200)
self.assertFalse(Note.objects.exists()) # нотатка не створилась
Паттерн 4 — IDOR prevention через form¶
class NoteCreateIDORTest(BaseViewTest):
def test_note_create_with_bobs_notebook_fails(self):
"""
Alice намагається POST /notes/new/ з notebook=bob_notebook.id
БЕЗ захисту у формі: Alice могла б записати нотатку до Bob's notebook
З захистом (NoteForm queryset): форма invalid → 200, нотатка не створена
"""
bob_nb = Notebook.objects.create(user=self.bob, title="Bob's Private")
self.client.force_login(self.alice)
response = self.client.post(
reverse('notes_app:note_create'),
data={
'title': 'IDOR Attack',
'notebook': bob_nb.pk, # ← чужий записник
'priority': 1,
}
)
# Форма invalid → 200 (не redirect)
self.assertEqual(response.status_code, 200)
# Нотатка не створилась → IDOR blocked ✓
self.assertFalse(Note.objects.filter(title='IDOR Attack').exists())
Схема доступу: що повертає кожен view для кожного юзера¶
alice bob (not member) bob (member of group)
note_list 200 200 (own notes) 200 (own notes)
note_detail (own) 200 404 404
note_detail (group) 200 404 200 ← group access!
note_edit 200 404 302+error
note_delete 302 ok 404 302+error
notebook_edit 200 404 404
group_detail 200 404 200 ← if member
group_delete 302 ok 403 403
Що перевіряє кожна категорія тестів¶
AuthenticationViewTest ← @login_required на кожному view
NoteListViewTest ← multi-tenant: alice бачить тільки свої нотатки
NoteDetailViewTest ← ownership + group-based access
NoteCreateViewTest ← form valid/invalid, IDOR prevention, note.user = request.user
NoteEditViewTest ← ownership: чужий → 404; свій → 302
NoteDeleteViewTest ← ownership + cascade: нотатка видалена з БД
NotebookViewTest ← ownership: get_object_or_404(user=request.user)
GroupViewTest ← membership check: чужий → 404, non-member delete → 403
TodoListSharingViewTest ← share/unshare workflow через HTTP
Запуск integration тестів¶
# crispy_notes_project:
python manage.py test hello_app.tests.test_views -v 2
# notes_chat_app (Docker):
docker compose run --rm web python manage.py test notes_app.tests.test_views -v 2
# Конкретний клас:
docker compose run --rm web python manage.py test \
notes_app.tests.test_views.NoteDetailViewTest -v 2
# Конкретний тест:
docker compose run --rm web python manage.py test \
notes_app.tests.test_views.NoteDetailViewTest.test_group_member_can_view_shared_note -v 2
Далі¶
- test_consumers.py — WebSocket тести (тільки notes_chat_app)