Введение
Этот туториал начинается там, где закончился **Обработка форм и сокращение нашего кода**. Мы создали веб-опросное приложение, и теперь создадим для него некоторые автоматические тесты.
Введение в автоматическое тестирование
Что такое автоматические тесты?
Тесты - это программы, которые проверяют работу вашего кода.
Тестирование проводится на разных уровнях. Некоторые тесты могут относиться к очень мелкой детали (возвращает ли конкретный метод модели значения, как ожидается?), в то время как другие проверяют общую работу программного обеспечения (дает ли последовательность пользовательских вводов на сайте ожидаемый результат?). Это не отличается от того вида тестирования, который вы делали ранее в **Настройка базы данных**, используя shell для проверки поведения метода или запуская приложение и вводя данные, чтобы проверить, как оно работает.
Отличием в автоматических тестах является то, что тестирование выполняется системой за вас. Вы создаете набор тестов один раз, а затем при внесении изменений в ваше приложение вы можете проверить, продолжает ли код работать так, как вы изначально планировали, не тратя время на трудоемкое ручное тестирование.
Почему вам нужно создавать тесты
Итак, почему создавать тесты и почему именно сейчас?
Вы, возможно, чувствуете, что у вас достаточно на дел, просто изучая Python/Django, и еще одна вещь для изучения и выполнения может показаться подавляющей и, возможно, ненужной. Вед毕竟,наше опросное приложение работает вполне стабильно сейчас; хлопоты по созданию автоматических тестов не сделают его работоспособностью лучше. Если создание опросного приложения будет последней частью программирования на Django, которое вы когда-либо сделаете, то, действительно, вам не нужно знать, как создавать автоматические тесты. Однако, если это не так, то сейчас прекрасное время для обучения.
Тесты节省您的时间
До определенного момента "проверка того, что кажется работать" будет приемлемым тестом. В более сложном приложении вы можете иметь десятки сложных взаимодействий между компонентами.
Изменение любого из этих компонентов может иметь непредвиденные последствия для поведения приложения. Проверка того, что оно по-прежнему "кажется работать", может означать запуск функциональности вашего кода с двадцатью разными вариантами тестовых данных, чтобы убедиться, что вы ничего не сломали - не лучший способ потратить свое время.
Это особенно актуально, когда автоматические тесты могут сделать это за вас за несколько секунд. Если что-то пошло не так, тесты также помогут определить код, который вызывает непредвиденное поведение.
Иногда может показаться утомительным оторваться от продуктивной, творческой работы по программированию, чтобы столкнуться с некрасивой и неинтересной работой по написанию тестов, особенно когда вы знаете, что ваш код работает правильно.
Однако задача по написанию тестов гораздо более удовлетворяющая, чем проводить вручную тестирование приложения в течение нескольких часов или пытаться определить причину вновь появившейся проблемы.
Тесты не только выявляют проблемы, но и предотвращают их
Ошибкой является думать, что тесты - это только отрицательная сторона разработки.
Без тестов цель или ожидаемое поведение приложения могут быть довольно непонятными. Даже когда это ваш собственный код, вы иногда будете искать в нем, пытаясь понять, что оно делает именно.
Тесты меняют это; они освещают ваш код снутри, и когда что-то идет не так, они направляют внимание на часть, которая сломалась - даже если вы даже не заметили, что что-то пошло не так.
Тесты делают ваш код более привлекательным
Вы, возможно, создали замечательное программное обеспечение, но вы обнаружите, что многие другие разработчики откажутся рассматривать его, потому что оно не имеет тестов; без тестов они не доверяют ему. Джейкоб Каплан-Мосс, один из первых разработчиков Django, говорит: "Код без тестов - это, по определению, сломанный".
То, что другие разработчики хотят видеть тесты в вашем программном обеспечении, прежде чем они его серьезно рассмотрите, еще одна причина для вас начать писать тесты.
Тесты помогают командам работать вместе
Предыдущие пункты написаны из точки зрения отдельного разработчика, поддерживающего приложение. Сложные приложения будут поддерживаться командами. Тесты гарантируют, что коллеги не случайно не сломают ваш код (и что вы не сломаете их, не зная об этом). Если вы хотите зарабатывать на программировании на Django, вы должны быть хороши в написании тестов!
Основные стратегии тестирования
Существует множество способов подхода к написанию тестов.
Некоторые программисты следуют дисциплине, называемой "разработка по тестированию"; они на самом деле пишут тесты перед написанием кода. Это может показаться противоречащим здравому смыслу, но на самом деле это похоже на то, что большинство людей обычно делают: они описывают проблему, а затем создают некоторый код для ее решения. Разработка по тестированию формализует проблему в тестовом случае на Python.
Чаще всего новичок в тестировании создаст некоторый код и позже решит, что для него должны быть какие-то тесты. Возможно, было бы лучше написать некоторые тесты заранее, но никогда не поздно начать.
Иногда трудно понять, откуда начать писать тесты. Если вы написали несколько тысяч строк Python, выбрать что-то для тестирования может быть не легко. В таком случае полезно написать свой первый тест в следующий раз, когда вы вносят изменения, будь то добавление новой функции или исправление ошибки.
Так что давайте это сделать сразу.
Написание нашего первого теста
Мы выявляем ошибку
К счастью, в приложении polls есть небольшая ошибка, которую мы можем исправить сразу: метод Question.was_published_recently() возвращает True, если Question была опубликована в течение последнего дня (что верно), но также если поле pub_date Question находится в будущем (что, конечно, не так).
Подтвердите ошибку, используя shell, чтобы проверить метод на вопросе, дата которого находится в будущем:
cd ~/project/mysite
python manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> ## создаем экземпляр Question с pub_date через 30 дней
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> ## была ли она опубликована недавно?
>>> 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() возвращает False для вопросов, у которых pub_date
находится в будущем.
"""
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
и вы увидите что-то вроде:
[object Object]
Различая ошибка?
Если вместо этого у вас возникает NameError, вы, возможно, пропустили шаг в Часть 2 <tutorial02-import-timezone>, где мы добавили импорты datetime и timezone в polls/models.py. Скопируйте импорты из этого раздела и попробуйте запустить тесты снова.
Что произошло:
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() возвращает False для вопросов, у которых pub_date
старше 1 дня.
"""
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() возвращает True для вопросов, у которых pub_date
находится в течение последнего дня.
"""
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 предоставляет тестовый ~django.test.Client для имитации взаимодействия пользователя с кодом на уровне представления. Мы можем использовать его в tests.py или даже в shell.
Мы начнем снова с shell, где нам нужно сделать несколько вещей, которые не понадобятся в tests.py. Первое - это настройка тестовой среды в shell:
python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
~django.test.utils.setup_test_environment устанавливает рендер шаблонов, который позволит нам изучить некоторые дополнительные атрибуты в ответах, такие как response.context, которые иначе не будут доступны. Обратите внимание, что этот метод не настраивает тестовую базу данных, поэтому следующее будет выполняться на основе существующей базы данных, и вывод может отличаться в зависимости от вопросов, которые вы уже создали. Вы можете получить непредвиденные результаты, если ваш TIME_ZONE в settings.py не правильный. Если вы не помните, назначили ли его ранее, проверьте его перед продолжением.
Далее нам нужно импортировать класс тестового клиента (позже в tests.py мы будем использовать класс django.test.TestCase, который自带自己的 клиент, поэтому этого не понадобится):
>>> from django.test import Client
>>> ## создаем экземпляр клиента для нашего использования
>>> client = Client()
С этим готовым, мы можем попросить клиента сделать для нас какую-то работу:
>>> ## получаем ответ от '/'
>>> response = client.get("/")
Not Found: /
>>> ## мы должны ожидать 404 от этого адреса; если вместо этого вы видите
>>> ## ошибку "Invalid HTTP_HOST header" и ответ 400, вы, вероятно,
>>> ## опустили вызов setup_test_environment() описанный выше.
>>> response.status_code
404
>>> ## с другой стороны, мы должны ожидать найти что-то по адресу '/polls/'
>>> ## мы будем использовать'reverse()' вместо жестко заданного 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 находится в будущем). Давайте это исправим.
В **Обработка форм и сокращение нашего кода** мы ввели представление на основе класса, основанного на ~django.views.generic.list.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()) возвращает queryset, содержащий 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.
"""
question = create_question(question_text="Past question.", days=-30)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(
response.context["latest_question_list"],
[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.
"""
question = 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],
)
def test_two_past_questions(self):
"""
The questions index page may display multiple questions.
"""
question1 = create_question(question_text="Past question 1.", days=-30)
question2 = create_question(question_text="Past question 2.", days=-5)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(
response.context["latest_question_list"],
[question2, question1],
)
Давайте более внимательно рассмотрим некоторые из них.
Первое - это функция-укладчик вопросов, create_question, чтобы избавиться от некоторой повторяемости в процессе создания вопросов.
test_no_questions не создает никаких вопросов, но проверяет сообщение: "No polls are available." и подтверждает, что latest_question_list пустой. Обратите внимание, что класс django.test.TestCase提供了一些额外的断言方法。在这些示例中,我们使用了 ~django.test.SimpleTestCase.assertContains() 和 ~django.test.TransactionTestCase.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 включает ~django.test.LiveServerTestCase, чтобы облегчить интеграцию с такими инструментами, как Selenium.
Если у вас есть сложное приложение, вы, возможно, захотите автоматически запускать тесты при каждом коммите для целей непрерывной интеграции, чтобы контроль качества был - по крайней мере, частично - автоматизирован.
Хороший способ выявить не протестированные части вашего приложения - это проверить покрытие кода. Это также помогает выявить неустойчивый или даже мертвый код. Если вы не можете протестировать часть кода, это обычно означает, что этот код следует переписать или удалить. Покрытие поможет выявить мертвый код. См. topics-testing-code-coverage для подробностей.
Testing in Django </topics/testing/index> содержит полную информацию о тестировании.
Резюме
Поздравляем! Вы завершили лабораторную работу "Создание автоматических тестов". Вы можете практиковаться в других лабораторных работах в LabEx, чтобы улучшить свои навыки.