Пишем интеграционные тесты для Actix Web
Модульные и Интеграционные тесты являются неотъемлемой частью жизни современного разработчика. И если с написанием простейших тестов описанных в различных обучающих статьях проблем обычно не возникает, то ситуация коренным образом меняется, когда нам необходимо написать интеграционные тесты для чего-то более сложного, чем 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 {
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.