Pull to refresh
323.1
PVS-Studio
Static Code Analysis for C, C++, C# and Java

Как тестировать сайт на Django. Часть 3. Отправка результата на почту, TestExplorer и декоратор tag

Reading time12 min
Views6K

Тесты написаны, тимлид рад, а что дальше-то делать? А дальше – автоматизация и отправка отчёта по тестам. Именно об этом мы поговорим в данной статье, попутно затронув полезный инструмент TestExplorer и декоратор tag.

Прошлые части статьи

Раз уж вы зашли на эту статью, думаю, тесты вам уже приходилось писать. Но, если хотите узнать о них больше, предлагаю посетить первую и вторую части. В них я на примере кода нашего сайта, рассказывают о тестировании таких веб-систем.

TestExplorer

TestExplorer — это расширение, позволяющее взаимодействовать с тестами не через консоль, а напрямую в вашей IDE. Можно сказать, что это GUI для прогона тестов.

TestExplorer встроен в PyCharm. Про его использование в этом редакторе можно почитать тут. VS Code, напротив, требует отдельной установки и настройки данного расширения. Рассмотрим, как это можно сделать.

Настраиваем TestExplorer для Django в VS Code

Установка

Скачиваем расширение Python Test Explorer for Visual Studio Code. Вместе с ним автоматически должно установиться ещё одно — Test Explorer UI. Поставьте его вручную, если этого не произошло.

Настройка. Способ 1

Настроить TestExplorer для работы с Django можно двумя способами. Начнём с первого. Он больше подходит для тестов конкретных приложений (а не всего проекта), либо для тестирования с помощью unittest.

Создадим обычный Django-проект my_project с приложением my_app. Открываем файл __init__.py в директории my_app и добавляем в него следующий код:

from os import environ
from django import setup

environ.setdefault(
    'DJANGO_SETTINGS_MODULE', 'my_project.settings'
)
  
setup()

TestExplorer берёт из нашего проекта файлы с тестами. Но где взять для них настройки, он не знает. Поэтому мы создаём переменную окружения DJANGO_SETTINGS_MODULE и с помощью метода setup передаём её в TestExplorer.

Далее нам нужно определиться с тестовым фреймворком. Обычно это либо pytest, либо unittest. О разнице между ними можно почитать тутunittest встроен в Python, поэтому устанавливать его нам не придётся. Для инсталляции pytest вводим эту команду в консоли:

pip install -U pytest

Теперь нам нужно «собрать» тесты. Открываем Command palette (Ctrl + Shift + P), вводим «Python: Configure Tests» и жмём Enter.

Выбираем нужный фреймворк и затем директорию с тестами — my_app. Если вы выбрали unittest, то вам нужно указать паттерн, по которому будут искаться тесты. В нашем случае это «test_.py».

Настройка. Способ 2

У Python TestExplorer есть специальный пакет pytest-django. С его помощью можно легко настроить работу TestExplorer через pytest для всего проекта, а не отдельного приложения. Сначала установим его:

pip install pytest-django

Далее создадим в нашем проекте файл pytest.ini с таким кодом:

[pytest]
DJANGO_SETTINGS_MODULE = my_project.settings
python_files = test_.py

Мы указываем, какой фреймворк использовать, где брать настройки проекта и по какому шаблону искать тесты. Теперь остаётся выполнить «сборку» тестов, как в первом способе с помощью «Python: Configure Tests».

Запуск тестов

Теперь, когда всё готово, начнём работать с тестами. Слева на панели Activity Bar жмём на значок колбы.

Откроется панель «Testing». В верхней её части отображается вся иерархия тестов проекта. В нижней — все файлы с тестами. Если вы написали все тесты из первой статьи, то панель «Testing» будет выглядеть так:

Наводим мышку на фразу «TEST EXPLORER» в любой из частей панели и видим справа блок с кнопками. Жмём кнопку «Run Tests» (треугольник), и все наши тесты начинают прогоняться. При успешном выполнении иерархия тестов будет выглядеть так:

Обзор возможностей TestExplorer

Обе панели TestExplorer позволяют:

  1. Запускать все тесты в обычном режиме;

  2. Запускать конкретный тест в отладочном режиме;

  3. Сбрасывать результаты тестов;

  4. Сортировать тесты;

  5. Переходить к коду конкретного теста;

  6. Получать время прогона тестов.

