Пишите лучше, более тестируемый Rust

В этой статье мы увидим, как структурировать проект на Rust так, чтобы его можно было легко тестировать. Мы создадим простой модуль аутентификации, который будет доступен через REST API при сохранении данных в PostgreSQL и Redis. Мы будем использовать actix-web для обработки части REST API, sqlx для взаимодействия с PostgreSQL и redis-rs для взаимодействия с Redis. Мы увидим, как приложение разбивается на более мелкие компоненты, которые можно тестировать. Окончательный исходный код приложения доступен на GitHub.

Фон

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

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

У меня возникла эта проблема, когда я работал над веб-проектом на Rust. Я пытался черпать вдохновение в некоторых проектах с открытым исходным кодом. Однако уровень тестирования, который они проводят, мне не нравится. У них либо их нет, либо они просто проводят интеграционные тесты, пропуская модульные тесты.

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

Шаблон "Порты и адаптеры"

У меня ограниченный опыт работы за пределами веб-проектов по разработке программного обеспечения, поэтому отнеситесь к этому разделу с недоверием.

Большую часть программного обеспечения можно структурировать с помощью шаблона портов и адаптеров. В этом шаблоне вы структурируете свой проект по следующим типам компонентов:

  1. Домен: эти компоненты выполняют логику, зависящую от домена. Например, если ваш домен является банковским, то компонент домена имеет логику для выполнения специфических для банковского дела функций, таких как перевод денег между счетами.
  2. Порты: «контракт» между внешними системами на взаимодействие с вашим доменом вне зависимости от того, извне он или изнутри.
  3. Адаптеры: эти компоненты «адаптируют» внешние системы к вашим портам. Например, компонент адаптера адаптирует HTTP-запросы к операциям, обеспечиваемым портами. Другим примером может быть компонент, который адаптирует порт к вызовам базы данных через SQL.
  4. Приложение: этот компонент объединяет все остальные компоненты. Одно из преимуществ шаблона «Порт-и-адаптеры» состоит в том, что у нас может быть много адаптеров для одного и того же порта. Например, вы можете взаимодействовать со своим доменом либо через REST API, либо через командную строку, предоставляя разные адаптеры для каждого из них. Когда компонент приложения собирает все компоненты вместе, он решает, какой адаптер использовать.

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

Пример в Rust

Теперь, когда у нас есть основная концепция того, чего мы хотим достичь, давайте применим ее в проекте Rust. Лучше всего я учусь, изучая рабочие примеры. Итак, давайте создадим его для нашего случая.

Описание приложения

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

  1. Зарегистрироваться: в приложении можно регистрировать новых пользователей.
  2. Вход: зарегистрированные пользователи могут войти в систему, указав учетные данные, а взамен получат токен, который можно использовать для аутентификации.
  3. Аутентифицировать: преобразование данного токена в пользователя.

Мы хотим, чтобы приложение было доступно из Интернета. Мы также хотим использовать PostgreSQL для хранения наших пользовательских данных. В дополнение к этому мы хотим сохранить токен в Redis для быстрого извлечения. Звучит очень типично, правда?

Архитектура

В соответствии с шаблоном «Порты и адаптеры» компоненты будут следующими:

Порты:

  1. AuthService: порт, через который мы предоставляем функции домена.
  2. TokenRepo: порт, через который наш домен взаимодействует с хранилищем токенов.
  3. CredentialRepo: порт, через который наш домен взаимодействует с хранилищем учетных данных.

Домен:

  1. AuthServiceImpl: компонент, в котором реализована логика аутентификации при реализации порта AuthService.

Адаптеры:

  1. RedisTokenRepo: компонент, который взаимодействует с Redis, переводя порт TokenRepo в операции Redis.
  2. PostgresCredentialRepo: компонент, который взаимодействует с PostgreSQL, переводя порт CredentialRepo в операции PostgreSQL.
  3. RestAuthController: компонент, который взаимодействует с портом AuthService, преобразуя HTTP-запросы в вызовы порта AuthService.

Приложение

  1. Основной: собирает компоненты вместе, чтобы они правильно работали как приложение.

