Привет мир! Сегодня будет напряженный день.

Во вчерашней статье я обсуждал, как обрабатывать ошибки во Flask и отправлять сообщение об ошибке клиенту, чтобы он знал, в чем проблема. В этой статье мы собираемся реализовать функцию сброса пароля в нашем приложении и написать модульный тест, который может протестировать один из методов в этом API.

Реализация сброса пароля

Прежде чем реализовать функцию сброса пароля в приложении, лучше понять, как она работает. Вот схема, которая объясняет процесс сброса пароля:

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

Во-вторых, пользователь будет использовать токен сброса и отправить его по пути /reset, где будет метод, который позволит пользователю изменить свой пароль — при условии, что он дал соответствующий токен сброса. После этого сервер отправит электронное письмо с подробной информацией об успешном сбросе пароля.

Чтобы сгенерировать токен сброса пароля, мы будем использовать библиотеку flask-jwt-extended. Однако у нас уже установлена ​​эта библиотека при реализации аутентификации и авторизации, поэтому ее устанавливать не нужно.

Вторая библиотека, которая нам понадобится, — Flask Mail. Это можно установить с помощью pipenv install flask-mail. После установки мы можем прописать почтовый сервер в нашем файле app.py.

https://gist.github.com/RussellAjax/5ba6e277e2f8a2649c3299b4904f43d7

Теперь давайте создадим сервис для отправки электронной почты клиенту. Эта услуга будет ответом на первый этап, упомянутый выше. Для этого создайте новый каталог с именем services и новый файл с именем mail_service.py внутри него. Добавьте следующий код во вновь созданный файл:

https://gist.github.com/RussellAjax/bcb0d3f379ef8a6af60016ea3ce07e24

Здесь вы можете видеть, что мы создали функцию send_mail(), которая принимает subject, sender, recipients, text_body и html_body в качестве аргументов. Затем он создает объект сообщения и запускает send_async_email() в отдельном потоке. Это происходит потому, что при отправке электронной почты клиенту мы должны ретранслировать отдельные службы, такие как Google, Outlook и т. д.

Поскольку этим службам может потребоваться некоторое время для фактической отправки электронной почты, мы собираемся сообщить клиенту, что его запрос был успешным, и начать отправку электронной почты в отдельном потоке.

Теперь мы готовы реализовать сброс пароля. Как показано на диаграмме и объяснении выше, для этого мы создадим две разные конечные точки:

  1. /forget: эта конечная точка принимает электронную почту пользователя и отправляет пользователю электронное письмо со ссылкой, содержащей токен сброса для сброса пароля.
  2. /reset: Эта конечная точка принимает два аргумента: reset_token, отправленный из сообщения электронной почты в /forget, и новый password, который хочет пользователь.

Чтобы создать этот метод, создайте файл Python внутри resources с именем reset_password.py со следующим кодом:

https://gist.github.com/RussellAjax/736d53681f76137fbf7a247d65a7cdcf

Здесь, в ресурсе ForgotPassword, компьютер получит пользователя на основе email, предоставленного клиентом. Затем серверный компьютер будет использовать create_access_token() для создания уникального токена на основе user.id. Срок действия токена истекает через 24 часа. Наконец, сервер отправит клиенту электронное письмо, содержащее информацию как в формате HTML, так и в текстовом формате.

Точно так же ResetPassword получит пользователя на основе идентификатора пользователя из уникального токена сброса, а затем сбросит пароль на основе запроса, предоставленного пользователем клиенту. Затем компьютер на стороне сервера отправит электронное письмо о том, что изменение пароля произошло успешно.

Теперь давайте создадим новые исключения — EmailDoesnotexists и BadTokenError — в нашем errors.py:

https://gist.github.com/RussellAjax/1cefbe8fb7604149e12e18e4c051d636

Далее нам нужно создать шаблоны HTML и текстовых файлов для электронной почты. Для этого создайте папку templates в корневом каталоге с другим каталогом с именем email внутри. Здесь создайте два новых файла: reset_password.html и reset_password.txt.

https://gist.github.com/RussellAjax/61240238f7762ffb74755f758d534dec

Файл .txt по сути представляет собой необработанную версию HTML:

Dear, User
To reset your password click on the following link:
{{ url }}
If you have not requested a password reset simply ignore this message.
Sincerely
Movie-bag Support Team

Здесь {{ url }} заменяет URL-адрес, который мы отправили ранее в функции render_templat().

Не забудьте инициализировать эти конечные точки routes.py:

https://gist.github.com/RussellAjax/7dfc44148e6d5d997aa93e23450d83eb

Теперь, если вы попытаетесь запустить приложение с python app.py, вы увидите следующую ошибку:

ImportError: cannot import name 'initialize_routes' from 'resources.routes'

Это связано с циклической зависимостью.

Круговая зависимость

Циклическая зависимость возникает, когда два или более модулей зависят друг от друга. Это связано с тем, что каждый модуль определяется с точки зрения другого. Например:

functionA():
    functionB()

И:

functionB():
    functionA()

В приведенном выше коде показана довольно очевидная циклическая зависимость. functionA() вызывает functionB(), таким образом, завися от него. Однако functionB() также вызывает functionA(), тем самым создавая еще одну зависимость.

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

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

Циклический импорт — это форма циклической зависимости, которая создается с помощью оператора import в Python.

Например, давайте проанализируем следующий код.

Модуль 1:

#module1
import module2
def function1():
  module2.function2()
def function3():
  print("Goodbye, World!")

Модуль 2:

#module2
import module1
def function2():
  print("Hello, World!")
  module1.function3()

__init__.py:

#__init__.py
import module1
module1.function1()

Когда Python импортирует модуль, он проверяет реестр модулей, чтобы убедиться, что модуль уже был импортирован. Если модуль уже был зарегистрирован, Python использует этот существующий объект из кеша. Реестр модулей — это таблица модулей, которые были инициализированы и проиндексированы по имени модуля. Доступ к этой таблице можно получить через sys.modules.

Если он не был зарегистрирован, Python находит модуль, при необходимости инициализирует его и выполняет в пространстве имен нового модуля.

В приведенном выше примере, когда Python достигает import module2, он загружает и выполняет его. Однако module2 также требует module1, который, в свою очередь, определяет function1(). Проблема возникает, когда function1() пытается вызвать function3() модуля1. Поскольку module1 была загружена первой и, в свою очередь, загрузила module2 до того, как достигла function3(), эта функция еще не определена и при вызове выдает ошибку.

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

Простое решение состоит в том, что оба модуля можно объединить в один более крупный модуль. Результирующий код из нашего примера выше будет выглядеть примерно так:

def function1():
  function2()
def function2():
  print("Hello, World!")
  function3()
def function3():
  print("Goodbye, World!")
function1()

Однако объединенные модули могут иметь некоторые несвязанные функции (сильная связь) и могут стать очень большими, если два модуля уже содержат много кода.

Таким образом, другим решением могла бы быть отсрочка импорта module2, чтобы импортировать его только тогда, когда это необходимо. Это можно сделать, поместив импорт module2 в определение function1():

#module1
def function1():
  import module2
  module2.function2()
def function3():
  print("Goodbye, World!")

В этом случае Python сможет загружать все функции в module1, а затем загружать module2 только при необходимости. Другими словами, Python может завершить загрузку функций без прерывания процесса другим оператором import.

Этот подход не противоречит синтаксису Python, как сказано в документации Python:

Обычно, но не обязательно, все операторы импорта помещаются в начало модуля (или скрипта, например).

В документации Python также говорится, что рекомендуется использовать import X вместо других операторов, таких как from module import * или from module import a, b, c.

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

Циклический импорт — это частный случай циклических ссылок. Как правило, их можно решить с помощью лучшего дизайна кода. Однако иногда конечный проект может содержать большое количество кода или смешивать несвязанные функции (сильная связь).

Вернемся к нашему коду.

Проблема циклической зависимости в нашем приложении возникает из-за того, что reset_password.py импортирует send_mail, send_mail импортирует app из app.py, тогда как app еще не определено в app.py. Чтобы решить эту проблему, создайте еще один файл run.py в корневом каталоге. Этот файл будет отвечать за запуск приложения. Кроме того, это означает, что мы инициализируем наши функции маршрутов/представлений после инициализации нашего приложения. Прямо сейчас ваш app.py должен выглядеть следующим образом:

https://gist.github.com/RussellAjax/5ba6e277e2f8a2649c3299b4904f43d7

Затем в вашем файле run.py напишите следующее:

https://gist.github.com/RussellAjax/2778570e1695bf0ebe581e08881d2348

После этого добавьте конфигурацию для MAIL_SERVER в .env:

JWT_SECRET_KEY = 't1NP63m4wnBg6nyHYKfmc2TpCOGI4nss'
MAIL_SERVER = "localhost"
MAIL_PORT = "1025"
MAIL_USERNAME = "[email protected]"
MAIL_PASSWORD = ""

А затем запустите SMTP-сервер в терминале с python -m smtpd -n -c DebuggingServer localhost:1025. Это создаст SMTP-сервер для тестирования нашей функции электронной почты. Теперь запустите приложение с python run.py (не забудьте экспортировать ENV_FILE_LOCATION, используя комментарий set=./.env )

Написание модульных тестов

После написания нашей системы сброса пароля мы собираемся реализовать модульные тесты для наших конечных точек REST API. Есть несколько важных причин для внедрения модульных тестов (особенно при разработке программного обеспечения на производственном уровне), в том числе:

  • Чтобы убедиться, что приложение не сломается при внесении изменений/рефакторинге
  • Для автоматизации повторяющихся ручных испытаний, уменьшающих человеческие ошибки
  • Чтобы выпускать продукцию вовремя
  • Тестирование обеспечивает лучший рабочий процесс CI/CD (непрерывная интеграция, непрерывная доставка).

