Тестируем Django с pytest.

Тестируем Django с pytest.

В текущем посте мы рассмотрим как производится тестирование приложения Django с помощью фреймворка pytest, рассмотрим механизмы тестирования, дополнительные модули и плагины, которые значительно упрощают данный процесс, позволяют выполнить параллельный запуск тестов, проанализировать покрытие кода тестами.

Данная статья основана на великолепной статье Дмитрия Чувардинского Testing Your Django App With Pytest (opens new window).

# Преимущества и недостатки pytest перед unittest

Прежде всего давайте рассмотрим зачем нам использовать pytest, а не остановится на стандартном, встроенном в Python модуле тестирования unittest.

# Преимущества pytest

  • Не зависит от API, меньше дублирующего кода, просто реализуйте тесты как функции с assert;
  • Использует стандартный assert (не требуется помнить конструкции вроде self.assert...);
  • Предоставляет подробный отчет. С цветной подсветкой и возможностью настройки с помощью дополнительных модулей;
  • Динамические фикстуры с возможностью вызова как автоматически, так и для конкретных тестов;
  • Дополнительные возможности фикстур (автоматическое использование, возвращаемые значения, финализаторы, области видимости, объект request, вложенные фикстуры и т.д.);
  • Автоматическое обнаружение модулей и функций тестирования;
  • Параметризация тестов, т.е запуск одного и того же теста с различными наборами параметров.
  • Метки, позволяющие отмечать тесты как неуспешные, игнорируемые, объединять тесты к группы чтобы можно было запускать их по имени;
  • Огромное количество плагинов позволяющих расширить возможности системы тестирования, и еще больше упростить написание тестов;
  • Обратная совместимость с тестами написанными на unittest и nose. Таким образом если есть тесты реализованные на unittest или nose их не потребуется переписывать.

# Недостатки использования pytest:

  • Требует отдельной установки модуля. Но данный недостаток может быть одновременно и преимуществом, т.к. отсутствует зависимость от версии Python. Если требуется использовать новые инструменты pytest достаточно обновить версию модуля;
  • Отсутствует дополнительный уровень вложенности, что создает некоторые неудобства в случае когда одна и та же функция может иметь несколько testcase'ов (частично решается с помощью плагина pytest-describe (opens new window).

# Установка pytest

Pytest устанавливается как обычный pip пакет:

pip install pytest

# Фикстуры pytest

Фикстуры в pytest - функции выполняемые в начале и в конце выполнения каждого отдельного теста, подобно setUp и tearDown в unittest. Фикстуры используются для создания данных конфигурации, установки соединения и отключения от базы данных, вызова дополнительных операций и т.д. Для фикстур могут быть заданы области видимости (scope) определяющие когда выполняется код приведенный в фикстуре. Данный параметр может иметь следующие значения:

  • function - фикстура уничтожается по завершению теста, данное значение используется по умолчанию;
  • class - фикстура уничтожается после выполнения последнего теста в классе;
  • module - фикстура уничтожается после выполнения последнего теста в модуле;
  • package - фикстура уничтожается после выполнения последнего теста в пакете;
  • session - фикстура уничтожается после завершения тестовой сессии. Кроме того фикстура может быть создана с использованием оператора yield таким образом предоставляя доступ к выполнению кода перед вызовом теста и после его выполнения, аналогично setUp и tearDown в одном флаконе.

Пример создания фикстур:

import pytest