С помощью верхней панели можно:

  1. Искать конкретные тесты, применяя фильтры;

  2. Отображать output выполнения тестов;

  3. Отображать тесты в виде иерархии, либо в виде списка;

  4. Прогонять только упавшие тесты;

  5. Прогонять все тесты в режиме отладки;

  6. Скрыть тест из панели;

  7. Узнать, сколько тестов выполнено успешно (в процентах либо в количестве тестов).

Нижняя панель не такая функциональная, но она позволяет включить автопрогон конкретных тестовых файлов. Т.е. выбранные тесты будут автоматически выполняться при изменении любого кода проекта.

Советую поиграться с этими панелями, чтобы лучше понять их возможности.

Теперь зайдём на любой тест. Над названием метода появилась небольшая панель ссылок. С её помощью можно запускать и дебажить тест, не уходя с его кода. Также слева от номера строки можно заметить галочку. Это статус выполнения теста. В данном случае — успешный.

При нажатии ЛКМ на галочку тест прогонится ещё раз. Если нажать ПКМ, откроется расширенный список действий, которые можно выполнить над тестом.

Запуск TestExplorer в контейнере Docker

Сейчас довольно много проектов используют Docker и Docker Compose. Если не все зависимости из requirements.txt установлены на машине, то просто так запустить TestExplorer для такого проекта не получится. С этой проблемой я лично столкнулся при написании тестов для нашего сайта. Давайте разберём, как её решить.

Чтобы было, над чем ставить опыты, создадим обычный Django-проект с названием proj и добавим в него приложение app. Из приложения удаляем файл tests.py и добавляем папку tests. В ней создаём файл test_views.py с максимально простым тестом:

from django.test import TestCase

class ViewsTests(TestCase):
    def test_true(self):
        self.assertTrue(True)

Добавим файл pytest.ini в корень проекта.

[pytest]
DJANGO_SETTINGS_MODULE = proj.settings
python_files = test_*.py

Соберём все тесты через Command Palette, как было показано выше. И запустим TestExplorer. Результат будет таким:

Тест работает. Теперь представим, что у нас большой проект с множеством зависимостей. Сымитируем их одной библиотекой — faust. На её месте может быть любая другая, не установленная у вас на машине. Для имитации добавим в settings.py проекта данную строку:

import faust

Теперь при запуске сервера мы получаем ошибку ModuleNotFoundError, что вполне логично. TestExplorer тоже говорит о проблеме и выдаёт ImportErorr:

Поскольку библиотека faust — имитация множества библиотек, устанавливать её на локальную машину будет проблематично. Поэтому воспользуемся услугами Docker Compose. Для этого добавим в корень проекта 3 файла.

requirements.txt:

Django>=3.0,<4.0
psycopg2>=2.8
faust>=1.10.4

Dockerfile:

FROM python:3

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

WORKDIR /code
COPY requirements.txt /code/

RUN pip install -r requirements.txt
COPY . /code/

docker-compose.yml:

version: "3"

services:
  web:
    build: .
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - .:/code
    ports:
      - "8000:8000"

Запускаем контейнер:

docker-compose up

TestExplorer по-прежнему не работает, поскольку он запускается локально, а не через контейнер. А значит, использует только те зависимости, которые установлены на нашу машину. Чтобы заставить TestExplorer работать, нужно запустить его внутри контейнера. Для этого сначала надо к нему подключиться. VS Code для этого предлагает специальное расширение Docker. После его установки слева на панели вы увидите соответствующую иконку:

Жмём на неё и видим все запущенные контейнеры.

Жмём на название нашего контейнера и выбираем «Attach Visual Studio Code». Таким образом мы подключимся к контейнеру с помощью VS Code.

Чтобы увидеть содержимое контейнера, нужно открыть папку code. Именно в ней оно и хранится (мы указали это в Dockerfile). Для открытия папки используем команду Ctrl K + Ctrl O.

Для дальнейшей работы, возможно, придётся установить расширение Python прямо в контейнер. Затем нужно выбрать версию его интерпретатора в Command Palette. Лучше выбирать самую свежую.

