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

4d1ff978c1d3149e8a2716bdb30282fe

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

© Habrahabr.ru