Перейти к содержанию

End-to-End тестирование вашей rust сервиса

Posted on:19 июня 2023 г. at 08:16

Example Dynamic OG Image link Если вы создаете веб-API в Rust, вам нужен способ тестирования конечных точек до конца. Модульные тесты гарантируют правильность логики, но правильный сквозной тест позволяет проверить правильность инфраструктуры, маршрутизации, миграции базы данных и параметров безопасности. Поскольку большинство современных служб управляют этими частями с помощью кода, тестирование их так же, как и код приложения, является хорошей идеей. Одним из лучших способов является сквозное тестирование в процессе CI/CD. Для сервисов, rust cargo делает это безболезненным.

Точка End-to-End теста

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

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

Идиоматическое Rust тестирование

Отчасти Rust - отличный язык, потому что он поставляется с инструментами. Под набором инструментов я имею в виду, что это гораздо больше, чем просто язык. При установке Rust с помощью rustup (канонического, предпочтительного метода) также получает cargo. Cargo - это менеджер пакетов, который упаковывает несколько других стандартных инструментов, включая форматтер, линтер и, что наиболее важно для нас: тестовые инструменты. С помощью cargo test мы можем обрабатывать все тесты, которые нам могут потребоваться, используя встроенные аннотации и поддержку, предоставляемую Rust, включая модульные тесты в наших исходных файлах и интеграционные тесты в каталоге тестов.

Запись модульного теста проста: добавьте тестовый модуль в файл, в который вы написали код приложения, и добавьте некоторые аннотации #[test]. Вот пример:

#[cfg(test)]
mod tests {
  #[test]
  fn run_test() {
    // foo
  }
}

Из-за аннотации #[cfg(test)] компилятор знает, что не должен включать этот код в фактические сборки. Он компилируется и выполняется только при выполнении cargo test.

Для интеграционных тестов мы хотим протестировать только открытые части нашего API, поэтому идиоматические тесты идут в отдельном каталоге тестов, где они не могут получить доступ к какому-либо частному коду. Аналогично модульным испытаниям, они должны быть аннотированы атрибутом #[test] и запускаться только при cargo test. Дополнительную информацию об установке и интеграционном тестировании можно найти в книге «Rust book».

Несмотря на то, что наш код всегда должен иметь единичные тесты, основное внимание в этой статье уделяется этим интеграционным тестам. Если вы пишете библиотеку Rust, которую вы собираетесь использовать для другого кода Rust, у вас может быть открытый API Rust, который необходимо протестировать. Но в нашем случае речь идет о сервисе, предоставляющем веб-API. У нас нет API-интерфейса Rust. Вместо этого мы будем делать некоторые HTTP-запросы по сети, и они, вероятно, будут асинхронными. Это меняет наш подход к написанию этих тестов.

Выполнение Unit и End-to-End тестов по отдельности

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

Поскольку развертывание может быть длительным (по крайней мере, несколько минут), перед развертыванием, вероятно, необходимо выполнить модульные тесты. Размещение кода там не имеет смысла, если логика не верна! Перед выполнением end-to-end тестов необходимо выполнить модульные(unit) тесты. После этого вы сможете развернуть приложение на конечном хосте. Только после развертывания необходимо запустить end-to-end тесты.

Существует два способа достижения такого разделения:

  1. Использовать встроенный атрибут #[ignore].
  2. Настройка интеграционных тестов для пропуска с использованием переменных среды.

Использование атрибута #[ignore] является более простым маршрутом. Когда вы пишете end-to-end тесты в каталоге тестов и аннотируете их #[test], то добавьте #[ignore] в следующую строку, например:

#[test]
#[ignore]
fn run_test() {
  // foo
}

Теперь, когда вы запускаете cargo test, любые тесты с #[ignore] будут пропущены! Если мы правильно пометили наши тесты, это означает, что cargo test будет выполнять только ваши юнит тесты. Затем, когда вы хотите запустить end-to-end тесты, вы можете сделать cargo test -- ignored , и он будет делать только игнорированные тесты!

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

Настройка тестового запуска с использованием переменных Env

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

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