Установим pytest-django в контейнер:

pip install pytest-django

И выполним сбор тестов с помощью флага collect-only:

python -m pytest --collect-only

Запускаем тесты и убеждаемся, что всё работает.

Запуск конкретных тестов

Допустим, вы нашли 404-ю ошибку на сайте и хотите узнать, есть ли ещё такие страницы. Тест на статус-код поможет это проверить. Но вы понимаете, что запускать все тесты ради одного — долго и неудобно. И как тогда быть? Конечно, можно воспользоваться TestExplorer, но, допустим, вам нужен консольный запуск тестов. Тогда можно закомментировать ненужные тесты или повесить на них skip. Но лучше воспользоваться декоратором tag. В него передаётся один или несколько идентификаторов, по которым можно обратиться к конкретному тесту.

@tag('идентификатор 1', ['идентификатор 2'], [...])

Возьмём наши тесты для пользователя-тестировщика из первой статьи и добавим к ним теги.

from django.test import tag

@tag('status_code')
def test_status_code(self):
    # ... 

@tag('links', 'links_and_redirects')
def test_links(self):
    # ...

@tag('redirects', 'links_and_redirects')
def test_redirects(self):
    # ...

Чтобы указать тег при запуске тестов, надо добавить конструкцию --tag=<тег> после основной команды.

python manage.py test --tag=<тег>

Теперь немного поиграемся.

Запуск только тестов на статус-код:

python manage.py test --tag=status_code

Запуск только тестов на ссылки:

python manage.py test --tag=links

Запуск только тестов на ссылки и редиректы (вариант 1):

python manage.py test --tag=links_and_redirects

Запуск только тестов на ссылки и редиректы (вариант 2):

python manage.py test --tag=links --tag=redirects

Сейчас мы вызываем конкретный тест или конкретную группу тестов. Но что если мы хотим запустить, наоборот, все тесты, кроме некоторых? Например, кроме тестов на редиректы. Команда --exclude-tag поможет нам это сделать.

python manage.py test --exclude-tag=redirects

Отправка результата выполнения тестов на почту

Каждый раз запускать тесты вручную — не самый лучший вариант. Гораздо удобнее, если тесты будут запускаться автоматически. А ещё лучше, чтобы результат их выполнения приходил нам на почту. Как это сделать, мы и разберём далее.

Самый простой способ автоматизации тестов — использование крона. Напишем скрипт, который будет выполнять тесты и отправлять их результат на почту.

Полный код скрипта

from smtplib import SMTP
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from subprocess import STDOUT, PIPE, Popen
from decouple import config
from emoji import emojize

class TestLauncher:
    """Класс запуска тестов"""

    __EMAILS = ('ваша почта',)

    __COMMAND = '/usr/bin/python3 manage.py test --noinput'

    __REPORT_SUBJECT = 'Результаты прогона тестов'

    __SUCCESS_STATUS = emojize(
        ":check_mark_button: Тесты выполнены успешно :check_mark_button:" \
        "\n:man_dancing_medium-dark_skin_tone: Пляшем" \
        ":woman_dancing_light_skin_tone:\n"
    )

    __FAILURE_STATUS = emojize(
        ":cross_mark: Тесты упали :cross_mark:\n" \
        "Полный печалити :weary_cat:\n"
    )

    def __init__(self):
        self.__process = Popen(
            self.__COMMAND.split(), stdout=PIPE, stderr=STDOUT
        )

        self.__output = iter(self.__process.stdout.readline, b'')
        self.__description = self.__get_description()
        self.__process.communicate()
        self.__status_code = self.__process.returncode

    def __is_successful(self) -> bool:
        """Если код запуска тестов - 0, тесты выполнены успешно"""

        return self.__status_code == 0

    def __get_description(self) -> str:
        """Описание выполнения тестов, взятое из output"""

        return ''.join(line.decode() + '\n' for line in self.__output)

    def send_report(self) -> None:
        """С помощью mailgun отправляет отчёт на указанные почты"""

        smtp_object = SMTP('smtp.eu.mailgun.org', 587)
        smtp_object.starttls()

        smtp_object.login(
            config('EMAIL_HOST_USER'), config('EMAIL_HOST_PASSWORD')
        )

        smtp_object.sendmail(
            config('TESTS_REPORT_SENDER_ADDRESS'),
            self.__EMAILS,
            self.__report.as_string()
        )

        smtp_object.quit()

    @property
    def __status(self) -> str:
        """Статус выполнения тестов"""

        return (
            self.__SUCCESS_STATUS
            if self.__is_successful()
            else self.__FAILURE_STATUS
        )

    @property
    def __body(self) -> str:
        """Полное содержимое отчёта о выполнении тестов"""

        body = f'{self.__status}<br>{self.__description}'.replace('\n', '<br>')

        return MIMEText(body, 'html')

    @property
    def __report(self) -> MIMEMultipart:
        """Отчёт о выполнении тестов"""

        report = MIMEMultipart()
        report['Subject'] = self.__REPORT_SUBJECT
        report.attach(self.__body)

        return report

