Модульные и Интеграционные тесты являются неотъемлемой частью жизни современного разработчика. И если с написанием простейших тестов описанных в различных обучающих статьях проблем обычно не возникает, то ситуация коренным образом меняется, когда нам необходимо написать интеграционные тесты для чего-то более сложного, чем 3 + 2 = 5.
В данной статье я хочу поделиться своим подходом к написанию интеграционных тестов для приложения, использующего Actix Web (API-тестирование).
Писать что-то абстрактное скучно, поэтому давайте напишем интеграционный тест для своего маленького AWS STS.
Note: Статья будет интересна тем, кому по какой-либо причине не подходят стандартные средства Actix Web для написания интеграционных тестов.
Немного об интеграционных тестах
Прежде чем перейти к написанию теста, давайте определимся, как он должен выглядеть и что мы от него хотим. В целом, можно выделить следующие этапы работы интеграционного теста:
Подготовка конфигурации для запуска приложения с тестовыми параметрами.
Запуск web-приложения (часть сервисов может быть выключена или заменена на mock'и).
Проверка работы одного или нескольких сервисных методов (это наш тест).
Остановка приложения.
К сожалению, мне не удалось найти альтернативы 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, }
Также, нам понадобятся две следующие функции:
Для создания тестового контекста и запуска нашего приложения:
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 } } ... }
Для остановки приложения:
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.
Давайте пройдёмся по ключевым местам теста:
Макрос
#[actix_rt::test]используется для того, чтобы указать, что данная асинхронная функция будет выполнена в рамках Actix system.
#[actix_rt::test] async fn assume_role() {
Важно: данный макрос используется вместо #[actix_web::test].
Инициализируем наш тестовый контекст и одновременно запускаем Actix Web приложение. Логика по запуску приложения была помещена в конструктор
TestContextдля экономии места. Также это позволяет гарантировать, чтоport, указанный вTestContext, будет "захвачен" соответствующим объектом приложения.
let mut ctx = TestContext::new().await; let port = ctx.port;
Непосредственно наш тест описанный с помощью
Given/When/Then.
let mut ctx = TestContext::new().await; let port = ctx.port;
Последний шаг - завершаем приложение после выполнения теста.
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.
