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

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

enter image description here

План

  1. Тестирование в Django
    1. Особенности тестирования
    2. Client
  2. Tестирование в DRF
    1. APITestCase
    2. APIClient
  3. Фабричный метод и фабрики
    1. RequestFactory
    2. Factory Boy, Fuzzy attributes
    3. Faker
  4. Литература

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

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

Подумайте о коде, который нужно протестировать в веб-приложении. Все маршруты, представления и модели требуют много импортов и знаний об используемом фреймворке.
Это похоже на тестирование автомобиля: перед тем, как провести простые тесты, вроде проверки работы фар, нужно включить компьютер в машине.

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

from django.test import TestCase

class MyTestCase(TestCase)
:   
    def setUp(self):
        # Установки запускаются перед каждым тестом
        pass

    def tearDown(self):
        # Очистка после каждого метода
        pass

    def test_something_that_will_pass(self):
        self.assertFalse(False)

    def test_something_that_will_fail(self):
        self.assertTrue(False)

Основное отличие от прошлых примеров — нужно наследовать из django.test.TestCase, а не unittest.TestCase.

TestCase.mro()
(<class 'django.test.testcases.TestCase'>,
 <class 'django.test.testcases.TransactionTestCase'>,
 <class 'django.test.testcases.SimpleTestCase'>,
 <class 'unittest.case.TestCase'>,
 <class 'object'>)

enter image description here
Для исполнения тестового набора используйте manage.py test вместо unittest в командной строке:

./manage.py test

Если вам нужно несколько тестовых файлов, замените tests.py на папку tests, положите в нее пустой файл с названием __init__.py и создайте файлы test_*.py. Django обнаружит их и выполнит.

Новые Assertions

assertContains(response, text)
assertNotContains(response, text)
assertTemplateUsed(response, template_name)
assertTemplateNotUsed(response, template_name)
assertURLEqual(url1, url2)
assertRedirects(response, expected_url, status_code=302, target_status_code=200)
assertHTMLEqual(html1, html2)
assertHTMLNotEqual(html1, html2)
assertXMLEqual(xml1, xml2)
assertXMLNotEqual(xml1, xml2)
assertJSONEqual(raw, expected_data)
assertJSONNotEqual(raw, expected_data)

Остальные в документации

База данных для тестирования

Для тестов используется отдельная база данных, которая будет указана в переменной TEST в переменной DATABASES в файле settings.py:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'USER': 'mydatabaseuser',
        'NAME': 'mydatabase',
        'TEST': {
            'NAME': 'mytestdatabase',
        },
    },
}

Эта база будет изначально пустая, и будет очищаться после каждого выполненного тест кейса. Что-бы не очишать базу, можно указать параметр --keepdb (или -k)

Настрока в PyCharm

enter image description here

Тестирование сайта это сложная задача, потому что она состоит их нескольких логических слоев – от HTTP-запроса и запроса к моделям, до валидации формы и их обработки, а кроме того, рендеринга шаблонов страниц.

Django предоставляет фреймворк для создания тестов, построенного на основе иерархии классов, которые, в свою очередь, зависят от стандартной библиотеки Python unittest. Несмотря на название, данный фреймворк подходит и для юнит-, и для интеграционного тестирования. Фреймворк Django добавляет методы и инструменты, которые помогают тестировать как веб так и, специфическое для Django, поведение. Это позволяет вам имитировать URL-запросы, добавление тестовых данных, а также проводить проверку выходных данных ваших приложений.

django.test.TestCase класс создает чистую базу данных перед запуском своих методов, а также запускает каждую функцию тестирования в его собственной транзакции. У данного класса также имеется тестовый Клиент, который вы можете использовать для имитации взаимодействия пользователя с кодом на уровне отображения.

Client

Для проведения интеграционного тестирования джанго приложенния нам необходимо отправлять запросы с клиента (браузера), функционал для этого нам предоставлен из коробки, и мы можем им воспользоваться:

