Spec-Zone.ru › Django 1.10

Написание вашего первого приложения Django, часть 5

Этот учебник начинается там, где закончился Учебник 4. Мы создали приложение Web-опросов, и теперь мы создадим для него некоторые автоматические тесты.

Введение в автоматическое тестирование

Что такое автоматические тесты?

Тесты — это простые процедуры, которые проверяют работу вашего кода.

Тестирование выполняется на разных уровнях. Некоторые тесты могут относиться к мельчайшей детали (возвращает ли определённый метод модели ожидаемые значения?), в то время как другие проверяют общую работу программного обеспечения (последовательность действий пользователя на сайте приводит к желаемому результату?). Это ничем не отличается от типа тестирования, который вы проводили ранее в Учебнике 2, используя shell для проверки поведения метода или запустив приложение и введя данные, чтобы проверить его поведение.

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

Почему вам нужно создавать тесты

Итак, зачем создавать тесты и почему сейчас?

Вы можете чувствовать, что у вас уже достаточно работы с изучением Python/Django, и добавление ещё одного пункта может показаться обременительным и, возможно, ненужным. В конце концов, наше приложение опросов работает вполне успешно; создание автоматических тестов не улучшит его работу. Если создание приложения опросов — последняя задача по программированию на Django, которую вы будете выполнять, то, да, вам не нужно знать, как создавать автоматические тесты. Но если это не так, сейчас — отличное время для обучения.

Тесты сэкономят вам время

До определённого момента «проверка того, что всё работает» будет приемлемым тестом. В более сложном приложении у вас может быть десятки сложных взаимодействий между компонентами.

Изменение любого из этих компонентов может иметь непредвиденные последствия для поведения приложения. Проверка того, что оно всё ещё «работает», может означать прогон вашей функциональности с двадцатью различными вариантами тестовых данных, чтобы убедиться, что вы ничего не сломали — неэффективное использование вашего времени.

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

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

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

Тесты не только выявляют проблемы, они их предотвращают

Ошибка думать о тестах только как о негативном аспекте разработки.

Без тестов назначение или задуманное поведение приложения может быть довольно неясно. Даже когда это ваш собственный код, вы иногда можете обнаружить себя, копаясь в нём, пытаясь выяснить, что он делает.

Тесты меняют это; они освещают ваш код изнутри, и когда что-то идёт не так, они концентрируют свет на той части, которая пошла не так — даже если вы даже не осознавали, что что-то пошло не так.

Тесты делают ваш код более привлекательным

Вы можете создать блестящее программное обеспечение, но вы обнаружите, что многие другие разработчики просто откажутся от его рассмотрения из-за отсутствия тестов; без тестов они не будут ему доверять. Джейкоб Каплан-Мосс, один из первоначальных разработчиков Django, говорит: «Код без тестов сломан по умолчанию».

Желание других разработчиков увидеть тесты в вашем программном обеспечении, прежде чем они его серьёзно воспримут, — ещё одна причина начать писать тесты.

Тесты помогают командам работать вместе

Предыдущие пункты написаны с точки зрения одного разработчика, поддерживающего приложение. Сложные приложения будут поддерживаться командами. Тесты гарантируют, что коллеги не сломают ваш код по неосторожности (и что вы не сломаете их без знания). Если вы хотите зарабатывать на жизнь как разработчик Django, вам нужно уметь писать тесты!

Основные стратегии тестирования

Существует множество способов подхода к написанию тестов.

Некоторые программисты следуют дисциплине под названием «разработка через тестирование»; они фактически пишут свои тесты, прежде чем писать свой код. Это может показаться нелогичным, но на самом деле это похоже на то, что большинство людей часто делают: они описывают проблему, а затем создают код для её решения. Разработка через тестирование просто формализует проблему в тестовом случае Python.

Чаще новички в тестировании сначала создают код, а затем решают, что ему нужны тесты. Возможно, было бы лучше написать тесты раньше, но никогда не поздно начать.

Иногда сложно понять, с чего начать написание тестов. Если вы написали несколько тысяч строк Python, выбрать что-то для тестирования может быть нелегко. В таком случае полезно написать свой первый тест в следующий раз, когда вы внесёте изменения, будь то добавление новой функции или исправление ошибки.

Итак, давайте сделаем это прямо сейчас.

Написание нашего первого теста

Мы обнаруживаем ошибку

К счастью, в приложении polls для нас есть небольшая ошибка, которую нужно исправить: метод Question.was_published_recently() возвращает True, если Question был опубликован в течение последнего дня (что правильно), но также и если поле Question поля pub_date находится в будущем (что определённо не так).

