Введение в тестирование с помощью Django для Python
В мире постоянно меняющихся технологий тестирование является неотъемлемой частью написания надежного и надежного программного обеспечения. Тесты проверяют, что ваш код ведет себя должным образом, упрощают его поддержку и рефакторинг, а также служат документацией для вашего кода.
Для тестирования приложений Django широко используются две широко используемые среды тестирования:
- Встроенная среда тестирования Django, построенная на модульном тесте Python.
- Pytest в сочетании с pytest-django.
В этой статье мы увидим, как работают оба.
Давайте начнем!
Что мы будем тестировать?
Все тесты, которые мы будем наблюдать, будут использовать один и тот же код — конечную точку BookList.
Модель Book
имеет поля title
, author
и date_published
. Обратите внимание, что порядок по умолчанию устанавливается с помощью date_published
.
class Book(models.Model):
title = models.CharField(max_length=20)
author = models.ForeignKey(Author, on_delete=models.CASCADE, blank=True, null=True)
date_published = models.DateField(null=True, blank=True)
class Meta:
ordering = ['date_published']
Просто представление ListView
:
from django.views.generic import ListView
from .models import Book
class BookListView(ListView):
model = Book
Поскольку тестовые примеры будут проверять конечную точку, нам также понадобится URL-адрес:
from django.urls import path
from . import views
urlpatterns = [
path("books/", views.BookListView.as_view(), name="book_list"),
]
Теперь о тестах.
Unittest для Python
Unittest — это встроенная среда тестирования Python. Django расширяет его некоторыми собственными функциями.
Поначалу вы можете запутаться в том, какие методы принадлежат unittest и каковы расширения Django.
Unittest обеспечивает:
- TestCase, выступающий в качестве базового класса.
setUp()
иtearDown()
методы для кода, который вы хотите выполнить до или после каждого метода тестирования.setUpClass()
иtearDownClass()
также запускают один раз за целоеTestClass
.- Несколько типов утверждений, таких как
assertEqual
,assertAlmostEqual
иassertIsInstance
.
Основной частью среды тестирования Django является TestCase.
Поскольку он является подклассом unittest.TestCase
, TestCase
имеет все те же функциональные возможности, но также строится на его основе:
- Метод класса setUpTestData: данные создаются один раз для каждого теста
TestCase
(в отличие от методаsetUp
, который создает тестовые данные один раз для каждого теста). ИспользованиеsetUpTestData
может значительно ускорить ваши тесты. - Тестовые данные загружаются через fixtures.
- Утверждения, специфичные для Django, такие как
assertQuerySetEqual
,assertFormError
иassertContains
. - Временные настройки переопределения, которые можно настроить во время тестового запуска.
Платформа тестирования Django также предоставляет клиент, который не зависит от TestCase
и поэтому может использоваться с pytest. Client
служит фиктивным браузером, позволяющим пользователям создавать GET
и POST
запросы.
Как создаются тесты с помощью Unittest
Unittest поддерживает обнаружение тестов, но необходимо соблюдать следующие правила:
- Тест должен находиться в файле с префиксом
test
. - Тест должен находиться внутри класса, который является подклассом
unittest.TestCase
(включая использованиеdjango.TestCase
). Включение «Test» в имя класса не является обязательным. - Имя метода тестирования должно начинаться с
test_
.
Давайте посмотрим, как выглядят тесты, написанные с помощью unittest:
from datetime import date
from django.test import TestCase
from django.urls import reverse
from .models import Book, Author
class BookListTest(TestCase):
@classmethod
def setUpTestData(cls):
author = Author.objects.create(first_name='Jane', last_name='Austen')
cls.second_book = Book.objects.create(title='Emma', author=author, date_published=date(1815, 1, 1))
cls.first_book = Book.objects.create(title='Pride and Prejudice', author=author, date_published=date(1813, 1, 1))
def test_book_list_returns_all_books(self):
response = self.client.get(reverse('book_list'))
response_book_list = response.context['book_list']
self.assertEqual(response.status_code, 200)
self.assertIn(self.first_book, response_book_list)
self.assertIn(self.second_book, response_book_list)
self.assertEqual(len(response_book_list), 2)
def test_book_list_ordered_by_date_published(self):
response = self.client.get(reverse('book_list'))
books = list(response.context['book_list'])
self.assertEqual(response.status_code, 200)
self.assertQuerySetEqual(books, [self.first_book, self.second_book])
Вы всегда должны помещать свои тестовые функции внутри класса, который расширяет класс TestCase
. Хотя это и не обязательно, мы включили слово «Test» в имя нашего класса, чтобы с первого взгляда его можно было легко узнать как тестовый класс. Обратите внимание, что файл TestCase
импортируется из Django, а не из unittest — вам нужен доступ к дополнительным функциям Django.
Что тут происходит?
Целью этого TestCase
является проверка правильности работы конечной точки book_list
. Существует два теста: один проверяет, что все объекты Book
в базе данных включены в ответ как book_list
, а второй проверяет, что книги перечислены по возрастанию даты публикации.
Хотя тесты кажутся похожими, они оценивают две разные функции, которые не следует объединять в один тест.
Поскольку обоим тестам нужны одни и те же данные в базе данных, для подготовки данных мы используем метод класса setUpTestData
— оба теста будут иметь к ним доступ, не повторяя процесс дважды. В отличие от двух объектов Book
, этот объект Author
не нужен вне метода установки, поэтому мы не устанавливаем его как атрибут экземпляра. Я поменял порядок двух книг, чтобы гарантировать, что правильный порядок не является случайным.
Эти два теста выглядят очень похоже — они оба отправляют запрос GET
на один и тот же URL-адрес и извлекают файл book_list
из context
. Методы внутри класса TestCase
автоматически имеют доступ к client
, который используется для выполнения запроса.
До этого момента оба теста делали одно и то же, но утверждения различались.
Первый тест утверждает, что обе книги находятся в контексте ответа, используя assertIn
. Это также обеспечивает длину объекта response.context['book_list']
. Во втором тесте используется Django assertQuerySetEqual
. Это утверждение проверяет порядок по умолчанию, поэтому оно делает именно то, что нам нужно.
Оба теста также проверяют status_code
, поэтому вы сразу узнаете, если URL-адрес не работает.
Запуск нашего теста с помощью Unittest
Мы можем запустить тест с помощью тестовой команды Django:
(venv)$ python manage.py test
Это результат:
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.089s
OK
Destroying test database for alias 'default'...
Что, если мы проведем неудачный тест? Например, если порядок возвращаемых объектов не соответствует желаемому результату:
(venv)$ python manage.py test
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_book_order (bookstore.test.BookListTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/bookstore/test.py", line 17, in test_book_order
self.assertEqual(books, [self.second_book, self.first_book])
AssertionError: Lists differ: [<Book: Pride and Prejudice>, <Book: Emma>] != [<Book: Emma>, <Book: Pride and Prejudice>]
First differing element 0:
<Book: Pride and Prejudice>
<Book: Emma>
- [<Book: Pride and Prejudice>, <Book: Emma>]
+ [<Book: Emma>, <Book: Pride and Prejudice>]
----------------------------------------------------------------------
Ran 2 tests in 0.085s
FAILED (failures=1)
Destroying test database for alias 'default'...
Как видите, вывод весьма информативен — вы узнаете, какой тест не пройден, в какой строке и почему. Тест не удался, поскольку списки различаются; вы даже можете просмотреть оба списка, чтобы быстро определить, в чем заключается проблема.
Еще одна важная вещь, на которую следует обратить внимание, это то, что только второй тест не удался — выяснить, что не так, может быть сложнее, если есть один тест.
Pytest для Python
Pytest — отличная альтернатива unittest. Несмотря на то, что он не является встроенным Python
, он считается более питоническим, чем unittest. Он не требует TestClass
, имеет меньше шаблонного кода и имеет простой оператор assert
. Pytest имеет богатую экосистему плагинов, включая специальный плагин Django, pytest-django.
Подход pytest сильно отличается от unittest.
Среди прочего:
- Вы можете писать тесты на основе функций без необходимости использования классов.
- Он позволяет создавать приспособления: компоненты многократного использования для установки и разборки. Фикстуры могут иметь разные области действия (например,
module
), что позволяет оптимизировать производительность, сохраняя при этом изолированность теста. - Параметризация тестовой функции означает, что ее можно эффективно запускать с разными аргументами.
- У него есть одно простое утверждение
assert
.
Pytest и Django
pytest-django служит адаптером между Pytest и Django. Тестирование Django с помощью pytest без pytest-django технически возможно, но непрактично и не рекомендуется. Помимо обработки настроек, статических файлов и шаблонов Django, он добавляет в pytest некоторые инструменты тестирования Django, а также добавляет свои собственные:
- Декоратор
@pytest.mark.django_db
, обеспечивающий доступ к базе данных для определенного теста. client
, который передает DjangoClient
в виде фикстуры.- Устройство
admin_client
возвращает аутентифицированный доступClient
с правами администратора. - Фикстура
settings
, позволяющая временно отменять настройки Django во время теста. - Те же специальные утверждения Django, что и
Django TestCase
.
Как создаются тесты с помощью Pytest
Как и unittest, pytest также поддерживает обнаружение тестов. Как вы увидите ниже, стандартные правила (эти правила можно легко изменить):
- Файлы должны называться
test_*.py
или*_test.py
. - Тестовые классы не нужны, но если вы хотите их использовать, имя должно иметь префикс Test.
- Имена функций должны начинаться с префикса test.
Давайте посмотрим, как выглядят тесты, написанные с помощью pytest:
from datetime import date
import pytest
from django.urls import reverse
from bookstore.models import Author, Book
@pytest.mark.django_db
def test_book_list_returns_all_books(client):
author = Author.objects.create(first_name='Jane', last_name='Austen')
second_book = Book.objects.create(title='Emma', author=author, date_published=date(1815, 1, 1))
first_book = Book.objects.create(title='Pride and Prejudice', author=author, date_published=date(1813, 1, 1))
response = client.get(reverse('book_list'))
books = list(response.context['book_list'])
assert response.status_code == 200
assert first_book in books
assert second_book in books
assert len(books) == 2
@pytest.mark.django_db
def test_book_list_ordered_by_date_published(client):
author = Author.objects.create(first_name='Jane', last_name='Austen')
second_book = Book.objects.create(title='Emma', author=author, date_published=date(1815, 1, 1))
first_book = Book.objects.create(title='Pride and Prejudice', author=author, date_published=date(1813, 1, 1))
response = client.get(reverse('book_list'))
books = list(response.context['book_list'])
assert response.status_code == 200
assert books == [first_book, second_book]
Что тут происходит?
Эти два теста оценивают ту же функциональность, что и два предыдущих модульных теста: первый тест гарантирует, что все книги в базе данных возвращены, а второй гарантирует, что книги упорядочены по дате публикации. Более подробную информацию о модульных тестах вы можете узнать в нашей статье "Профессиональные модульные тесты на Python".
Итак, чем же они отличаются?
- Они полностью автономны — классная или совместная подготовка данных не требуется. Вы можете поместить их в два разных файла, и ничего не изменится.
- Поскольку им нужен доступ к базе данных, им требуется декоратор
@pytest.mark.django_db
из pytest-django. Если доступ к БД не требуется, пропустите декоратор. - Django
client
необходимо передавать как приспособление, поскольку у вас нет автоматического доступа к нему. Опять же, это исходит от pytest-django. - Вместо различных утверждений
assert
используется простой.
Строки, выполняющие запрос и возвращающие ответ book_list
, точно такие же, как и в unittest.
Фикстуры в Pytest
В pytest нет setUpTestData
. Но если вы обнаружите, что повторяете один и тот же код подготовки данных снова и снова, вы можете использовать фикстуру.
В нашем примере приспособление будет выглядеть так:
import pytest
@pytest.fixture
def two_book_objects_same_author():
author = Author.objects.create(first_name='Jane', last_name='Austen')
second_book = Book.objects.create(title='Emma', author=author, date_published=date(1815, 1, 1))
first_book = Book.objects.create(title='Pride and Prejudice', author=author, date_published=date(1813, 1, 1))
return [first_book, second_book]
# fixture usage
@pytest.mark.django_db
def test_book_list_ordered_by_date_published_with_fixture(client, two_book_objects_same_author):
response = client.get(reverse('book_list'))
books = list(response.context['book_list'])
assert response.status_code == 200
assert books == two_book_objects_same_author
Вы отмечаете фикстуру с помощью декоратора @pytest.fixture
. Затем вам нужно передать его в качестве аргумента для использования функции. Вы можете использовать фикстуры, чтобы избежать повторяющегося кода на этапе подготовки или улучшить читаемость, но не переусердствуйте.
Запуск теста с помощью Pytest для Django
Чтобы pytest работал правильно, вам нужно сообщить django-pytest, где можно найти настройки Django. Хотя это можно сделать в терминале (pytest --ds=bookstore.settings
), лучше использовать pytest.ini.
В то же время вы можете использовать pytest.ini для предоставления других параметров конфигурации.
pytest.ini выглядит примерно так:
[pytest]
;where the django settings are
DJANGO_SETTINGS_MODULE = bookstore.settings
;changing test discovery
python_files = tests.py test_*.py
;output logging records into the console
log_cli = True
После установки DJANGO_SETTINGS_MODULE
вы можете просто запустить тесты с помощью:
(venv)$ pytest
Давайте посмотрим результат:
================================================================================================= test session starts ==================================================================================================
platform darwin -- Python 3.10.5, pytest-7.4.3, pluggy-1.3.0
django: settings: bookstore.settings (from option)
rootdir: /bookstore
plugins: django-4.5.2
collected 2 items
bookstore/test_with_pytest.py::test_book_list_returns_all_books_with_pytest PASSED [ 50%]
bookstore/test_with_pytest.py::test_book_list_ordered_by_date_published_with_pytest PASSED [100%]
================================================================================================== 2 passed in 0.58s ===================================================================================================
Этот вывод отображается, если установлено log_cli
значение True
. Если он не задан явно, по умолчанию используется значение False
, а выходные данные каждого теста заменяются простой точкой.
И как будет выглядеть результат, если тест не пройден?
================================================================================================= test session starts ==================================================================================================
platform darwin -- Python 3.10.5, pytest-7.4.3, pluggy-1.3.0
django: settings: bookstore.settings (from ini)
rootdir: /bookstore
configfile: pytest.ini
plugins: django-4.5.2
collected 2 items
bookstore/test_with_pytest.py::test_book_list_returns_all_books_with_pytest PASSED [ 50%]
bookstore/test_with_pytest.py::test_book_list_ordered_by_date_published_with_pytest FAILED [100%]
======================================================================================================= FAILURES =======================================================================================================
___________________________________________________________________________________________________ test_book_order ____________________________________________________________________________________________________
client = <django.test.client.Client object at 0x1064dbfa0>, books_in_correct_order = [<Book: Emma>, <Book: Pride and Prejudice>]
@pytest.mark.django_db
def test_book_order(client, books_in_correct_order):
response = client.get(reverse('book_list'))
books = list(response.context['book_list'])
> assert books == books_in_correct_order
E assert [<Book: Pride... <Book: Emma>] == [<Book: Emma>...nd Prejudice>]
E At index 0 diff: <Book: Pride and Prejudice> != <Book: Emma>
E Full diff:
E - [<Book: Emma>, <Book: Pride and Prejudice>]
E + [<Book: Pride and Prejudice>, <Book: Emma>]
=============================================================================================== short test summary info ================================================================================================
FAILED bookstore/test_with_pytest.py::test_book_list_ordered_by_date_published_with_pytest - assert [<Book: Pride... <Book: Emma>] == [<Book: Emma>...nd Prejudice>]
============================================================================================= 1 failed, 1 passed in 0.78s ==============================================================================================
Если log_cli
установлено значение True
, вы можете легко увидеть, какой тест пройден, а какой нет. Несмотря на то, что сообщение об ошибке содержит длинный отчет, вы часто можете найти достаточно информации о том, почему тест не пройден, в краткой сводной информации о тесте.
Unittest против Pytest для Python
Нет единого мнения относительно того, что лучше: unittest или pytest. Мнения разработчиков расходятся, в чем можно убедиться на StackOverflow или Reddit. Многое зависит от ваших собственных предпочтений или предпочтений вашей команды. Если большая часть вашей команды привыкла к pytest, нет необходимости переключаться, и наоборот.
Однако, если у вас еще нет предпочтительного способа тестирования, вот быстрое сравнение между ними:
unittest (с тестом Django) | pytest (с pytest-django) |
Никакой дополнительной установки не требуется | Требуется установка pytest и pytest-django. |
Классовый подход | Функциональный подход |
Несколько типов утверждений | Простое assert заявление |
Методы установки/разрыва внутри TestCase класса | Система креплений с различными возможностями |
Параметризация не поддерживается (но ее можно выполнить самостоятельно) | Встроенная поддержка параметризации |
Как подходить к тестированию в Django
Django поощряет быструю разработку, поэтому вам может показаться, что тесты вам на самом деле не нужны, поскольку они просто замедлят вашу работу. Однако в долгосрочной перспективе тесты сделают процесс разработки более быстрым и менее подверженным ошибкам. Поскольку Django предоставляет множество строительных блоков, которые позволяют вам добиться многого с помощью всего лишь нескольких строк кода, вы можете в конечном итоге написать гораздо больше тестов, чем реального кода.
Одна вещь, которая может помочь вам покрыть большую часть вашего кода тестами, — это покрытие кода. « Покрытие кода » — это показатель, показывающий, какой процент кода вашей программы выполняется во время выполнения набора тестов. Чем выше процент, тем большую часть тестируемого кода обычно означает меньшее количество незамеченных проблем.
Coverage.py — это инструмент для измерения покрытия кода программ Python. После установки вы можете использовать его либо с unittest, либо с pytest.
Если вы используете pytest, вы можете установить pytest-cov — библиотеку, которая интегрирует Coverage.py
с pytest. Он широко используется, но в официальной документации Coverage.py указано, что он по большей части не нужен.
Команды для запуска покрытия для unittest и pytest немного отличаются:
- Для unittest:
coverage run manage.py test
- Для pytest:
coverage run -m pytest
Эти команды будут запускать тесты и проверять покрытие, но для получения результатов необходимо запустить отдельную команду.
Чтобы увидеть отчет в вашем терминале, вам нужно выполнить coverage report
команду:
$(venv) coverage report
Name Stmts Miss Cover
------------------------------------------------------------------------------------------------------
core/__init__.py 0 0 100%
core/settings.py 34 0 100%
core/urls.py 3 0 100%
bookstore/__init__.py 0 0 100%
bookstore/admin.py 19 1 95%
bookstore/apps.py 6 0 100%
bookstore/models.py 64 7 89%
bookstore/urls.py 3 0 100%
bookstore/views.py 34 7 79%
------------------------------------------------------------------------------------------------------
TOTAL 221 15 93%
Проценты в диапазоне от 75% до 100% считаются «хорошим охватом». Стремитесь к результату выше 75 %, но имейте в виду, что если ваш код хорошо покрыт тестами, это еще не значит, что он хорош.
Примечание о тестах
Тестовое покрытие — один из аспектов хорошего набора тестов. Однако, если ваш охват не ниже 75%, точный процент не так уж важен.
Не пишите тесты просто ради их написания или для повышения процента покрытия. Не все в Django нуждается в тестировании — написание бесполезных тестов замедлит работу вашего набора тестов и сделает рефакторинг медленным и болезненным процессом.
Не следует тестировать собственный код Django — он уже протестирован. Например, вам не нужно писать тест, который проверяет, получен ли объект с помощью get_object_or_404
. В наборе тестов Django это уже предусмотрено.
Кроме того, не каждую деталь реализации необходимо тестировать. Например, я редко проверяю, правильный ли шаблон используется для ответа — если я изменю имя, тест завершится неудачей, не предоставив никакой реальной ценности.
Как AppSignal может помочь в тестировании в Django?
Проблема с тестами в том, что они пишутся изолированно — когда ваше приложение работает, оно может вести себя по-другому, и пользователь, вероятно, будет вести себя не так, как вы ожидали.
Вот почему рекомендуется использовать мониторинг приложений — он может помочь вам отслеживать ошибки и контролировать производительность. Самое главное, он может сообщить вам, когда ошибка является фатальной и нарушает работу вашего приложения.
AppSignal интегрируется с Django и может помочь вам найти ошибки, которые вы упускаете при написании тестов.
С помощью AppSignal вы можете увидеть, как часто и когда возникает ошибка. Знание этого может помочь вам решить, насколько срочно вам нужно исправить ошибку.
Поскольку вы можете интегрировать AppSignal со своим репозиторием Git, вы также можете точно определить строку, в которой возникает ошибка. Это упрощает процесс определения причины ошибки и облегчает ее исправление. Когда ошибка хорошо понята, легче добавить тесты, которые предотвратят ее и подобные ошибки в будущем.
Вот пример ошибки из списка «Проблемы» на панели «Ошибки» для приложения Django в AppSignal:
Подведение итогов
В этом посте мы увидели, что у вас есть два отличных варианта тестирования в Django: unittest и pytest. Мы представили оба варианта, подчеркнув их уникальные сильные стороны и возможности. Какой из них вы выберете, во многом зависит от ваших личных предпочтений или предпочтений конкретного проекта.
Надеюсь, вы поняли, насколько важно написание тестов для создания высококачественного продукта, обеспечивающего удобство работы с пользователем.
Приятного кодирования!