>>> from django.test import Client
>>> c = Client()
>>> response = c.post('/login/', {'username': 'john', 'password': 'smith'})
>>> response.status_code
200
>>> response = c.get('/customer/details/')
>>> response.content
b'<!DOCTYPE html...'

Такой запрос не будет требовать CSRF токен

Поддерживает метод login()

c = Client()
c.login(username='fred', password='secret')

после чего запросы будут приходть от авторизированого пользователя
и метод force_login принимающий объект юзера, а не логин и пароль.
и метод logout()

GET

>>> c = Client()
>>> response = c.get('/customers/details/', {'name': 'fred', 'age': 7})

отправит GET запрос на

/customers/details/?name=fred&age=7

POST

>>> c = Client()
>>> c.post('/login/', {'name': 'fred', 'passwd': 'secret'})

И response запишет ответ
отправит POST запрос на

/login/

response

response = {TemplateResponse} <TemplateResponse status_code=200, "text/html; charset=utf-8">
 charset = {str} 'utf-8'
 client = {Client} <django.test.client.Client object at 0x7f1b7b1ed850>
 closed = {bool} True
 content = {bytes: 2495} b'<!DOCTYPE html>\n<html lang="en">\n<head>\n  \n  <title>Local Library</title>\n  <meta charset="utf-8">\n  <meta name="viewport" content="width=device-width, initial-scale=1">\n  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/d
 context = {ContextList: 2} [[{'True': True, 'False': False, 'None': None}, {'csrf_token': <SimpleLazyObject: <function csrf.<locals>._get_val at 0x7f1b7a78d790>>, 'request': <WSGIRequest: GET '/catalog/authors/'>, 'user': <SimpleLazyObject: <django.contrib.auth.models.AnonymousUser 
 context_data = {dict: 6} {'paginator': <django.core.paginator.Paginator object at 0x7f1b7a771dc0>, 'page_obj': <Page 1 of 2>, 'is_paginated': True, 'object_list': <QuerySet [<Author: Surname 0, Christian 0>, <Author: Surname 1, Christian 1>, <Author: Surname 10, Christian 10>, <Au
 cookies = {SimpleCookie: 0} 
 exc_info = {NoneType} None
 is_rendered = {bool} True
 json = {partial} functools.partial(<bound method ClientMixin._parse_json of <django.test.client.Client object at 0x7f1b7b1ed850>>, <TemplateResponse status_code=200, "text/html; charset=utf-8">)
 reason_phrase = {str} 'OK'
 rendered_content = {SafeString} <!DOCTYPE html>\n<html lang="en">\n<head>\n  \n  <title>Local Library</title>\n  <meta charset="utf-8">\n  <meta name="viewport" content="width=device-width, initial-scale=1">\n  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">\n\n  \n  <!-- Add additional CSS in static file -->\n  \n  <link rel="stylesheet" href="/static/css/styles.78e88d8d7ee5.css">\n</head>\n<body>\n\n<div class="container-fluid">\n\n<div class="row">\n  <div class="col-sm-2">\n  \n  <ul class="sidebar-nav">\n    <li><a href="/catalog/">Home</a></li>\n    <li><a href="/catalog/books/">All books</a></li>\n    <li><a href="/catalog/authors/">All authors</a></li>\n  </ul>\n \n  <ul class="sidebar-nav">\n   \n     <li><a href="/accounts/login/?next=/catalog/authors/">Login</a></li>   \n    \n  </ul>\n  \n   \n \n\n  </div>\n  <div class="col-sm-10 ">\n  \n\n<h1>Author List</h1>\n\n\n  <ul>\n\n  \n    <li>\n...
 rendering_attrs = {list: 4} ['template_name', 'context_data', '_post_render_callbacks', '_request']
 request = {dict: 5} {'PATH_INFO': '/catalog/authors/', 'REQUEST_METHOD': 'GET', 'SERVER_PORT': '80', 'wsgi.url_scheme': 'http', 'QUERY_STRING': ''}
 resolver_match = {ResolverMatch} ResolverMatch(func=catalog.views.AuthorListView, args=(), kwargs={}, url_name=authors, app_names=[], namespaces=[], route=catalog/authors/)
 status_code = {int} 200
 streaming = {bool} False
 template_name = {list: 1} ['catalog/author_list.html']
 templates = {list: 2} [<django.template.base.Template object at 0x7f1b7a6dbcd0>, <django.template.base.Template object at 0x7f1b7a7a6ee0>]
 using = {NoneType} None
 wsgi_request = {WSGIRequest} <WSGIRequest: GET '/catalog/authors/'>

Структура тестов
enter image description here

Тестирование view

class AuthorListView(generic.ListView):
    model = Author
    paginate_by = 10
from django.test import TestCase
from django.contrib.auth.models import User
from catalog.models import Author
from django.urls import reverse

class AuthorListViewTest(TestCase):

	def setUp(self):  
	    # Create a user  
	    self.user = User.objects.create_user(username='user_1', password='1X<ISRUkw+tuK')
	    self.client.force_login(user=self.user)
        self.author = Author.objects.create(first_name='Christian',  last_name = 'Author')

    def test_index(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)
		assertContains`(response, 'Christian')
        self.assertTemplateUsed(response, 'catalog/author_list.html')
    
    def test_view(self):
        response = self.client.get(reverse('authors'), args=[self.author.id])
        self.assertEqual(response.status_code, 200)
		assertContains`(response, 'Christian')
        self.assertTemplateUsed(response, 'catalog/author_view.html')

	def test_create(self):  
		url = reverse('author-create')
		data = {'first_name': 'First Name', 'last_name': 'Surname'}
	    response = self.client.post(url,data)  
	    # Manually check redirect because we don't know what author was created  
	    self.assertEqual(response.status_code, 302)  
	    self.assertTrue(response.url.startswith('/catalog/author/')

В качестве значений кодов статуса можно использовать HTTPStatus

>>> from http import HTTPStatus
>>> HTTPStatus.OK
<HTTPStatus.OK: 200>

 self.assertEqual(resp.status_code, HTTPStatus.OK) # 200
 self.assertEqual(resp.status_code, HTTPStatus.FOUND) # 302
 self.assertEqual(resp.status_code, HTTPStatus.FORBIDDEN) # 403
 self.assertEqual(resp.status_code, HTTPStatus.NOT_FOUND) # 404

Тестирование REST API

В DRF для тестирования есть APITestCase, который дублирует TestCase из Django, но в качестве клиента выступает APIClient

from rest_framework.test import APITestCase

APITestCase.mro()
[<class 'rest_framework.test.APITestCase'>, 
 <class 'django.test.testcases.TestCase'>, 
 <class 'django.test.testcases.TransactionTestCase'>, 
 <class 'django.test.testcases.SimpleTestCase'>, 
 <class 'unittest.case.TestCase'>, 
 <class 'object'>]
from django.test import testcases

class APITestCase(testcases.TestCase):  
    client_class = APIClient

APIClient

В DRF есть свой клиент для запросов в котором уже прописаны все необходимые методы запросов (get(), post(), итд.)

from rest_framework.test import APIClient

client = APIClient()
client.post('/notes/', {'title': 'new idea'}, format='json')

Авторизация через клиент

# Make all requests in the context of a logged in session.
client = APIClient()
client.login(username='lauren', password='secret')

# Log out
client.logout()

from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient

# Include an appropriate `Authorization:` header on all requests.
token = Token.objects.get(user__username='lauren')
client = APIClient()
client.credentials(HTTP_AUTHORIZATION='Token ' + token.key)

Так же подерживает форсированую авторизацию

user = User.objects.get(username='lauren')
client = APIClient()
client.force_authenticate(user=user)
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from myproject.apps.core.models import Account

class AccountTests(APITestCase):
    def setUp(self):
		self.user = User.objects.create_user(username='user_1', password='1X<ISRUkw+tuK')
		#self.client = APIClient()
		self.client.force_authenticate(user=self.user)

	def test_view(self):
	    #лучше использовать response.data, а не json.loads(response.content)
	    data = {'id': 4, 'username': 'lauren'}
		response = self.client.get('/users/4/')
		self.assertEqual(response.data, data)

    def test_create_account(self):
        """
        Ensure we can create a new account object.
        """
        url = reverse('account-list')
        data = {'name': 'DabApps'}
        response = self.client.post(url, data, format='json')
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Account.objects.count(), 1)
        self.assertEqual(Account.objects.get().name, 'DabApps')

Фабричный метод и фабрики

Фабричный метод (Factory Method) - порождающий паттерн проектирования, который определяет интерфейс для создания объектов некоторого класса, но непосредственное решение о том, объект какого класса создавать происходит в подклассах. То есть паттерн предполагает, что базовый класс делегирует создание объектов классам-наследникам.

RequestFactory (Фабрика запросов)

Класс RequestFactory использует тот же API, что и тестовый клиент. Однако вместо того, чтобы вести себя как браузер, RequestFactory предлагает способ создания экземпляра запроса, который может использоваться в качестве первого параметра любого представления. Это позволяет тестировать функцию просмотра так же, как вы тестируете любую другую функцию, например черный ящик, с известными данными в качестве входных данных и тестированием определенного результата.

API RequestFactory немного более ограничен, чем у тестового клиента:

Это дает доступ к методам HTTP только get() , post() , put() , delete() , head() , options() и trace().
Все эти методы принимают одни и те же параметры. Поскольку это только фабрика, производящая запросы, вы несете ответственность за ответ.
Он не поддерживает промежуточное ПО. Атрибуты сеанса и аутентификации должны быть предоставлены самим тестом, если необходимо, для правильной работы представления.
Для чего необходим RequestFactory? Не для всех проверок нужно делать запрос, часто необходимо его только имитировать.

from django.test import RequestFactory, TestCase

from .views import MyView, my_view

class SimpleTest(TestCase):
    def setUp(self):
        # Every test needs access to the request factory.
        self.factory = RequestFactory()
        self.user = User.objects.create_user(
            username='jacob', email='jacob@…', password='top_secret')

    def test_details(self):
        # Create an instance of a GET request.
        request = self.factory.get('/customer/details')

        # Recall that middleware are not supported. You can simulate a
        # logged-in user by setting request.user manually.
        request.user = self.user

        # Or you can simulate an anonymous user by setting request.user to
        # an AnonymousUser instance.
        request.user = AnonymousUser()

        # Test my_view() as if it were deployed at /customer/details
        response = my_view(request)
        # Use this syntax for class-based views.
        response = MyView.as_view()(request)
        self.assertEqual(response.status_code, 200)

Factory Boy

pip install factory_boy

Прописывание в setUp создание новых обектов может занимать очень много времени. Что-бы это ускорить, упростить и автоматизировать, можно написать свою фабрику

import factory
from . import base

class UserFactory(factory.Factory):
    class Meta:
        model = base.User

    firstname = "John"
    lastname = "Doe"
>>>john = UserFactory()
<User: John Doe>
>>>jack = UserFactory(firstname="Jack")
<User: Jack Doe>

На один класс можно создавать несколько фабрик

class EnglishUserFactory(factory.Factory):
    class Meta:
        model = base.User

    firstname = "John"
    lastname = "Doe"
    lang = 'en'

class FrenchUserFactory(factory.Factory):
    class Meta:
        model = base.User

    firstname = "Jean"
    lastname = "Dupont"
    lang = 'fr'
EnglishUserFactory()
<User: John Doe (en)>
>>> FrenchUserFactory()
<User: Jean Dupont (fr)>

Sequences

class UserFactory(factory.Factory):
    class Meta:
        model = models.User

    username = factory.Sequence(lambda n: 'user%d' % n)

LazyFunction

При создании можно использовать функции,которые не зависят то класса

class LogFactory(factory.Factory):
    class Meta:
        model = models.Log

    timestamp = factory.LazyFunction(datetime.now)
>>> LogFactory()
<Log: log at 2016-02-12 17:02:34>

>>> # The LazyFunction can be overridden
>>> LogFactory(timestamp=now - timedelta(days=1))
<Log: log at 2016-02-11 17:02:34>

LazyAttribute

Некоторые поля могут быть заполнены при помощи других, например электронная почта на основе имени пользователя. LazyAttribute обрабатывает такие случаи: он должен получить функцию, принимающую создаваемый объект и возвращающую значение для поля:

class UserFactory(factory.Factory):
    class Meta:
        model = models.User

    username = factory.Sequence(lambda n: 'user%d' % n)
    email = factory.LazyAttribute(lambda obj: '%s@example.com' % obj.username)
>>>UserFactory()
<User: user1 (user1@example.com)>

>>> # The LazyAttribute handles overridden fields
>>> UserFactory(username='john')
<User: john (john@example.com)>

>>> # They can be directly overridden as well
>>> UserFactory(email='doe@example.com')
<User: user3 (doe@example.com)>

Наследование

class UserFactory(factory.Factory):
    class Meta:
        model = base.User

    firstname = "John"
    lastname = "Doe"
    group = 'users'

class AdminFactory(UserFactory):
    admin = True
    group = 'admins'

Fuzzy attributes

Fuzzy позволяет генерировать фейковые данные

from factory import fuzzy
...
def setUp(self):  
    self.username = fuzzy.FuzzyText().fuzz()  
    self.password = fuzzy.FuzzyText().fuzz()  
    self.user_id = fuzzy.FuzzyInteger(1).fuzz()

Faker

Faker пришел на замену Fuzzy

pip install Faker
from faker import Faker
fake = Faker()

fake.name()
# 'Lucy Cechtelar'

fake.address()
# '426 Jordy Lodge
#  Cartwrightshire, SC 88120-6700'

fake.text()
# 'Sint velit eveniet. Rerum atque repellat voluptatem quia rerum. Numquam excepturi
#  beatae sint laudantium consequatur. Magni occaecati itaque sint et sit tempore. Nesciunt
#  amet quidem. Iusto deleniti cum autem ad quia aperiam.
#  A consectetur quos aliquam. In iste aliquid et aut similique suscipit. Consequatur qui
#  quaerat iste minus hic expedita. Consequuntur error magni et laboriosam. Aut aspernatur
#  voluptatem sit aliquam. Dolores voluptatum est.
#  Aut molestias et maxime. Fugit autem facilis quos vero. Eius quibusdam possimus est.
#  Ea quaerat et quisquam. Deleniti sunt quam. Adipisci consequatur id in occaecati.
#  Et sint et. Ut ducimus quod nemo ab voluptatum.'

Использование с Factory Boy

import factory
from myapp.models import Book

class BookFactory(factory.Factory):
    class Meta:
        model = Book

    title = factory.Faker('sentence', nb_words=4)
    author_name = factory.Faker('name')

Providers
У Faker есть большое количество шаблонов, которые расположены в так называемых провайдерах

from faker import Faker
from faker.providers import internet

fake = Faker()
fake.add_provider(internet)

>>> fake.ipv4_private()
'10.10.11.69'
>>> fake.ipv4_private()
'10.86.161.98'

Итоги

Написание тестов не является ни весельем, ни развлечением и, соответственно, при создании сайтов часто остается напоследок (или вообще не используется). Но тем не менее, они являются действенным механизмом, который позволяет вам убедиться, что ваш код в находится безопасности, даже если в него добавляются какие-либо изменения. Кроме того, тесты повышают эффективность поддержки вашего кода.

Литература

  1. Тестирование приложений Django от mozilla
  2. Web-приложение с примерами тестов