Часть 15: Модульное тестирование интерфейсов

Python Django

автор: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Интерфейс всего один, а функция относительно проста, сначала мы используем ее в качестве примера, чтобы объяснить, как писать юнит-тесты.

Общие шаги для тестирования интерфейса:

  1. Получите URL-адрес интерфейса.
  2. Отправить запрос в интерфейс.
  3. Убедитесь, что код состояния 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 пункта:

  1. Создает запрос объекта HTTP-запроса.
  2. Установите значение свойства _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% покрытие тестами не означает, что программа свободна от ошибок. В сети могут быть разные странные задачи, и эти задачи могут быть не написаны как тестовые случаи, поэтому они не тестируются. Но в любом случае, мы сейчас провели относительно достаточный тест, можно подумать о выпуске версии. Если в будущем вы столкнетесь с какими-либо проблемами в сети или подумываете о новых тест-кейсах, вы можете добавить юнит-тесты в любое время, и вероятность ошибок в программе в будущем будет становиться все ниже и ниже.


Подпишитесь на официальный аккаунт, чтобы присоединиться к группе обмена