if __name__ == '__main__':
    test_launcher = TestLauncher()
    test_launcher.send_report()

Рассмотрим код детально.

Импорты

Для отправки сообщений на почту мы используем класс SMTP встроенной библиотеки smtplib.

from smtplib import SMTP

Отчёт о выполнении тестов имеет MIME-тип multipart, а его содержимое — MIME-тип text. Их мы задаём с помощью классов MIMEMultipart и MIMEText.

from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

Класс Popen используется для выполнения программы в отдельном процессе. В нашем случае он запускает тесты и выдаёт их результат. STDOUT и PIPE передаются ему в качестве аргументов (об этом позже).

from subprocess import STDOUT, PIPE, Popen

Переменные окружения прописаны в файле .env. Для их получения используем функцию config.

from decouple import config

Чтобы немного разукрасить тестовый отчёт, добавим в него эмоджи (почему бы и нет) с помощью функции emojize.

from emoji import emojize

Поля класса TestLauncher

Все нужные методы и данные хранятся в одном классе — TestLauncher. Опишем его поля.

Кортеж с почтами, на которые будет выполняться отправка отчёта:

__EMAILS = ('ваша почта',)

Команда, запускающая наши тесты:

__COMMAND = '/usr/bin/python3 manage.py test --noinput'

Если после предыдущего тестирования база не удалилась, флаг noinput скажет сделать это без запроса на разрешение. Путь к Python у вас может быть другим. Чтобы узнать его, введите следующую команду в консоли:

which python3

Тема отправляемого на почту сообщения:

__REPORT_SUBJECT = 'Результаты прогона тестов'

Статусы выполнения тестов:

__SUCCESS_STATUS = emojize(
    ":check_mark_button: Тесты выполнены успешно :check_mark_button:" \
    "\n:man_dancing_medium-dark_skin_tone: Пляшем" \
    ":woman_dancing_light_skin_tone:\n"
)
__FAILURE_STATUS = emojize(
    ":cross_mark: Тесты упали :cross_mark:\n" \
    "Полный печалити :weary_cat:\n"
)

Они будут отображаться в теле сообщения. Чтобы немного их разукрасить, мы добавляем несколько эмоджи, используя функцию emojize.

Метод __init__

Переходим к методам. Начнём с __init__. Его действия следующие:

  1. Создаёт процесс, выполняющий запуск тестов. Первым аргументом он принимает нашу команду, разделённую на список. Чтобы в дальнейшем мы могли использовать результат выполнения тестов, указываем значение PIPE для stdout. А чтобы все ошибки шли в stdout, указываем для stderr значение STDOUT;

  2. Получает результат выполнения тестов из stdout;

  3. С помощью метода __get_description (будет рассмотрен позже) сохраняет описание выполнения тестов в переменную;

  4. Ждёт завершения процесса и записывает код его выполнения в атрибут returncode. Если он равен 0, значит всё прошло хорошо, и тесты выполнены успешно;

  5. Заносит код выполнения процесса в переменную __status_code.

def __init__(self):
    self.__process = Popen(
        self.__COMMAND.split(), stdout=PIPE, stderr=STDOUT
    ) # (1)

    self.__output = iter(self.__process.stdout.readline, b'') # (2)
    self.__description = self.__get_description() # (3)
    self.__process.communicate() # (4)
    self.__status_code = self.__process.returncode # (5)