@pytest.fixture
def function_scope_fixture():
  print('Фикстура выполняется для каждого теста")
  return 'function_scope'

@pytest.fixture(scope='class')
def class_scope_fixture():
  print('Фикстура выполняется для класса тестов")
  return 'class_scope'

@pytest.fixture
def setup_teardown_fixture():
  # Код выполняемый до начала теста
  yield 'some_data' # Передача управления тесту.
  # Код выполняемый после выполнения теста

Для использования фикстуры достаточно передать фикстуру в качестве параметра функции тестирования:

def test_function(function_scope_fixture):
  assert function_scope_fixture == 'function_scope'

Фикстура также может быть передана в качестве параметра для другой фикстуры расширяющей ее функционал:

@pytest.fixture
def extend_fixture(function_scope_fixture):
  return f'{function_scope_fixture}_extended'

# Маркеры pytest

Маркеры позволяют добавить метаданные к функционалу тестов такие как:

  • skip - всегда пропускать данную функцию тестирования;
  • skipif - пропускать тест при определенном условии;
  • xfail - используется чтобы обозначить что ожидается провал данного теста.
import pytest

@pytest.mark.skip(reason='не готов для тестирования')
def test_skipped():
  pass

@pytest.mark.skipif(sys.version_info < (3, 7), reason='Требует Python 3.7 и выше')
def test_function():
  # some code

@pytest.mark.xfail
def test_failed():
  return 1/0

Кроме того в файле pytest.ini могут быть заданы пользовательские маркеры:

pytest.ini

[pytest]
markers = 
  database: database tests marker
  failed: failed tests marker

Для запуска отмеченных тестов используется флаг -m причем параметры запуска тестов могут быть заданы логическим выражением с использованием or, not, and:

python -m "database and not failed" --strict-markers

# Параметризация тестов

Параметризация позволяет запустить один и тот же тест с различным набором параметров и реализована в pytest как маркер: @pytest.mark.parametrize далее в строке задается перечень используемых полей через запятую, а за ним список в котором в кортежах указывается отдельный набор параметров который будет применен при каждом запуске теста. Тема параметризации в pytest довольна обширна и выходит за рамки данного поста ознакомится с ней можно в официальной документации pytest (opens new window). Простой пример использования параметризации:

import pytest

@pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])
def test_eval(test_input, expected):
    assert eval(test_input) == expected

# Настройка pytest для проекта Django

Для упрощения процесса тестирования Django приложения был разработан плагин pytest-django (opens new window) предоставляющий набор инструментов для тестирования приложений и проектов Django. Установка данного плагина выполняется следующим образом:

pip install pytest-django

Далее необходимо указать в качестве параметра DJANGO_SETTINGS_MODULE путь к settings и сделать ваши тесты доступными для обнаружения в файле pytest.ini в корне проекта:

pytest.ini

# -- FILE: pytest.ini (or tox.ini)
[pytest]
DJANGO_SETTINGS_MODULE = your_project.settings
# -- recommended but optional:
python_files = tests.py test_*.py *_tests.py

После чего запуск тестов станет доступен по команде pytest:

pytest #запуск всех тестов
pytest path # запуск тестов находящихся по пути path
pytest test_some_function.py # запуск всех тестов в модуле test_some_function
pytest test_some_function.py::test_one # запуск функции test_one в модуле test_some_function

Использование pytest напрямую дает следующие преимущества по отношению к использованию manage.py:

  • сокращает объем кода, нет необходимости импортировать unittest, создавать классы с методами. Достаточно просто реализовать обычные функции выполняющие тестирование;
  • Позволяет управлять зависимостями с помощью фикстур;
  • Позволяет запускать несколько процессов, чтобы увеличить скорость;
  • Использовать дополнительные плагины доступные для pytest;
  • Лего переключаться, существующие unittest тесты доступны для выполнения без какой либо модификации.

# Тестирование Django с pytest

Фикстуры для подключения к БД

После установки pytest-django нам становятся доступны вспомогательные фикстуры Django helpers (opens new window). Фикстура django_db предоставляет доступ к тестовой БД Django. Каждый тест будет запущен в отдельной транзакции которая будет возвращена к исходному состоянию в конце теста. Обычно в тестах используется именно данная фикстура. При необходимости использовать транзакции следует указать параметр transaction=True, если требуется использовать транзакции с возможностью сброса автоматически инкрементированных значений используется параметр reset_sequences=True.

import pytest
from django.contrib.auth.models import User

@pytest.mark.django_db
def test_user_create():
  User.objects.create_user('user', 'user@mail.com', 'password')
  assert User.objects.count() == 1

Также имеются фикстуры для доступа к БД имеющие специальное назначение db, transactional_db, django_db_reset_sequences.

Фикстура Client