Когда дело доходит до тестирования, есть два популярных инструмента для тестирования кода Python:

  1. unittest: стандартная библиотека Python, предоставляющая множество инструментов для построения и запуска тестов.
  2. pytest: библиотека Python, рассматриваемая как надмножество unittest.

В этом уроке мы узнаем, как писать тесты с использованием unittest, потому что это позволяет нам писать наш тест с принципами ООП.

Прежде чем мы начнем, удалите строку ниже в файле app.py:

app.config['MONGODB_SETTINGS'] = {
  'host': 'mongodb://localhost/movie-bag'
}

и добавьте в файл .env следующее:

MONGODB_SETTINGS = {
  'host': 'mongodb://localhost/movie-bag'
}

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

Прежде всего, создайте новый файл .env для хранения наших конфигураций, связанных с тестами. Мы должны отделить тестовые конфигурации от конфигураций разработки и производства. В корне каталога создайте файл .env.test и добавьте следующие конфигурации:

JWT_SECRET_KEY = 'super-secret'
MAIL_SERVER: "localhost"
MAIL_PORT = "1025"
MAIL_USERNAME = "[email protected]"
MAIL_PASSWORD = ""
MONGODB_SETTINGS = {
    'host': 'mongodb://localhost/movie-bag-test'
}

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

Теперь давайте создадим новую папку tests внутри нашего корневого каталога. Создайте новый файл test_signup.py :

https://gist.github.com/RussellAjax/9c18ab9a719441a13fb1e64b4f6e7f7c

Прежде всего, мы определяем класс SignupTest, который расширяет класс unittest.TestCase. TestCase предоставляет нам полезные методы, такие как setUP и tearDown, а также другие методы утверждений.

Метод setUp() запускается каждый раз перед запуском каждого метода, определенного в классе SignupTest. setUp(), как следует из названия, используется для настройки нашей тестовой инфраструктуры перед запуском теста.

Здесь вы можете видеть, что мы определяем this.app и this.db в методе. Мы используем app.test_client() вместо app, потому что это упрощает тестирование нашего фляжного приложения. Кроме того, мы получаем экземпляр Database с db.get_db() и устанавливаем для него значение this.db.

Точно так же test_successful_signup() — это метод, который фактически тестирует функцию Signup. Здесь мы определили полезную нагрузку, которая должна иметь значение JSON. И отправляем POST-запрос на /api/auth/signup.

Ответ на запрос используется для окончательного утверждения, действительно ли функция Signup отправила идентификатор пользователя и код успешного состояния — 200.

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

Тестовая изоляция

Изоляция тестов — одна из самых важных концепций в тестировании. Обычно, когда мы пишем тесты, мы тестируем одну бизнес-логику — конкретный метод. Идея изоляции тестов заключается в том, что один из ваших тестов никоим образом не должен влиять на другой тест. Другими словами, каждый тест должен быть независимым и полностью зависеть от самого себя.

Предположим, что вы создали пользователя в своем тесте регистрации, а затем собираетесь протестировать метод входа. В соответствии с принципами изоляции тестов вы не можете использовать один и тот же пользователь, сгенерированный в ходе теста «Регистрация», для теста «Вход». Это гарантирует, что никакие методы никак не влияют друг на друга, и что метод входа в систему может работать исключительно по своей собственной функции — для входа пользователей.

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

Перед запуском теста убедитесь, что переменная среды экспортирована. Не забудьте предварительно включить виртуальную среду с помощью pipenv shell. Для запуска теста введите команду:

python -m unittest test/test_signup.py

Вы должны увидеть результат следующим образом:

-----------------------------------------
Ran 1 test in 1.023s
OK

Это означает, что наш тест прошел успешно.

Кода

Сегодняшняя сессия была очень сложной! Особенно с учетом того объема работ, который необходимо было выполнить. Тем не менее, сегодняшние проблемы принесли несколько ценных уроков:

Во-первых, важен хороший дизайн кода. Сегодня я впервые слышу о круговых зависимостях и о том, что это распространенная ошибка среди разработчиков программного обеспечения. По-видимому, при отсутствии надлежащего и тщательного планирования команда разработчиков может по незнанию совершать ошибки, приводящие к круговым зависимостям. Как только кодовая база увеличится в сложности и размере, программистам будет намного сложнее исправить проблемы с круговой зависимостью. Следовательно, важно, чтобы: (1) Команда разработчиков села и спланировала надлежащий дизайн кода и дисциплину, которой должен следовать каждый член команды. (2) Создайте надлежащую документацию, чтобы посреди бесчисленных коммитов и push-запросов команда могла отследить, где что-то пошло не так.

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

Эти уроки снова напомнили мне, что программирование — это не просто хобби, это искусство. Подобно тому, как пианист проводит бесчисленные часы, практикуя гаммы, изучая теорию музыки и пробуя различные техники, чтобы овладеть своим ремеслом, программисты должны проводить свое время с намерением иметь глубокое понимание и специализацию в том, что они делают. Я считаю, что ключом к достижению такого совершенства является постоянство.