Пишем интеграционные тесты для Actix Web

от автора

Модульные и Интеграционные тесты являются неотъемлемой частью жизни современного разработчика. И если с написанием простейших тестов описанных в различных обучающих статьях проблем обычно не возникает, то ситуация коренным образом меняется, когда нам необходимо написать интеграционные тесты для чего-то более сложного, чем 3 + 2 = 5.

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

Писать что-то абстрактное скучно, поэтому давайте напишем интеграционный тест для своего маленького AWS STS.

Note: Статья будет интересна тем, кому по какой-либо причине не подходят стандартные средства Actix Web для написания интеграционных тестов.


Немного об интеграционных тестах

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

  1. Подготовка конфигурации для запуска приложения с тестовыми параметрами.

  2. Запуск web-приложения (часть сервисов может быть выключена или заменена на mock’и).

  3. Проверка работы одного или нескольких сервисных методов (это наш тест).

  4. Остановка приложения.

К сожалению, мне не удалось найти альтернативы JUnit с его @BeforeEach/@AfterEach для Rust, поэтому мы будем вынуждены воспользоваться стандартными средствами для тестирования.

Что-то общее для всех тестов

А начнём мы с базовой структуры, которая будет ответственна за хранение информации об

  • port: u16 — порт, на котором запущено наше приложение.

  • server_handle: ServerHandle — специальная структура, которая позволяет нам управлять состоянием нашего приложения. (Подробнее про ServerHandle можно почитать здесь.)

use actix_web::dev::ServerHandle;  #[derive(Debug)] pub struct TestContext {     pub port: u16,     pub server_handle: ServerHandle, }

Также, нам понадобятся две следующие функции:

  1. Для создания тестового контекста и запуска нашего приложения:

impl TestContext {     /// Create TestContext and start web-application with random available port.     pub async fn new() -> TestContext {         let port = get_available_port().expect("Failed to bind available port for Test Server");          let server = crate::create_http_server(|| crate::config::AppConfig::with_params("file::memory:?cache=shared", port.clone()))             .await             .expect("Failed to start Test Server");          let server_handle = server.handle();         actix_rt::spawn(server);         TestContext { port, server_handle }     }     ... }
  1. Для остановки приложения:

impl TestContext {     ...      /// Stop web-application     pub async fn stop_server(&mut self) {         self.server_handle.stop(false).await;     } }

Функция get_available_port() позволяет получить свободный port для запуска приложения. Таким образом мы можем запустить несколько изолированных версий приложения для каждого теста (если это необходимо).

Время теста

Пришло время познакомиться с самим интеграционным тестом:

use aws_credential_types::provider::SharedCredentialsProvider; use aws_sdk_sts::config::Region;  use super::*;  #[actix_rt::test] async fn assume_role() {     // Creating test context and starting web-application on random available port     let mut ctx = TestContext::new().await;     let port = ctx.port;         // Given     let config = aws_config::SdkConfig::builder()         .region(Some(Region::new("eu-local-1")))         // since our application is executed locally,          //        we can use 'localhost' to access it         .endpoint_url(format!("http://localhost:{}/", port))         .credentials_provider(SharedCredentialsProvider::new(credentials_provider()))         .build();     let client = aws_sdk_sts::Client::new(&config);      let test_role_session_name = "s3_access_example";      // When     let response = client         .assume_role()         .role_arn("arn:aws:sts::000000000000:role/rd_role")         .role_session_name(test_role_session_name)         .send()         .await         .expect("Failed to assume role");      // Then     let assumed_role_id = response         .assumed_role_user()         .expect("AssumedRoleUser property should be available in the response")         .assumed_role_id()         .expect("AssumedRoleId property should be available in the response");      let parts = assumed_role_id.split(":");      assert_eq!(test_role_session_name, parts.enumerate().last().unwrap().1);      // Stopping web-application     ctx.stop_server().await; }

Обратите внимание, что код выше использует стандартный клиент для доступа к функциям AWS STS: aws_sdk_sts.

Давайте пройдёмся по ключевым местам теста:

  1. Макрос#[actix_rt::test] используется для того, чтобы указать, что данная асинхронная функция будет выполнена в рамках Actix system.

#[actix_rt::test] async fn assume_role() {

Важно: данный макрос используется вместо #[actix_web::test].

  1. Инициализируем наш тестовый контекст и одновременно запускаем Actix Web приложение. Логика по запуску приложения была помещена в конструктор TestContext для экономии места. Также это позволяет гарантировать, что port, указанный в TestContext, будет «захвачен» соответствующим объектом приложения.

let mut ctx = TestContext::new().await; let port = ctx.port;
  1. Непосредственно наш тест описанный с помощью Given/When/Then.

let mut ctx = TestContext::new().await; let port = ctx.port;
  1. Последний шаг — завершаем приложение после выполнения теста.

ctx.stop_server().await;

Не всё так просто

Как вы могли видеть, тест получился достаточно простым, но в тоже время есть ещё одно изменение, которое необходимо сделать. И касается оно точки входа в нашем приложении:

#[tokio::main] async fn main() -> std::io::Result<()> {     dotenv().ok();      logger::init_with_level(LevelFilter::Debug);     create_http_server(|| AppConfig::init())         .await         .expect("Failed to Run HTTP server...")         .await }

Те, кто знаком с Actix Web, сразу обратят внимание, что используется Tokio runtime (макрос #[tokio::main]) вместо #[actix_web::main]. Это связано с тем, что мы хотим самостоятельно управлять состоянием Actix Web HTTP сервера.

Ниже вы можете увидеть укороченную версию функции, отвечающей за создание HTTP-сервера:

async fn create_http_server(app_config_factory: impl Fn() -> AppConfig) -> std::io::Result<Server> {     let app_config = app_config_factory();     // connect to db     ...      let app_data = web::Data::new(sts_db);      // start HTTP server     log::info!("Starting Local Rust Cloud STS on port {}", app_config.service_port);     let server = HttpServer::new(move || {         App::new()             .app_data(app_data.clone())             .service(handle_service_request)             .wrap(actix_web::middleware::Logger::default())     })     .bind(("0.0.0.0", app_config.service_port))?     .run();     return Result::Ok(server); }

Эта же функция используется в нашем TestContext для запуска HTTP-сервера:

let server = crate::create_http_server(|| crate::config::AppConfig::with_params("file::memory:?cache=shared", port.clone()))     .await     .expect("Failed to start Test Server");  let server_handle = server.handle(); actix_rt::spawn(server);

Где actix_rt — это основанный на библиотеке tokio однопоточный асинхронный runtime для экосистемы Actix.

Плюсы и минусы подхода

Главный и несомненный плюс данного подхода — мы получили возможность запустить приложение целиком (или частично) и протестировать его работоспособность в рамках интеграционных тестов.

К минусам можно отнести усложнение конфигурации по сравнению со стандартной инициализацией Actix Web приложения.

Запускаем тест

Напоследок запустим тест и проверим, что он проходит:

damal@lmde5:~/Documents/projects/local-rust-cloud/local_rust_cloud_sts_rs$ cargo test  Finished test [unoptimized + debuginfo] target(s) in 0.15s   Running unittests src/main.rs (/home/damal/Documents/projects/local-rust-cloud/target/debug/deps/local_rust_cloud_sts_rs-adc0d1a1499b843e)  running 1 test test tests::assume_role::assume_role ... ok  test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s

Спасибо, что дочитали до конца 🙂

Исходный код проекта доступен на GitHub.


ссылка на оригинал статьи https://habr.com/ru/articles/773250/