Написание вашего первого приложения Django, часть 5
Этот учебник начинается там, где закончился Учебник 4. Мы создали веб-приложение для опросов, и теперь мы создадим для него некоторые автоматические тесты.
Введение в автоматическое тестирование
Что такое автоматические тесты?
Тесты — это простые процедуры, которые проверяют работу вашего кода.
Тестирование выполняется на разных уровнях. Некоторые тесты могут относиться к мельчайшим деталям (возвращает ли конкретный метод модели ожидаемые значения?), в то время как другие исследуют общую работу программного обеспечения (приводит ли последовательность пользовательских входов на сайте к желаемому результату?). Это ничем не отличается от типа тестирования, которое вы выполняли ранее в Учебнике 2, используя shell для проверки поведения метода или запуская приложение и вводя данные, чтобы проверить его поведение.
Отличие в автоматических тестах заключается в том, что работа по тестированию выполняется за вас системой. Вы создаёте набор тестов один раз, а затем, когда вы вносите изменения в своё приложение, можете проверить, работает ли ваш код так, как вы изначально задумывали, без необходимости проведения трудоёмких ручных тестов.
Почему вам нужно создавать тесты
Итак, зачем создавать тесты и почему сейчас?
Вы можете чувствовать, что у вас уже достаточно работы, просто осваивая Python/Django, и добавление ещё одной задачи может показаться подавляющим и, возможно, ненужным. В конце концов, наше приложение для опросов работает вполне нормально; создание автоматических тестов не улучшит его работу. Если создание приложения для опросов — это последняя работа по программированию Django, которую вы будете когда-либо выполнять, то да, вам не нужно знать, как создавать автоматические тесты. Но если это не так, сейчас отличное время для обучения.
Тесты сэкономят вам время
До определённого момента «проверка, что всё работает» будет приемлемым тестом. В более сложном приложении у вас может быть десятки сложных взаимодействий между компонентами.
Изменение любого из этих компонентов может привести к непредвиденным последствиям для поведения приложения. Проверка того, что оно по-прежнему «кажется работающим», может означать выполнение функций вашего кода с двадцатью различными вариациями тестовых данных, только чтобы убедиться, что вы ничего не сломали — неэффективное использование вашего времени.
Это особенно верно, когда автоматические тесты могут сделать это за вас за секунды. Если что-то пошло не так, тесты также помогут в определении кода, вызывающего неожиданное поведение.
Иногда может показаться обременительным отвлечься от вашей продуктивной, творческой работы по программированию, чтобы столкнуться с непривлекательным и неинтересным делом написания тестов, особенно когда вы знаете, что ваш код работает правильно.
Однако задача написания тестов гораздо более удовлетворительна, чем тратить часы на ручное тестирование приложения или попытки определить причину недавно возникшей проблемы.
Тесты не только выявляют проблемы, они предотвращают их
Ошибка думать о тестах только как о негативном аспекте разработки.
Без тестов назначение или предполагаемое поведение приложения может быть довольно неясно. Даже когда это ваш собственный код, вы иногда будете обнаруживать себя, разбираясь в нём, пытаясь выяснить, что именно он делает.
Тесты меняют это; они освещают ваш код изнутри, а когда что-то идёт не так, они фокусируют свет на части, которая пошла не так — даже если вы и не осознавали, что что-то пошло не так.
Тесты делают ваш код более привлекательным
Вы могли создать блестящую программу, но обнаружите, что многие другие разработчики просто откажутся взглянуть на неё, так как в ней нет тестов; без тестов они не будут ей доверять. Джейкоб Каплан-Мосс, один из первоначальных разработчиков Django, говорит: «Код без тестов по умолчанию сломан».
Желание других разработчиков видеть тесты в вашем программном обеспечении, прежде чем они его примут всерьёз, — ещё одна причина для начала написания тестов.
Тесты помогают командам работать вместе
Предыдущие пункты написаны с точки зрения одного разработчика, поддерживающего приложение. Сложные приложения будут поддерживаться командами. Тесты гарантируют, что коллеги не сломают ваш код по ошибке (и что вы не сломаете их без понимания). Если вы хотите зарабатывать на жизнь, как разработчик Django, вы должны уметь писать тесты!
Основные стратегии тестирования
Существует множество способов подхода к написанию тестов.
Некоторые программисты следуют дисциплине под названием «разработка, управляемая тестами»; они фактически пишут свои тесты, прежде чем писать свой код. Это может показаться нелогичным, но на самом деле это похоже на то, что многие люди часто делают: они описывают проблему, а затем создают код для её решения. Разработка, управляемая тестами, просто формализует проблему в тестовом случае Python.
Чаще всего новичок в тестировании создаст код, а затем решит, что для него должны быть тесты. Возможно, было бы лучше написать тесты раньше, но никогда не поздно начать.
Иногда трудно понять, с чего начать написание тестов. Если вы написали несколько тысяч строк Python, выбрать что-то для тестирования может быть нелегко. В таком случае полезно написать свой первый тест в следующий раз, когда вы внесёте изменение, будь то добавление новой функции или исправление ошибки.
Итак, давайте сделаем это прямо сейчас.
Написание нашего первого теста
Мы определяем ошибку
К счастью, в приложении polls есть небольшая ошибка, которую мы можем сразу исправить: метод Question.was_published_recently() возвращает True, если вопрос Question был опубликован в течение последнего дня (что правильно), но также если поле Question имеет поле pub_date в будущем (чего совершенно не должно быть).
Подтвердите ошибку, используя shell для проверки метода на вопрос, дата которого лежит в будущем:
$ python manage.py shell
...\> py manage.py 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.test import TestCase
from django.utils import timezone
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
...\> py 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'...
Что произошло:
-
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:
$ python manage.py shell
...\> py manage.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'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 в будущем. База данных сбрасывается для каждого метода теста, поэтому первый вопрос больше не существует, и поэтому в индексе не должно быть вопросов.
И так далее. По сути, мы используем тесты, чтобы рассказать историю ввода администратора и пользовательского опыта на сайте и проверять, что при каждом состоянии и каждом новом изменении состояния системы публикуются ожидаемые результаты.
Тестирование DetailView
Всё работает хорошо; однако, даже если вопросы из будущего не отображаются в списке, пользователи всё ещё могут получить к ним доступ, если знают или угадают правильный 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/2.1/intro/tutorial05/