Spec-Zone.ru › Django 1.11

Написание вашего первого приложения 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 QuestionModelTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() returns 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'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
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'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
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() returns False for questions whose pub_date
    is older than 1 day.
    """
    time = timezone.now() - datetime.timedelta(days=1, seconds=1)
    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() returns True for questions whose pub_date
    is within the last day.
    """
    time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
    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('/')
Not Found: /
>>> # we should expect a 404 from that address; if you instead see an
>>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
>>> # omitted the setup_test_environment() call described earlier.
>>> 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'
>>> 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):
    """
    Create 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 QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        If no questions exist, an appropriate message is 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_past_question(self):
        """
        Questions with a pub_date in the past are 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_future_question(self):
        """
        Questions with a pub_date in the future aren't 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_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        are 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_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_no_questions не создает никаких вопросов, но проверяет сообщение: «Доступны опросы.» и проверяет, что latest_question_list пуст. Обратите внимание, что класс django.test.TestCase предоставляет дополнительные методы утверждения. В этих примерах мы используем assertContains() и assertQuerysetEqual().

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

В test_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 QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        """
        The detail view of a question with a pub_date in the future
        returns 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_past_question(self):
        """
        The detail view of a question with a pub_date in the past
        displays 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.11/intro/tutorial05/

Spec-Zone.ru

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