Чтобы проверить, действительно ли существует ошибка, с помощью Админа создайте вопрос, дата которого находится в будущем, и проверьте метод с помощью shell:

>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True

Поскольку события в будущем не являются «недавними», это явно неправильно.

Создайте тест, чтобы выявить ошибку

То, что мы только что сделали в shell для проверки проблемы, — это то, что мы можем сделать в автоматическом тесте, поэтому давайте превратим это в автоматический тест.

Традиционное место для тестов приложения — файл tests.py приложения; система тестирования автоматически найдёт тесты в любом файле, имя которого начинается с test.

Вставьте следующее в файл tests.py в приложении polls:

import datetime

from django.utils import timezone
from django.test import TestCase

from .models import Question


class QuestionMethodTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() should return False for questions whose
        pub_date is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

Здесь мы создали подкласс django.test.TestCase с методом, который создаёт экземпляр Question с pub_date в будущем. Затем мы проверяем результат was_published_recently() — который должен быть False.

Запуск тестов

В терминале мы можем запустить наш тест:

$ python manage.py test polls

и вы увидите что-то вроде:

Creating test database for alias 'default'...
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
    self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)
Destroying test database for alias 'default'...

Вот что произошло:

  • python manage.py test polls искал тесты в приложении polls
  • он нашёл подкласс класса django.test.TestCase
  • он создал специальную базу данных для тестирования
  • он искал тестовые методы — те, имена которых начинаются с test
  • в test_was_published_recently_with_future_question он создал экземпляр Question, поле pub_date которого на 30 дней в будущем
  • … и с помощью метода assertIs(), он обнаружил, что was_published_recently() возвращает True, хотя мы хотели, чтобы он возвращал False

Тест сообщает нам, какой тест завершился неудачно и даже на какой строке произошла ошибка.

Исправление ошибки

Мы уже знаем, в чём проблема: Question.was_published_recently() должен возвращать False, если его pub_date находится в будущем. Исправьте метод в models.py, чтобы он возвращал только True, если дата также в прошлом:

def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

и снова запустите тест:

Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...

После выявления ошибки мы написали тест, который её выявил, и исправили ошибку в коде, так что наш тест проходит.

В будущем могут возникнуть и другие проблемы с нашим приложением, но мы можем быть уверены, что мы не введём эту ошибку повторно, так как просто запуск теста немедленно предупредит нас. Мы можем считать эту небольшую часть приложения надёжно закреплённой навсегда.

Более полные тесты

Пока мы здесь, мы можем дополнительно проверить метод was_published_recently(); на самом деле было бы крайне неудобно, если бы при исправлении одной ошибки мы ввели другую.

Добавьте ещё два тестовых метода в тот же класс, чтобы более полно проверить поведение метода:

def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() should return False for questions whose
    pub_date is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=30)
    old_question = Question(pub_date=time)
    self.assertIs(old_question.was_published_recently(), False)

