автор:HelloGitHub-Dream Chaser
Для полного проекта, будь то личного или корпоративного, автоматизированное модульное тестирование необходимо, иначе любые функциональные изменения в будущем станут вашей катастрофой.
Предположим, вы поддерживаете в своей компании проект, в котором разработаны десятки API-интерфейсов, но без каких-либо юнит-тестов. Теперь ваш руководитель просит вас модифицировать несколько интерфейсов и реализовать некоторые новые функции.После получения требований вы эффективно выполняете задачи по разработке, а затем вручную тестируете измененные интерфейсы и новые реализованные функции, чтобы убедиться в отсутствии проблем.Отправил код С радостью.
После выхода кода в сеть возникла ошибка.После анализа причины было обнаружено, что новое изменение вызвало проблему со старым интерфейсом API.Поскольку перед выходом в сеть тестировался только измененный интерфейс, проблему найти не удалось. Ваш руководитель раскритиковал вас.Вы записали минус из-за несчастного случая.Вы можете получить только 3,25 в конце года,что очень мизерно.
Но если у нас есть комплексные модульные тесты, вышеописанной ситуации, скорее всего, удастся избежать. Просто запустите модульный тест один раз перед выпуском кода, и затронутая функция немедленно сообщит об ошибке, чтобы проблему можно было найти до развертывания кода, что позволяет избежать онлайн-аварий.
Конечно, приведенные выше истории являются чисто выдуманными, я просто надеюсь, что в процессе разработки у всех выработаются хорошие привычки.Первый — написать элегантный код, а второй — протестировать собственный код..
обзор модульного теста
в предыдущем урокеУчебное пособие по блогу Django (второе издание)изМодульное тестирование: тестирование приложения блога,Модульное тестирование: тестирование приложения для проверки,Статистическое тестовое покрытие Coverage.pyВ разделе мы подробно объясняем, как использовать фреймворк модульного тестирования django. Здесь мы сделаем общий обзор среды тестирования djnago. Что касается того, как писать и запускать тесты, мы подробно объясним позже. Если вы хотите получить более общее представление о модульном тестировании django, рекомендуется вернуться и посмотреть 3 о тестировании, туториалы и официальная документация по django.
Вот некоторые основные моменты фреймворка модульного тестирования djnago:
- Среда модульного тестирования Django основана на среде модульного тестирования Python.
- django предоставляет несколько классов XXTestCase, все из которых прямо или косвенно наследуются от
unittest.TestCase
класс, потому что среда модульного тестирования django основана на модульном тесте, поэтому написанные классы тестовых случаев также должны наследоваться прямо или косвенноunittest.TestCase
. Обычно мы наследуем XXTestCase, предоставленный django, потому что эти классы настраивают больше функций для django. - По умолчанию тестовый код должен быть помещен в файл test.py или пакет тестов в приложении django.Django автоматически обнаружит модули, начинающиеся с test в пакете тестов (такие как test_models.py, test_views.py), а затем выполните методы тестового примера в классе с именем, начиная с test.
- Команда python manage.py test может запускать модульные тесты.
Отсортируйте интерфейсы, которые необходимо протестировать
Далее мы напишем модульные тесты для интерфейса API блога. Что касается интерфейса API, наша основная задача заключается в следующем:Возвращает правильный ответ на конкретный запрос. Давайте сначала разберемся с интерфейсами и функциональными точками, которые необходимо протестировать.
Основной интерфейс блога сосредоточен вPostViewSet
иCommentViewSet
Оба взгляда сфокусированы.
-
CommentViewSet
Интерфейс вьюсета относительно прост, то есть создать комментарий. -
PostViewSet
Интерфейс набора представлений включает в себя список статей, детали статьи, список комментариев, список дат архива и т. д. Для интерфейса списка статей вы также можете отфильтровать запрошенный ресурс списка статей с помощью параметров запроса, чтобы получить подмножество всех статей.
Тестовый набор комментариев
CommentViewSet
Интерфейс всего один, а функция относительно проста, сначала мы используем ее в качестве примера, чтобы объяснить, как писать юнит-тесты.
Общие шаги для тестирования интерфейса:
- Получите URL-адрес интерфейса.
- Отправить запрос в интерфейс.
- Убедитесь, что код состояния HTTP ответа, возвращенные данные и т. д. соответствуют ожиданиям.
Давайте протестируем код, который создает комментарийtest_create_valid_comment
Например:
# filename="comments/tests/test_api.py
from django.apps import apps
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase
from blog.models import Category, Post
from comments.models import Comment
class CommentViewSetTestCase(APITestCase):
def setUp(self):
self.url = reverse("v1:comment-list")
# 断开 haystack 的 signal,测试生成的文章无需生成索引
apps.get_app_config("haystack").signal_processor.teardown()
user = User.objects.create_superuser(
username="admin", email="admin@hellogithub.com", password="admin"
)
cate = Category.objects.create(name="测试")
self.post = Post.objects.create(
title="测试标题", body="测试内容", category=cate, author=user,
)
def test_create_valid_comment(self):
data = {
"name": "user",
"email": "user@example.com",
"text": "test comment text",
"post": self.post.pk,
}
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
comment = Comment.objects.first()
self.assertEqual(comment.name, data["name"])
self.assertEqual(comment.email, data["email"])
self.assertEqual(comment.text, data["text"])
self.assertEqual(comment.post, self.post)
Во-первых, адрес URL интерфейса:reverse("v1:comment-list")
.reverse
Функция анализирует соответствующий URL-адрес через имя функции представления Формат имени функции представления:"<namespace>:<basename>-<action name>"
.
где пространство именinclude
указанная функцияnamespace
значение параметра, например:
path("api/v1/", include((router.urls, "api"), namespace="v1"))
basename — это маршрутизатор вregister
Значение параметра basename указывается при просмотре набора, например:
router.register(r"posts", blog.views.PostViewSet, basename="post")
имя действия задается декоратором действияurl_name
Значение параметра или список по умолчанию, получение, создание, обновление, удаление стандартных имен действий, например:
# filename="blog/views.py
@action(
methods=["GET"], detail=False, url_path="archive/dates", url_name="archive-date"
)
def list_archive_dates(self, request, *args, **kwargs):
pass
следовательно,reverse("v1:comment-list")
Будет разрешено в /api/v1/comments/.
Затем мы отправляем запрос POST на этот URL:response = self.client.post(self.url, data)
, потому что он наследуется от тестового класса, предоставленного django-reset-frameworkAPITestCase
, так что можно напрямуюself.client
отправить запрос, гдеself.client
предоставляется django-rest-frameworkAPIClient
Экземпляр , предназначенный для отправки тестовых HTTP-запросов.
Наконец, результат ответа на запросresponse
Проверьте. Код состояния, возвращаемый после успешного создания комментария, должен быть 201, а данные, возвращаемые интерфейсом, находятся вresponse.data
В свойствах мы утверждаем код состояния и некоторые данные, возвращаемые интерфейсом, чтобы гарантировать соответствие ожидаемым результатам.
Конечно, это тот случай, когда комментарий успешно создан.При тестировании мы должны не только тестировать нормальную ситуацию, но и обращать внимание на граничные условия и ненормальные условия.Добавим тестовый пример, где формат данных комментария неверно, и создание завершается ошибкой:
# filename="comments/tests/test_api.py
def test_create_invalid_comment(self):
invalid_data = {
"name": "user",
"email": "user@example.com",
"text": "test comment text",
"post": 999,
}
response = self.client.post(self.url, invalid_data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Comment.objects.count(), 0)
Процедура осталась прежней: первый шаг — отправить запрос интерфейсу, а затем подтвердить ожидаемый результат ответа. Здесь, поскольку данные комментария неверны (связанный пост с идентификатором 999 не существует), ожидаемый возвращаемый код состояния — 400, и в базе данных не должно быть созданного комментария.
Тестовый PostViewSet
несмотря на то чтоPostViewSet
Включено много интерфейсов, но процедура каждого теста интерфейса такая же, как и выше, просто следуйте за тыквой и рисуйте совок. так какPostViewSet
Кодов тестов много, здесь перечислены только методы, соответствующие каждому тестовому случаю, а конкретная логика теста опущена. Для получения подробной информации вы можете просмотреть исходный код проекта на GitHub:
# filename="blog/tests/test_api.py
from datetime import datetime
from django.apps import apps
from django.contrib.auth.models import User
from django.core.cache import cache
from django.urls import reverse
from django.utils.timezone import utc
from rest_framework import status
from rest_framework.test import APITestCase
from blog.models import Category, Post, Tag
from blog.serializers import PostListSerializer, PostRetrieveSerializer
from comments.models import Comment
from comments.serializers import CommentSerializer
class PostViewSetTestCase(APITestCase):
def setUp(self):
# 断开 haystack 的 signal,测试生成的文章无需生成索引
apps.get_app_config("haystack").signal_processor.teardown()
# 清除缓存,防止限流
cache.clear()
# 设置博客数据
# post3 category2 tag2 2020-08-01 comment1 comment2
# post2 category1 tag1 2020-07-31
# post1 category1 tag1 2020-07-10
def test_list_post(self):
"""
这个方法测试文章列表接口,预期的响应状态码为 200,数据为文章列表序列化后的结果
"""
url = reverse("v1:post-list")
def test_list_post_filter_by_category(self):
"""
这个方法测试获取某个分类下的文章列表接口,预期的响应状态码为 200,数据为文章列表序列化后的结果
"""
url = reverse("v1:post-list")
def test_list_post_filter_by_tag(self):
"""
这个方法测试获取某个标签下的文章列表接口,预期的响应状态码为 200,数据为文章列表序列化后的结果
"""
url = reverse("v1:post-list")
def test_list_post_filter_by_archive_date(self):
"""
这个方法测试获取归档日期下的文章列表接口,预期的响应状态码为 200,数据为文章列表序列化后的结果
"""
url = reverse("v1:post-list")
def test_retrieve_post(self):
"""
这个方法测试获取单篇文章接口,预期的响应状态码为 200,数据为单篇文章序列化后的结果
"""
url = reverse("v1:post-detail", kwargs={"pk": self.post1.pk})
def test_retrieve_nonexistent_post(self):
"""
这个方法测试获取一篇不存在的文章,预期的响应状态码为 404
"""
url = reverse("v1:post-detail", kwargs={"pk": 9999})
def test_list_archive_dates(self):
"""
这个方法测试获取文章的归档日期列表接口
"""
url = reverse("v1:post-archive-date")
def test_list_comments(self):
"""
这个方法测试获取某篇文章的评论列表接口,预期的响应状态码为 200,数据为评论列表序列化后的结果
"""
url = reverse("v1:post-comment", kwargs={"pk": self.post3.pk})
def test_list_nonexistent_post_comments(self):
"""
这个方法测试获取一篇不存在的文章的评论列表,预期的响应状态码为 404
"""
url = reverse("v1:post-comment", kwargs={"pk": 9999})
мы начинаем сtest_list_post_filter_by_archive_date
В качестве примера, чтобы сделать объяснение, логика кода другого тестового примера аналогична.
# filename="blog/tests/test_api.py
def test_list_post_filter_by_archive_date(self):
# 解析文章列表接口的 URL
url = reverse("v1:post-list")
# 发送请求,我们这里给 get 方法的第二个参数传入了一个字典,这个字典代表了 get 请求的查询参数。
# 例如最终的请求的 URL 会被编码成:/posts/?created_year=2020&created_month=7
response = self.client.get(url, {"created_year": 2020, "created_month": 7})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# 如何检查返回的数据是否正确呢?对这个接口的请求,
# 我们预期返回的结果是 post2 和 post1 这两篇发布于2020年7月的文章序列化后的数据。
# 因此,我们使用 PostListSerializer 对这两篇文章进行了序列化,
# 然后和返回的结果 response.data["results"] 进行比较。
serializer = PostListSerializer(instance=[self.post2, self.post1], many=True)
self.assertEqual(response.data["results"], serializer.data)
запустить тест
Далее запустите тест:
"Linux/macOS"
$ pipenv run coverage run manage.py test
"Windows"
...\> pipenv run coverage run manage.py test
Большинство тестов пройдено, но один тест не пройден, а это значит, что мы нашли ошибку в тесте:
======================================================================
FAIL: test_list_archive_dates (blog.tests.test_api.PostViewSetTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "C:\Users\user\SpaceLocal\Workspace\G_Courses\HelloDjango\HelloDjango-rest-framework-tutorial\blog\tests\test_api.py", line 123, in test_list_archive_dates
self.assertEqual(response.data, ["2020-08", "2020-07"])
AssertionError: Lists differ: ['2020-08-01', '2020-07-01'] != ['2020-08', '2020-07']
что не получаетсяtest_list_archive_dates
В этом тестовом примере данные, возвращаемые интерфейсом даты архива статьи, не соответствуют нашим ожиданиям, мы ожидали получить список дат в формате гггг-мм, но интерфейс вернул гггг-мм-дд, чего не было во время нашего предыдущего теста. Тест выявил проблему, что в определенной степени подтвердило роль теста, подчеркнутую нами ранее.
Теперь, когда проблема найдена, давайте ее исправим. Я полагаю, что исправление этой ошибки уже должно быть для вас легкой задачей, так что оставьте это в качестве упражнения и не будем рассматривать его здесь.
Перезапустите тест и получите статус ok.
Ran 55 tests in 8.997s
OK
Все тесты пройдены.
Проверить тестовое покрытие
Достаточно ли вышеуказанных тестов? Естественно, его трудно обнаружить невооруженным глазом.Статистическое тестовое покрытие Coverage.pyВыше мы настроили Coverage.py и представили его использование.Вы можете напрямую запустить следующую команду, чтобы проверить тестовое покрытие кода:
"Linux/macOS"
$ pipenv run coverage report
"Windows"
...\> pipenv run coverage report
Результаты покрытия следующие:
Name Stmts Miss Branch BrPart Cover Missing
-----------------------------------------------------------------
blog\serializers.py 46 5 0 0 89% 82-86
blog\utils.py 21 2 4 1 88% 29->30, 30-31
blog\views.py 119 5 4 0 94% 191, 200, 218-225
comments\views.py 25 1 2 0 96% 59
-----------------------------------------------------------------
TOTAL 1009 13 34 1 98%
Видно, что общий охват тестами достиг 98%, но есть еще 4 файла, часть кода не тестировалась, в командной строке указан только номер строки кода (столбец Missing), не охваченный тестом, что не очень интуитивно понятно, выполните следующую команду. Команда может создать отчет в формате HTML, чтобы визуально увидеть фрагменты кода, не охваченные тестами:
"Linux/macOS"
$ pipenv run coverage html
"Windows"
...\> pipenv run coverage html
После выполнения команды в корневом каталоге проекта будет сгенерирована папка htmlcov, внутри которой откройте в браузере страницу index.html, чтобы просмотреть подробный отчет о пройденном тестами.
Пример HTML-страницы отчета:
Непокрытый код помечается красным фоном, что очень интуитивно понятно. можно увидеть в blog/views.pyCategoryViewSet
иTagViewSet
Если теста нет, можно дополнить тест по методике тестирования, описанной выше. Оба эти набора представлений очень просты, и задача тестирования остается в качестве упражнения.
Дополнительное тестирование
в блоге/serializers.pyHighlightedCharField
Непроверенный и новый в blog/utils.pyUpdatedAtKeyBit
Не тестировалось, пишем соответствующие тест-кейсы.
Протестировать UpdatedAtKeyBit
Есть только один UpdatedAtKeyBitget_data
метод, ожидаемая логика этого метода: получитьself.key
это кеш-значение ключа (время, когда кеш был установлен), если кеш промахивается, то берется текущее время и это время записывается в кеш.
Запишем ожидаемую логику в тестовый код следующим образом.Следует отметить, что этот вспомогательный класс не задействует работу с базой данных django, поэтому мы напрямую наследуемся от более простогоunittest.TestCase
, что может ускорить тест:
# filename="blog/tests/test_utils.py
import unittest
from datetime import datetime
from django.core.cache import cache
from ..utils import Highlighter, UpdatedAtKeyBit
class UpdatedAtKeyBitTestCase(unittest.TestCase):
def test_get_data(self):
# 未缓存的情况
key_bit = UpdatedAtKeyBit()
data = key_bit.get_data()
self.assertEqual(data, str(cache.get(key_bit.key)))
# 已缓存的情况
cache.clear()
now = datetime.utcnow()
now_str = str(now)
cache.set(key_bit.key, now)
self.assertEqual(key_bit.get_data(), now_str)
Тест HighlightedCharField
Мы говорили об этом, когда объясняли пользовательские сериализованные поля, сериализованные поля вызываются вызовомto_representation
метод для сериализации входящего значения.HighlightedCharField
Ожидаемая логика заключается в вызовеto_representation
После метода входящее значение будет выделено.
HighlightedCharField
Задействованы некоторые сложные операции, главным образом потому, чтоto_representation
Метод включает в себя операцию запроса HTTP-запроса. Когда вызывается обычная функция просмотра, функция просмотра получает параметр входящего запроса, а затем django-rest-framework передает запрос сериализатору (Serializer)_context
свойства, к любому сериализованному полю в сериализаторе можно получить доступ напрямую черезcontext
Косвенный доступ к свойствам_context
свойство для получения объекта запроса.
Но в модульных тестах таких вызовов функций представления может и не быть, поэтому_context
Настройка не будет производиться автоматически, нам нужно смоделировать поведение вызова функции просмотра и задать ее вручную. В основном включают 2 пункта:
- Создает запрос объекта HTTP-запроса.
- Установите значение свойства _context.
Конкретный код выглядит следующим образом, подробности см. в комментариях к соответствующим строкам кода:
# filename="blog/tests/test_serializer.py
import unittest
from blog.serializers import HighlightedCharField
from django.test import RequestFactory
from rest_framework.request import Request
class HighlightedCharFieldTestCase(unittest.TestCase):
def test_to_representation(self):
field = HighlightedCharField()
# RequestFactory 专门用来构造 request 对象。
# 这个 RequestFactory 生成的 request 代表了一个对 URL / 访问的 get 请求,
# 并包含 URL 参数 text=关键词。
# 请求访问的完整 URL 就是 /?text=关键词
request = RequestFactory().get("/", {"text": "关键词"})
# django-rest-framework 对 django 内置的 request 进行了包装,
# 因此这里要手动使用 drf 提供的 Request 类对 django 的 request 进行一层包装。
drf_request = Request(request=request)
# 设置 HighlightedCharField 实例 _context 属性的值,这样在其内部就可以通过
# self.context["request"] 拿到请求对象 request
setattr(field, "_context", {"request": drf_request})
document = "无关文本关键词无关文本,其他别的关键词别的无关的词。"
result = field.to_representation(document)
expected = (
'无关文本<span class="highlighted">关键词</span>无关文本,'
'其他别的<span class="highlighted">关键词</span>别的无关的词。'
)
self.assertEqual(result, expected)
Запустите команду проверки тестового покрытия еще раз, на этот раз тестовое покрытие составляет 100%:
Name Stmts Miss Branch BrPart Cover Missing
---------------------------------------------------
---------------------------------------------------
TOTAL 1047 0 32 0 100%
Конечно, следует напомнить, что 100% покрытие тестами не означает, что программа свободна от ошибок. В сети могут быть разные странные задачи, и эти задачи могут быть не написаны как тестовые случаи, поэтому они не тестируются. Но в любом случае, мы сейчас провели относительно достаточный тест, можно подумать о выпуске версии. Если в будущем вы столкнетесь с какими-либо проблемами в сети или подумываете о новых тест-кейсах, вы можете добавить юнит-тесты в любое время, и вероятность ошибок в программе в будущем будет становиться все ниже и ниже.
Подпишитесь на официальный аккаунт, чтобы присоединиться к группе обмена