Как правильно тестировать асинхронные функции с Tokio в Rust?
Основное решение: макрос 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] как основной метод, а ручной подход — только для специфических случаев.