Метод __is_successful

Как говорилось выше, если статус-код процесса — 0, значит, тесты выполнились успешно. Это проверяет метод __is_successful.

def __is_successful(self) -> bool:
    """Если код запуска тестов - 0, тесты выполнены успешно"""

    return self.__status_code == 0

Метод __get_description

Следующий метод выдаёт описание выполнения тестов. Он берёт его из output процесса, созданного в __init__. Каждая строка описания декодируется из байтовой в обычную, затем к ней добавляется «\n». После все строки объединяются через join.

def __get_description(self) -> str:
    """Описание выполнения тестов, взятое из output"""

    return ''.join(line.decode() + '\n' for line in self.__output)

Метод send_report

Теперь рассмотрим метод send_report. Вот его действия:

  1. Подключается к серверу mailgun и создаёт SMTP объект;

  2. Указывает, что соединение должно шифроваться с помощью TLS;

  3. Авторизуется под конкретным пользователем;

  4. Отправляет отчёт о тестах на указанные почты;

  5. Закрывает соединение.

def send_report(self) -> None:
    """С помощью mailgun отправляет отчёт на указанные почты"""

    smtp_object = SMTP('smtp.eu.mailgun.org', 587) # (1)
    smtp_object.starttls() # (2)

    smtp_object.login( # (3)
        config('EMAIL_HOST_USER'), config('EMAIL_HOST_PASSWORD')
    )

    smtp_object.sendmail( # (4)
        config('TESTS_REPORT_SENDER_ADDRESS'),
        self.__EMAILS,
        self.__report.as_string()
    )

    smtp_object.quit() # (5)

Свойство __status

Перейдём к свойствам класса TestLauncher. Первое — __status. Оно в зависимости от значения статус-кода процесса возвращает текст, который будет добавлен в отчёт.

@property
def __status(self) -> str:
    """Статус выполнения тестов"""

    return (
        self.__SUCCESS_STATUS
        if self.__is_successful()
        else self.__FAILURE_STATUS
    )

Свойство __body

__body возвращает полный текст для отчёта с MIME-типом text.

@property
def __body(self) -> str:
    """Полное содержимое отчёта о выполнении тестов"""

    body = f'{self.__status}<br>{self.__description}'.replace('\n', '<br>')

    return MIMEText(body, 'html')

Свойство __report

Последнее свойство — __report. Как бы это ни было удивительно, но оно возвращает отчёт о выполнении тестов с MIME-типом multipart. Сначала указывается тема, после с помощью метода attach добавляется основное содержимое.

@property
def __report(self) -> MIMEMultipart:
    """Отчёт о выполнении тестов"""

    report = MIMEMultipart()
    report['Subject'] = self.__REPORT_SUBJECT
    report.attach(self.__body)

    return report

Отправка сообщения

Заключительная часть этого кода — создание объекта класса TestLauncher и отправка отчёта.

if __name__ == '__main__':
    test_launcher = TestLauncher()
    test_launcher.send_report()

Запуск скрипта

В кроне пропишем следующую команду:

@daily /usr/bin/python3 send_tests_report.py

Она ежедневно в 00:00 (для Timezone – UTC) будет выполнять созданный нами скрипт.

При его успешном выполнении на почту придёт такое сообщение:

При фейле сообщение будет таким:

Заключение

Тестирование кода – это хорошо, но не стоит забывать про безопасность. С этой точки зрения интерес может представлять методология статического анализа кода для выявления потенциальных уязвимостей в back-end части. Вот несколько статей, которые познакомят вас с этой темой:

  1. Почему моё приложение при открытии SVG-файла отправляет сетевые запросы?

  2. XSS: атака и защита с точки зрения C# программирования.

  3. OWASP, уязвимости и taint анализ в PVS-Studio C#. Смешать, но не взбалтывать.

Надеюсь, эта статья была вам полезна. Для фидбека или критики пишите в комментарии, либо в мой инстаграм. Спасибо за внимание и до скорых встреч)

Tags:
Hubs:
Total votes 6: ↑3 and ↓30
Comments8

Articles

Information

Website
pvs-studio.com
Registered
Founded
2008
Employees
31–50 employees