Реализация Rust

Теперь, как архитектура из предыдущего раздела транслируется на Rust? Общее практическое правило состоит в том, что порты должны быть реализованы как traits, в то время как другие реализованы как structs, impls или modules. Давайте проследим за реализацией, чтобы у вас была более четкая картина.

Во-первых, давайте посмотрим, как структурированы исходные файлы. Список ниже показывает вам именно это.

.
├── Cargo.toml
├── migrations
│ └── 000000_init.sql
├── src
│ ├── auth
│ │ ├── auth_service_impl.rs
│ │ ├── mod.rs
│ │ ├── ports.rs
│ │ ├── postgres_credential_repo.rs
│ │ ├── redis_token_repo.rs
│ │ └── rest_auth_controller.rs
│ ├── infrastructure
│ │ ├── mod.rs
│ │ ├── postgresql.rs
│ │ └── redis.rs
│ └── main.rs
└── test-stack.yml

Реализация портов в Rust

Порты определены в auth/ports.rs. Вы видите, что у нас есть 9_ для каждого порта и struct для каждой специальной структуры данных, с которой порт взаимодействует.

Реализация домена в Rust

Компоненты домена, которых оказалось всего AuthServiceImpl, реализованы в отдельном файле с именем auth/auth_service_impl.rs. Как видите, у нас есть struct, который содержит ссылки на TokenRepo порт и CredentialRepo порт. Нам нужна эта ссылка, поскольку реализация AuthServiceImpl требует взаимодействия с этими портами.

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

Что касается тестовой части, мы тестируем только login функциональность, так как остальные очень тривиальны. Мы используем mocks как тестовый дублер для TokenRepo и CredentialRepo. Эти макеты позволяют нам моделировать различные ответы от этих портов, и мы можем использовать это для проверки функциональности входа в систему. Моки генерируются с использованием библиотеки themockall.

Реализация адаптеров в Rust

Адаптеры представлены в трех отдельных файлах: auth/redis_token_repo.rs, auth/postgres_credential_repo.rs и auth/rest_auth_controller. Давайте рассмотрим их по порядку.

RedisTokenRepo реализует TokenRepo trait. Он содержит ссылку на клиентскую библиотеку Redis для взаимодействия с Redis. Для тестирования мы тестируем реальную вещь: экземпляр Redis, работающий на localhost. Мы предоставляем экземпляр Redis через докер, чтобы его было легко настроить.

Точно так же PostgresCredentialRepo реализует CredentialRepo trait и содержит ссылку на пул соединений PostgreSQL, предоставленный sqlx. Настоящая база данных PostgreSQL также предоставляется для тестирования через Docker. При каждом запуске теста мы воссоздаем базу данных, специфичную для этого теста, чтобы гарантировать изоляцию теста.

Адаптер RestAuthController немного отличается от других адаптеров. Этот адаптер управляет взаимодействием с доменом, в то время как остальные управляются доменом. Этот адаптер соединяет actix-web с нашим доменом. Реализацию можно найти в auth/rest_auth_controller.rs. Для тестирования мы используем функции тестирования, предоставляемые actix-web. Главное, что мы хотим проверить, - правильно ли данный HTTP-запрос запускает порт.

заявка

Наконец, компоненты, которые мы определили выше, собраны в main.rs. Как видите, он устанавливает соединение с PostgreSQL и Redis. Затем он создает фактические компоненты, которые используются в приложении, и собирает их вместе.

Мы отделяем PostgreSQL и Redis в отдельный файл, поскольку он имеет собственную логику инициализации. Это просто для лучшей читаемости. Эти два компонента упоминаются в main.rs, как мы видели выше.

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

Заключение

В этой статье мы увидели структуру проекта Rust, которая помогает с тестируемостью. Общая идея состоит в том, чтобы структурировать проект с помощью шаблона «Порты и адаптеры», использовать trait для портов и использовать двойной тест для зависимостей компонентов, чтобы каждый компонент можно было тестировать независимо.

Полный исходный код проекта в этой статье размещен на GitHub.