def test_was_published_recently_with_recent_question(self):
    """
    was_published_recently() should return True for questions whose
    pub_date is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=1)
    recent_question = Question(pub_date=time)
    self.assertIs(recent_question.was_published_recently(), True)

И теперь у нас есть три теста, которые подтверждают, что Question.was_published_recently() возвращает разумные значения для вопросов в прошлом, в настоящем и в будущем.

Опять же, polls — это простое приложение, но независимо от того, насколько сложным оно станет в будущем и с какими другими кодами оно будет взаимодействовать, у нас теперь есть гарантия, что метод, для которого мы написали тесты, будет вести себя ожидаемым образом.

Тестирование представления

Приложение опросов довольно неразборчиво: оно опубликует любой вопрос, включая тот, чьё поле pub_date находится в будущем. Мы должны улучшить это. Установка pub_date в будущем должна означать, что вопрос опубликован в этот момент, но невидим до тех пор.

Тест для представления

Когда мы исправляли ошибку выше, мы сначала написали тест, а затем код для его исправления. Фактически, это был простой пример разработки через тестирование, но порядок выполнения работы не имеет значения.

В нашем первом тесте мы сосредоточились на внутреннем поведении кода. В этом тесте мы хотим проверить его поведение с точки зрения пользователя через веб-браузер.

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

Клиент Django для тестирования

Django предоставляет тестовый Client для имитации взаимодействия пользователя с кодом на уровне представления. Мы можем использовать его в tests.py или даже в shell.

Мы начнём снова с shell, где нам нужно сделать несколько вещей, которые не понадобятся в tests.py. Первое – это настройка тестовой среды в shell:

>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

setup_test_environment() устанавливает рендерер шаблонов, который позволит нам проверить некоторые дополнительные атрибуты ответов, такие как response.context, которые в противном случае были бы недоступны. Обратите внимание, что этот метод не настраивает тестовую базу данных, поэтому следующее выполняется с существующей базой данных, и вывод может незначительно отличаться в зависимости от того, какие вопросы вы уже создали. Вы можете получить неожиданные результаты, если ваш TIME_ZONE в settings.py неверен. Если вы не помните, устанавливали ли вы его ранее, проверьте это перед продолжением.

Далее нам нужно импортировать класс тестового клиента (позже в tests.py мы будем использовать класс django.test.TestCase, который поставляется со своим клиентом, поэтому это не потребуется):

>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()

После этого мы можем попросить клиента выполнить некоторую работу за нас:

>>> # get a response from '/'
>>> response = client.get('/')
>>> # we should expect a 404 from that address
>>> response.status_code
404
>>> # on the other hand we should expect to find something at '/polls/'
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n    <ul>\n    \n        <li><a href="/polls/1/">What&#39;s up?</a></li>\n    \n    </ul>\n\n'
>>> # If the following doesn't work, you probably omitted the call to
>>> # setup_test_environment() described above
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>

Улучшение нашего представления

Список опросов показывает опросы, которые ещё не опубликованы (то есть те, у которых pub_date в будущем). Давайте исправим это.

В Учебнике 4 мы представили представление на основе класса, основанное на ListView:

class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by('-pub_date')[:5]

Нам нужно изменить метод get_queryset() и изменить его так, чтобы он также проверял дату, сравнивая её с timezone.now(). Сначала нам нужно добавить импорт:

from django.utils import timezone

а затем нам нужно изменить метод get_queryset следующим образом:

def get_queryset(self):
    """
    Return the last five published questions (not including those set to be
    published in the future).
    """
    return Question.objects.filter(
        pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]

Question.objects.filter(pub_date__lte=timezone.now()) возвращает набор результатов, содержащий Question с pub_date меньше или равно - то есть, ранее или равно - timezone.now.

Тестирование нашего нового представления

Теперь вы можете убедиться, что это работает так, как ожидается, запустив runserver, загрузив сайт в браузере, создав Questions с датами прошлого и будущего, и проверив, что отображаются только опубликованные. Вы не хотите делать это каждый раз, когда вы вносите любое изменение, которое может повлиять на это - поэтому давайте также создадим тест, основанный на нашей shell сессии выше.

Добавьте следующее к polls/tests.py:

from django.urls import reverse

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

def create_question(question_text, days):
    """
    Creates a question with the given `question_text` and published the
    given number of `days` offset to now (negative for questions published
    in the past, positive for questions that have yet to be published).
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionViewTests(TestCase):
    def test_index_view_with_no_questions(self):
        """
        If no questions exist, an appropriate message should be displayed.
        """
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_index_view_with_a_past_question(self):
        """
        Questions with a pub_date in the past should be displayed on the
        index page.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_index_view_with_a_future_question(self):
        """
        Questions with a pub_date in the future should not be displayed on
        the index page.
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(response.context['latest_question_list'], [])

    def test_index_view_with_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        should be displayed.
        """
        create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question.>']
        )

    def test_index_view_with_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        create_question(question_text="Past question 1.", days=-30)
        create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['<Question: Past question 2.>', '<Question: Past question 1.>']
        )

Давайте посмотрим на некоторые из них поближе.

Во-первых, функция-короткая запись вопроса, create_question, чтобы избежать повторения процесса создания вопросов.

test_index_view_with_no_questions не создаёт никаких вопросов, но проверяет сообщение: «Доступны опросы.» и проверяет, что latest_question_list пусто. Обратите внимание, что класс django.test.TestCase предоставляет дополнительные методы утверждения. В этих примерах мы используем assertContains() и assertQuerysetEqual().

В test_index_view_with_a_past_question, мы создаём вопрос и проверяем, что он появляется в списке.

В test_index_view_with_a_future_question, мы создаём вопрос с pub_date в будущем. База данных сбрасывается для каждого метода теста, поэтому первый вопрос больше не существует, и поэтому в списке не должно быть никаких вопросов.

И так далее. По сути, мы используем тесты, чтобы рассказать историю о вводе администратора и пользовательском опыте на сайте, и проверяем, что на каждом этапе и при каждом новом изменении состояния системы ожидаемые результаты публикуются.

Тестирование представления деталей

Всё работает хорошо; однако, даже если будущие вопросы не отображаются в списке, пользователи всё ещё могут получить к ним доступ, если знают или угадывают правильный URL. Поэтому нам нужно добавить аналогичное ограничение в DetailView:

class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        Excludes any questions that aren't published yet.
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

И, конечно же, мы добавим некоторые тесты, чтобы проверить, что Question с pub_date в прошлом может быть отображен, а с pub_date в будущем нет:

class QuestionIndexDetailTests(TestCase):
    def test_detail_view_with_a_future_question(self):
        """
        The detail view of a question with a pub_date in the future should
        return a 404 not found.
        """
        future_question = create_question(question_text='Future question.', days=5)
        url = reverse('polls:detail', args=(future_question.id,))
        response = self.client.get(url)
        self.assertEqual(response.status_code, 404)

    def test_detail_view_with_a_past_question(self):
        """
        The detail view of a question with a pub_date in the past should
        display the question's text.
        """
        past_question = create_question(question_text='Past Question.', days=-5)
        url = reverse('polls:detail', args=(past_question.id,))
        response = self.client.get(url)
        self.assertContains(response, past_question.question_text)

Идеи для дополнительных тестов

Мы должны добавить аналогичный метод get_queryset в ResultsView и создать новый класс теста для этого представления. Это будет очень похоже на то, что мы только что создали; на самом деле будет много повторений.

Мы также могли бы улучшить наше приложение другими способами, добавляя тесты по мере необходимости. Например, глупо, что Questions могут быть опубликованы на сайте, у которых нет Choices. Поэтому наши представления могли бы проверять это и исключать такие Questions. Наши тесты создадут Question без Choices и затем проверят, что он не опубликован, а также создадут аналогичный Question с Choices, и проверят, что он опубликован.

Возможно, авторизованным администраторам следует разрешить просмотр неопубликованных Questions, но не обычным посетителям. Опять же: любые добавления в программное обеспечение для достижения этого должны сопровождаться тестом, вне зависимости от того, пишете ли вы тест сначала, а затем делаете код проходящим тест, или сначала выводите логику в своём коде, а затем пишете тест, чтобы это доказать.

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

Когда больше тестов – лучше

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

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

Иногда тесты придётся обновлять. Предположим, что мы изменим наши представления так, чтобы публиковались только Questions с Choices. В этом случае многие из наших существующих тестов потерпят неудачу – то есть, они точно укажут, какие тесты нужно изменить, чтобы они были актуальны, поэтому в этом смысле тесты помогают следить за собой.

В худшем случае, по мере продолжения разработки, вы можете обнаружить некоторые тесты, которые теперь избыточны. Даже это не проблема; в тестировании избыточность – это хорошо.

Пока ваши тесты разумно организованы, они не станут неподъёмными. К хорошим правилам относятся:

  • отдельный TestClass для каждой модели или представления
  • отдельный метод теста для каждого набора условий, которые вы хотите проверить
  • названия методов тестов, описывающие их функцию

Дополнительное тестирование

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

Например, в то время как наши тесты здесь охватывают часть внутренней логики модели и способ, которым наши представления публикуют информацию, вы можете использовать фреймворк «в браузере», такой как Selenium, чтобы протестировать рендеринг вашего HTML в браузере. Эти инструменты позволяют проверять не только поведение вашего кода Django, но также, например, вашего JavaScript. Довольно впечатляет увидеть, как тесты запускают браузер и начинают взаимодействовать с вашим сайтом, как будто им управляет человек! Django включает LiveServerTestCase для облегчения интеграции с такими инструментами, как Selenium.

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

Хороший способ обнаружить непроверенные части вашего приложения – это проверить покрытие кода. Это также помогает выявить хрупкий или даже неиспользуемый код. Если вы не можете протестировать фрагмент кода, это обычно означает, что код следует переработать или удалить. Покрытие поможет выявить неиспользуемый код. Подробности см. в Интеграция с coverage.py.

Тестирование в Django содержит исчерпывающую информацию о тестировании.

Что дальше?

Подробные сведения о тестировании см. в Тестирование в Django.

Когда вы освоите тестирование представлений Django, прочитайте часть 6 этого учебника, чтобы узнать о управлении статическими файлами.

© Django Software Foundation and individual contributors
Licensed under the BSD License.
https://docs.djangoproject.com/en/1.10/intro/tutorial05/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API