Avatar ·

Как правильно тестировать асинхронные функции с Tokio в Rust?

Привет! Я начал изучать Rust и Tokio для асинхронного программирования. Написал простой модуль для работы с сетью, но не могу понять, как правильно писать тесты для асинхронных функций. Вот пример моего кода: ```rust use tokio::time::{sleep, Duration}; pub async fn fetch_data(url: &str) -> Result { // Имитация сетевого запроса sleep(Duration::from_millis(100)).await; if url.is_empty() { return Err("URL пустой".to_string()); } Ok(format!("Данные с {}", url)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_fetch_data() { let result = fetch_data("https://example.com"); // Здесь ошибка компиляции } } ``` Когда я пытаюсь запустить тест, компилятор выдаёт ошибку: `the trait bound '(): futures::Future' is not satisfied`. Я понимаю, что асинхронные функции возвращают Future, и их нужно как-то выполнять, но не знаю как. Пробовал добавить `await` в тесте, но тогда нужно сделать сам тест асинхронным. Rust не позволяет использовать `async fn` в тестах по умолчанию. Видел в документации какие-то атрибуты, но не разобрался. Подскажите, как правильно организовать тестирование асинхронного кода на Tokio? Может быть, есть специальные макросы или подходы? И нужно ли для тестов создавать отдельный Tokio runtime или можно использовать глобальный? <<>>

Основное решение: макрос tokio::test

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

use tokio::time::{sleep, Duration};

pub async fn fetch_data(url: &str) -> Result {
    sleep(Duration::from_millis(100)).await;
    if url.is_empty() {
        return Err("URL пустой".to_string());
    }
    Ok(format!("Данные с {}", url))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_fetch_data_success() {
        let result = fetch_data("https://example.com").await;
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), "Данные с https://example.com");
    }

    #[tokio::test]
    async fn test_fetch_data_empty_url() {
        let result = fetch_data("").await;
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "URL пустой");
    }
}

Важные детали и настройки

Макрос #[tokio::test] имеет несколько параметров для управления runtime:

  • flavor — выбор типа runtime: multi_thread (многопоточный, по умолчанию) или current_thread (однопоточный для лёгких тестов).
  • worker_threads — количество потоков для многопоточного runtime.
  • start_paused — запуск с "замороженным" временем для тестирования таймаутов.

Пример с однопоточным runtime:

#[tokio::test(flavor = "current_thread")]
async fn test_lightweight() {
    // Тест без многопоточности
}

Работа с временем в тестах

Для тестов, где важна скорость (например, проверка таймаутов), используйте start_paused:

#[tokio::test(start_paused = true)]
async fn test_timeout() {
    let start = tokio::time::Instant::now();
    sleep(Duration::from_secs(5)).await;
    let elapsed = start.elapsed();
    // elapsed будет близко к 5 секундам, но тест выполнится мгновенно
    assert!(elapsed >= Duration::from_secs(5));
}

Тестирование с моками и зависимостями

Если функция использует внешние сервисы, создайте трейт для абстракции и моки:

trait HttpClient {
    async fn get(&self, url: &str) -> Result;
}

struct MockHttpClient;
impl HttpClient for MockHttpClient {
    async fn get(&self, _url: &str) -> Result {
        Ok("мок-данные".to_string())
    }
}

async fn process_data(client: &impl HttpClient, url: &str) -> String {
    client.get(url).await.unwrap_or_default()
}

#[tokio::test]
async fn test_with_mock() {
    let mock = MockHttpClient;
    let result = process_data(&mock, "http://test").await;
    assert_eq!(result, "мок-данные");
}

Почему это работает?

Макрос #[tokio::test] преобразует асинхронную функцию в синхронный тест, внутри которого запускается Tokio runtime. Это гарантирует, что каждый тест имеет изолированное окружение, и все асинхронные операции (Future, sleep, I/O) корректно выполняются.

<<>>

Альтернативный подход: ручное создание runtime

Если по каким-то причинам вы не можете или не хотите использовать макрос #[tokio::test], можно создать Tokio runtime вручную внутри обычного теста:

#[cfg(test)]
mod tests {
    use super::*;
    use tokio::runtime::Runtime;

    #[test]
    fn test_fetch_data_manual() {
        let rt = Runtime::new().unwrap();
        let result = rt.block_on(async {
            fetch_data("https://example.com").await
        });
        assert!(result.is_ok());
    }
}

Когда это может быть полезно?

  • Тестирование с особыми настройками runtime — например, вам нужно создать runtime с кастомным количеством потоков или с особым обработчиком паники.
  • Интеграционные тесты, где требуется один runtime для нескольких тестов (но будьте осторожны с состоянием).
  • Совместимость с другими библиотеками — если вы используете не Tokio, а другую асинхронную среду (например, async-std), этот подход можно адаптировать.

Недостатки ручного подхода

Ручное создание runtime делает тесты более громоздкими и менее читаемыми. Кроме того, вы должны явно обрабатывать ошибки создания runtime (например, если система не поддерживает многопоточность). Рекомендую использовать #[tokio::test] как основной метод, а ручной подход — только для специфических случаев.