Для обращения к представлению Django используется встроенная pytest-django фикстура client, для доступа с правами суперпользователя используется фикстура admin_client:

import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_view_unauthorized(client):
   url = reverse('home')
   response = client.get(url)
   assert response.status_code == 401

@pytest.mark.django_db
def test_view_as_admin(admin_client):
   url = reverse('home')
   response = admin_client.get(url)
   assert response.status_code == 200

Фикстура для создания пользователя

Для создания пользователя мы используем следующую фикстуру:

import uuid
import pytest

@pytest.fixture
def test_password():
   return 'strong-test-pass'
  
@pytest.fixture
def create_user(db, django_user_model, test_password):
   def make_user(**kwargs):
       kwargs['password'] = test_password
       if 'username' not in kwargs:
           kwargs['username'] = str(uuid.uuid4())
       return django_user_model.objects.create_user(**kwargs)
   return make_user

Т.к. фикстура pytest не может принимать аргументы, создана функция create_user передающая дополнительные аргументы в make_user.

Использование данной фикстуры может выглядеть следующим образом:

import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_user_detail(client, create_user):
   user = create_user(username='someone')
   url = reverse('user-detail-view', kwargs={'pk': user.pk})
   response = client.get(url)
   assert response.status_code == 200
   assert 'someone' in response.content

@pytest.mark.django_db
def test_superuser_detail(client, create_user):
   admin_user = create_user(
       username='custom-admin-name',
       is_staff=True, is_superuser=True
   )
   url = reverse(
       'superuser-detail-view', kwargs={'pk': admin_user.pk}
   )
   response = client.get(url)
   assert response.status_code == 200
   assert 'custom-admin-name' in response.content

Клиент с автоматическим входом

Создадим фикстуру для выполнения автоматическохо входа в систему (auto login):

import pytest

@pytest.fixture
def auto_login_user(db, client, create_user, test_password):
   def make_auto_login(user=None):
       if user is None:
           user = create_user()
       client.login(username=user.username, password=test_password)
       return client, user
   return make_auto_login

Данная фикстура получает пользователя в качестве параметра либо создает нового и выполняет вход. В конце возвращает клиент и пользователя для последующих действий.

Использование данной фикстуры для тестирования:

import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_auth_view(auto_login_user):
   client, user = auto_login_user()
   url = reverse('auth-url')
   response = client.get(url)
   assert response.status_code == 200

Тестирование Mail Outbox

Для тестирования исходящей почты pytest-django имеет встроенную фикстуру mailoutbox:

import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_send_report(auto_login_user, mailoutbox):
   client, user = auto_login_user()
   url = reverse('send-report-url')
   response = client.post(url)
   assert response.status_code == 201
   assert len(mailoutbox) == 1
   mail = mailoutbox[0]
   assert mail.subject == f'Report to {user.email}'
   assert list(mail.to) == [user.email]

Для тестирования мы используем созданную ранее auto_login_user фикстуру и встроенную mailoutbox фикстуру.

Подводя итог преимуществам подхода, продемонстрированного выше: pytest учит нас, как легко настроить наши тесты, чтобы мы могли больше сосредоточиться на тестировании основных функций.

# Тестирование Django REST Framework с pytest

API Client

Прежде всего создадим фикстуру для API Client REST фреймворка:

import pytest

@pytest.fixture
def api_client():
   from rest_framework.test import APIClient
   return APIClient()

Теперь у нас есть api_client для наших тестов:

import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_unauthorized_request(api_client):
   url = reverse('need-token-url')
   response = api_client.get(url)
   assert response.status_code == 401

Получение и создание Token

Для авторизации нашего пользователя API обычно используется токен. Создадим фикстуру для получения либо создания токена ля пользователя:

import pytest
from rest_framework.authtoken.models import Token

@pytest.fixture
def get_or_create_token(db, create_user):
   user = create_user()
   token, _ = Token.objects.get_or_create(user=user)
   return token

метод get_or_create использует create_user фикстуру которая создана выше. Теперь можем реализовать тест с авторизацией:

import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_unauthorized_request(api_client, get_or_create_token):
   url = reverse('need-token-url')
   token = get_or_create_token()
   api_client.credentials(HTTP_AUTHORIZATION='Token ' + token.key)
   response = api_client.get(url)
   assert response.status_code == 200

Автоматическая аутентификация

API клиент позволяет выполнять аутинтификацию в автоматическом режиме. Для того чтобы выполнить код с заданными правами и по завершению его восстановить права мы можем использовать yield:

import pytest

@pytest.fixture
def api_client_with_credentials(
   db, create_user, api_client
):
   user = create_user()
   api_client.force_authenticate(user=user)
   yield api_client
   api_client.force_authenticate(user=None)

Тест с авторизацией станет проще:

import pytest
from django.urls import reverse

@pytest.mark.django_db
def test_unauthorized_request(api_client, get_or_create_token):
   url = reverse('need-token-url')
   response = api_client_with_credentials.get(url)
   assert response.status_code == 200

Проверка валидации данных с Pytest.parametrize

Если требуется запустить один и тот же тест с различными параметрами мы можем использовать параметризацию тестов pytest.mark.parametrize:

import pytest

@pytest.mark.django_db
@pytest.mark.parametrize(
   'email, password, status_code', [
       (None, None, 400),
       (None, 'strong_pass', 400),
       ('user@example.com', None, 400),
       ('user@example.com', 'invalid_pass', 400),
       ('user@example.com, 'strong_pass', 201),
   ]
)
def test_login_data_validation(
   email, password, status_code, api_client
):
   url = reverse('login-url')
   data = {
       'email': email,
       'password': password
   }
   response = api_client.post(url, data=data)
   assert response.status_code == status_code

Использование pytest-mock

При проведении тестирования периодически нам приходится тестировать функционал который зависит от внешних связей (обращение к сетевым ресурсам, файловой системе и т.п.). Для тестирования взаимодействия с такими объектами используются Mock-объекты, которые позволяют верунть ожидаемые значения и произвести тестирование без сложных зависимостей. Python предлагает для этих целей unittes.mock модуль, который может быть использован в pytest. Но существует пакет pytest-mock (opens new window) который позволяет упростить работу с unittest.mock объектами. Он дополняет функционал pytest встроенной фикстурой mocker через который мы можем создавать mock объекты.

Установка пакета осуществляется через pip:

pip install pytest-mock

Предположим наше приложение использует внешний сервис, для получения некоторых данных:

from rest_framework.views import APIView
from rest_framework.response import Response
from .external import ThirdPartyService

class DataLoader(APIView):
   ...
def get(self, request, format=None):
       data = ThirdPartyService.get_data(id=request.id)
       return Response(data)

Нам необхоимо проестировать его без фактического обращения к сервису, для этого мы можем использовать mock.patch как декоратор в тесте python:

import pytest

@pytest.mark.django_db
def test_send_new_event_service_called(api_client, mocker):
  mocker.patch('service.ThirdPartyService.get_data')
   response = api_client.get('get_data', id=1)
   assert response.status_code == 201
   service.ThirdPartyService.get_data.assert_called_with(id=1)

# Полезные библиотеки для тестирования с pytest

# Создание моделей с помощью FactorBoy

При тестировании приложений Django мы постоянно имеем дело с моделями и их данными и для выполнения тестирования нам необходимо создавать модели с данными. Мы можем производить данные операции вручную либо с помощью фикстур. Но это приводит к дополнительным затратам ресрусов, приводи к большому объему кода, особенно если модель имеет множество внешних зависимостей таких как ForeignKey, ManyToMany поля и т.д. Здесь нам на помощь приходят фабрики - решения позволяющие тестировать наши данные наиболее удобным способом. Такие как Factory Boy (opens new window) или Model Bakery (opens new window) мы данном посте мы рассмотрим Factory Boy.

Установка как обычно через pip:

pip install pytest-factoryboy

Для того чтобы воспользоваться Фабрикой нам необходимо создать фабрику для генерации данных в файле tests/factories.py:

import factory
from django.contrib.auth.models import User, Group


class UserFactory(factory.DjangoModelFactory):
  class Meta:
       model = User

   username = factory.Sequence(lambda n: f'JohnDoe_{n}')
   email = factory.Sequence(lambda n: f'JohnDoe_{n}@mail.com')
   password = factory.PostGenerationMethodCall(
       'set_password', 'pass'
   )

   @factory.post_generation
   def has_default_group(self, create, extracted, **kwargs):
       if not create:
           return
       if extracted:
           default_group, _ = Group.objects.get_or_create(
               name='group'
           )
           self.groups.add(default_group)

После чего фабрика должна быть зарегистрирована в файле tests/conftest.py:

from pytest_factoryboy import register
from factories import UserFactory

register(UserFactory)  # имя фикстуры будет в snakecase виде: user_factory

Тестирование модли tests/test_models.py:

import pytest

@pytest.mark.django_db
def test_user_user_factory(user_factory):
   user = user_factory(has_default_group=True)
   assert user.username == 'JohnDoe_0'
   assert user.email == 'JohnDoe_0@mail.com'
   assert user.check_password('pass')
   assert user.groups.count() == 1

# Параллельный запуск тестов.

Процесс тестирования может быть значительно ускорен, с помощью плагина pytest-xdist (opens new window), который позволяет запускать тесты параллельно. Установка плагина:

pip install pytest-xdist

Запуск тестов в на нескольких процессорах/ядрах:

pytest -n <кол-во_процессов>
  • Избегайте операций вывода и stdout в тестах, т.к. они снижают скорость выполнения тестов;

  • Если тесты вызываются с помощью xdist, pytest-django создает отдельную тестовую базу данных для каждого процесса. Каждой тестовой базе данных будет присвоен суффикс (что-то вроде gw0, gw1) для сопоставления с процессом xdist. Если имя вашей базы данных установлено на foo, тестовая база данных с xdist будет test_foo_gw0, test_foo_gw1 и т.д.

# Анализ покрытия кода тестами

Для анализа покрытия кода тестами следует воспользоваться плагином pytest-cov (opens new window).

Установка плагина:

pip install pytest-cov

Запуск нализа покрытия кода тестами и вывод отчета:

pytest --cov tests

# Настройка тестирования в pytest.ini

Простой пример pytest.ini для Django проекта:

[pytest]
DJANGO_SETTINGS_MODULE = yourproject.settings
python_files = tests.py test_*.py *_tests.py
addopts = -p no:warnings --strict-markers --no-migrations --reuse-db
norecursedirs = venv old_tests
markers =
   custom_mark: some information of your mark
   slow: another one slow tes

Здесь:

  • DJANGO_SETTINGS_MODULE - путь к файлу с настройкми Django проекта, который используется pytest для создания фикстур взаимодействующих с БД и др.

  • addopts - добавляет аргументы командной строки чтобы не приходилось их вводить вручную при каждом запуске. Здесь определены:

    • --p no:warnings - отключить предупреждения
    • --strict-markers - отмечать как ошибки опечатки и дублирование в маркерах функций
    • --no-migrations отключает миграции Django и создаст базу данных, проверив все модели. Это может быть быстрее, если в настройке базы данных нужно выполнить несколько миграций
    • --reuse-db повторно использует тестовую базу данных между тестовыми запусками. Это обеспечивает намного более быстрое время запуска для тестов
  • reuse-db: запускать тесты с помощью pytest; при первом запуске будет создана тестовая база данных. При последующих запусках будет использоваться эта-же БД.

-create-db: принудительно воссаздовать тестовую БД при каждом запуске.

  • norecursedirs - устанавливает исключение исключение шаблонов базовых имен каталогов при рекурсии для обнаружения тестов. Это сообщает pytest не просматривать каталоги venv и old_testsdirectory.

Паттерны по умолчанию: '. ', 'Build', 'dist', 'CVS', '_darcs', '{arch}', ' .egg', 'venv'

  • markers - перечень дополнительных маркеров, которые в дальнейшем могут быть использованы в тестах.

На этом все, надеюсь данная статья была полезна и вы используете в своих Django проектах pytest.