Затем необходимо сослаться на этот модуль в тестовых файлах интеграции верхнего уровня, например, объявить модули в файле main.rs или lib.rs. Дополнительные сведения о структуре файлов см. в книге «Rust book».

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

pub struct TestConfig {
    pub is_enabled: bool,
    pub delay_start_min: u64,
    pub env_name: String,
}

pub fn test_config() -> TestConfig {
    let is_enabled = env::var("E2E_ENABLE")
        .map(|s| &s.to_lowercase() == "true" || &s == "1")
        .unwrap_or(false);
    let delay_start_min = env::var("E2E_DELAY_START_MIN")
        .unwrap_or(String::from("0"))
        .parse::<u64>()
        .unwrap_or(0);
    let env_name = env::var("E2E_ENV_NAME").unwrap_or_else(|_| String::from("local"));

    TestConfig {
        is_enabled,
        delay_start_min,
        env_name,
    }
}

В моих тестовых функциях я затем могу вызвать функцию test_config(), чтобы получить структуру StartConfig с моей конфигурацией в ней. Если переменные среды не заданы, в конфигурационном элементе используются некоторые разумные значения по умолчанию. Если явно не установить значение E2E_ENABLE в true или 1, то тесты будут отключены. Если E2E_DELAY_START_MIN не задано (или установлено нечисловое значение), то по умолчанию исполнение отсутствует.

Наконец, если E2E_ENV_NAME не настроен на что-либо (например, dev или staging), то по умолчанию он будет локальным на случай, если я захочу запустить свои тесты на localhost или чём-то подобном. После этого в тестовых функциях можно использовать ссылки на этот TestConfig для пропуска, задержки или генерации макетных данных для конкретной среды (например, для получения идентификаторов пользователей, существующих в промежуточной базе данных).

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

По умолчанию с настройкой, которую я показал, это то, что когда вы выполняете cargo test без установки E2E_ENABLE, вы будете выполнять только единичные тесты. Затем, когда вы готовы выполнить только интеграционные тесты, вы можете установить для E2E_ENABLE значение true или 1, а затем запустить cargo test. Если требуется выполнить только интеграционные тесты, можно также передать имена модулей для фильтрации только по нужным тестам. Это довольно просто, если сгруппировать тесты в связанные юниты, а затем поместить эти юниты в свои собственные файлы.

Группировка тестов

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

Допустим, у вас есть API REST с двумя различными ресурсами - яблочным и оранжевым. Тесты можно объединить в два файла: apple_tests.rs и orange_tests.rs. Каждый тестовый файл может полагаться на модуль test_config и извлекать конфигурацию теста с помощью этого публичного метода test_config(). Кроме того, эти имена файлов можно использовать при выполнении cargo test для фильтрации только к этим тестам. Вы бы сделали это, выполнив:

cargo test apple_tests orange_tests

Асинхронный

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

Вероятно, наиболее известным вариантом будет использование тестового макроса, предоставленного библиотекой tokio. Этот макрос настраивает среду выполнения Tokio для обёртывания теста, позволяя сделать тестовую функцию асинхронной. Это очень легко использовать: просто поменяйтесь местами #[tokio:: test] атрибуты #[test], которые вы использовали, и будет всё настроено!

Подобная альтернатива - которая может быть проще, если вы уже используете actix-web для обслуживания вашего веб API - это использовать модуль actix_rt. Этот модуль рекомендуется для выполнения асинхронных модульных тестов для вашего кода actix-web, это означает, что вы, вероятно, уже имеете его в проекте, если вы используете actix-web. В этом случае используйте атрибут #[actix_rt:: test] для тестов!

В заключение

Всё должно быть настроено на запуск end-to-end тестов, используя только cargo test! Я обнаружил, что это очень легкий и простой подход. Другим разработчикам, работающим над вашей кодовой базой, также легко написать тесты, так как все они пишутся на одном языке.

Если вы обнаружите, что это не соответствует вашим потребностям и вам нужно что-то немного более функциональное, я также имел большой успех написания тестов API с Playwright. У меня было несколько коллег, которые использовали его для тестов пользовательского интерфейса, и я обнаружил, что было довольно просто адаптировать его только для выполнения HTTP-запросов. Но это тема для другой статьи!