Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Тестирование сервиса на SQL
Тест запускает собственное приложение сервиса через TestApp на настоящей базе данных: на SQLite в памяти или на собственной базе данных теста на сервере.
Поведение очереди задаёт её SQL: захват, завершение, собственный Publish сервиса.
Поэтому у обвязки нет модели базы данных.
Её режим в процессе выполняет операторы сервиса на той базе данных, которую тест передаёт сервису, и ведёт учёт, по которому ждёт обвязка.
Тестируемый сервис делает миниатюры.
Миниатюра маленького изображения делается сразу.
Большое изображение программа масштабирования принимает только со второй попытки, поэтому первая доставка большого изображения откладывается на минуту через retry_after.
Приложение строит service(url), поэтому тест выбирает базу данных, а main берёт рабочую.
Тестам нужна фича testing крейта ruststream-sqlx в [dev-dependencies].
Маленькое изображение
- Макросы
- Вручную
/// A database in memory that every connection naming it shares, alive while one of them is
/// open: the test holds that one beside the pool it reads the table through. The service
/// creates the table when it starts.
async fn database(
name: &str,
) -> Result<(String, SqlitePool, SqliteConnection), Box<dyn Error>> {
let url = format!("sqlite:file:{name}?mode=memory&cache=shared");
let options: SqliteConnectOptions = url.parse()?;
let keeper = SqliteConnection::connect_with(&options).await?;
let pool = SqlitePoolOptions::new()
.min_connections(1)
.idle_timeout(None)
.connect_with(options)
.await?;
Ok((url, pool, keeper))
}
/// The scenario, on whichever database the app runs.
async fn a_small_image_is_made_at_once(broker: BrokerHandle<'_>) -> Result<(), Box<dyn Error>> {
let image = Image {
path: "cat.png".to_owned(),
large: false,
};
broker.message(&image).publish().await?;
broker
.subscriber("thumbnails")
.assert_called_once()
.with(&image)
.settled(HandlerOutcome::ack());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn in_process() -> Result<(), Box<dyn Error>> {
let (url, pool, _keeper) = database("guide_testing").await?;
let tb = TestApp::start(service(&url)).await?;
a_small_image_is_made_at_once(tb.broker::<SqlxBroker<Sqlite>>()).await?;
let left: i64 = sqlx::query_scalar("SELECT count(*) FROM thumbnail_jobs")
.fetch_one(&pool)
.await?;
assert_eq!(left, 0, "the acknowledgement deleted the row");
Ok(())
}
/// A database in memory that every connection naming it shares, alive while one of them is
/// open: the test holds that one beside the pool it reads the table through. The service
/// creates the table when it starts.
async fn database(
name: &str,
) -> Result<(String, SqlitePool, SqliteConnection), Box<dyn Error>> {
let url = format!("sqlite:file:{name}?mode=memory&cache=shared");
let options: SqliteConnectOptions = url.parse()?;
let keeper = SqliteConnection::connect_with(&options).await?;
let pool = SqlitePoolOptions::new()
.min_connections(1)
.idle_timeout(None)
.connect_with(options)
.await?;
Ok((url, pool, keeper))
}
/// The scenario, on whichever database the app runs.
async fn a_small_image_is_made_at_once(broker: BrokerHandle<'_>) -> Result<(), Box<dyn Error>> {
let image = Image {
path: "cat.png".to_owned(),
large: false,
};
broker.message(&image).publish().await?;
broker
.subscriber("thumbnails")
.assert_called_once()
.with(&image)
.settled(HandlerOutcome::ack());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn in_process() -> Result<(), Box<dyn Error>> {
let (url, pool, _keeper) = database("manual_guide_testing").await?;
let tb = TestApp::start(service(&url)).await?;
a_small_image_is_made_at_once(tb.broker::<SqlxBroker<Sqlite>>()).await?;
let left: i64 = sqlx::query_scalar("SELECT count(*) FROM thumbnail_jobs")
.fetch_one(&pool)
.await?;
assert_eq!(left, 0, "the acknowledgement deleted the row");
Ok(())
}
database создаёт базу данных SQLite в памяти, общую для всех соединений, которые её называют.
Она существует, пока открыто хотя бы одно из этих соединений, поэтому тест держит одно рядом с пулом, через который читает таблицу.
Таблицу создаёт сам сервис при старте, как и в промышленной среде.
TestApp::start подключает к ней рабочее приложение в процессе, а тест обращается к брокеру по его рабочему типу, tb.broker::<SqlxBroker<Sqlite>>().
Изображение попадает в очередь через маршрут и собственный Publish сервиса, как в промышленной среде.
publish возвращает управление, когда разбуженный им обработчик завершил строку, поэтому код теста после него читает итоговое состояние: как thumbnail завершил строку и что находит в таблице собственный запрос теста.
Строка, которую собственный SQL сервиса записал до запуска приложения (начальные данные или миграция), засчитывается при открытии её подписки, поэтому settle ждёт её так же, как опубликованное сообщение.
Строка, записанная так во время теста, засчитывается, только когда её берёт захват, поэтому settle её не ждёт; такой тест работает на реальных часах и пропускает интервал опроса, как в руководстве Постановка задач в очередь.
Сценарий принимает дескриптор брокера аргументом, поэтому его выполняет и тест на Postgres ниже.
Таблица без столбца полезной нагрузки передаёт обработчику саму строку (Строки).
Тест такого обработчика проверяет строку через with_value и received_values вместо with и received.
Задержка без ожидания
- Макросы
- Вручную
#[tokio::test]
async fn a_large_image_is_made_a_minute_later() -> Result<(), Box<dyn Error>> {
let (url, _pool, _keeper) = database("guide_testing_clock").await?;
// The queue reads "now" from this clock, and the database calls run off it. A paused
// clock takes the single-threaded runtime `#[tokio::test]` starts.
tokio::time::pause();
let tb = TestApp::start(service(&url)).await?;
tb.broker::<SqlxBroker<Sqlite>>()
.message(&Image {
path: "panorama.png".to_owned(),
large: true,
})
.publish()
.await?;
tb.advance(Duration::from_secs(61)).await?;
// Put off for a minute, then made.
let outcomes = tb
.broker::<SqlxBroker<Sqlite>>()
.subscriber("thumbnails")
.assert_called(2)
.outcomes();
assert_eq!(outcomes, [Outcome::Nack, Outcome::Ack]);
Ok(())
}
#[tokio::test]
async fn a_large_image_is_made_a_minute_later() -> Result<(), Box<dyn Error>> {
let (url, _pool, _keeper) = database("manual_guide_testing_clock").await?;
// The queue reads "now" from this clock, and the database calls run off it. A paused
// clock takes the single-threaded runtime `#[tokio::test]` starts.
tokio::time::pause();
let tb = TestApp::start(service(&url)).await?;
tb.broker::<SqlxBroker<Sqlite>>()
.message(&Image {
path: "panorama.png".to_owned(),
large: true,
})
.publish()
.await?;
tb.advance(Duration::from_secs(61)).await?;
// Put off for a minute, then made.
let outcomes = tb
.broker::<SqlxBroker<Sqlite>>()
.subscriber("thumbnails")
.assert_called(2)
.outcomes();
assert_eq!(outcomes, [Outcome::Nack, Outcome::Ack]);
Ok(())
}
В процессе очередь читает «сейчас» по часам tokio, привязанным к системным часам в момент подключения брокера.
Тест останавливает эти часы, а advance двигает их: строка большого изображения становится готовой к захвату минутой позже, и вторая доставка делает миниатюру.
Эта минута не занимает времени.
Обращения к базе данных выполняются вне остановленных часов, поэтому часы не перескакивают вперёд, пока база данных отвечает.
Аренда истекает так же, когда часы проходят её срок.
Таблица на часах базы данных читает время базы данных, которое часы теста не двигают, а собственные часы сервиса продолжают действовать; тесты таких таблиц работают на реальных часах.
Тот же тест на Postgres
- Макросы
- Вручную
// Skips without `POSTGRES_TEST_URL`; fails the skip under `RUSTSTREAM_REQUIRE_LIVE`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn on_postgres() -> Result<(), Box<dyn Error>> {
// A database of the test's own on the server, dropped at the end.
let Some(db) = live::postgres::database().await else {
return Ok(());
};
let tb = TestApp::start_live(service_on_postgres(&db.url())).await?;
a_small_image_is_made_at_once(tb.broker::<SqlxBroker<Postgres>>()).await?;
tb.shutdown().await?;
db.finish().await;
Ok(())
}
// Skips without `POSTGRES_TEST_URL`; fails the skip under `RUSTSTREAM_REQUIRE_LIVE`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn on_postgres() -> Result<(), Box<dyn Error>> {
// A database of the test's own on the server, dropped at the end.
let Some(db) = live::postgres::database().await else {
return Ok(());
};
let tb = TestApp::start_live(service_on_postgres(&db.url())).await?;
a_small_image_is_made_at_once(tb.broker::<SqlxBroker<Postgres>>()).await?;
tb.shutdown().await?;
db.finish().await;
Ok(())
}
Сервис, который в промышленной среде работает на Postgres, тестируется тоже на Postgres: SQLite выполняет другой SQL, и в нём нет блокировок строк, уведомлений и выбора уровня изоляции.
TestApp::start_live подключает приложение обычным способом и ждёт на реальных часах.
Тест берёт на сервере собственную базу данных, где сервис создаёт свою таблицу, поэтому ничего, что оставил предыдущий запуск, он не читает; live::postgres::database - вспомогательная функция тестов репозитория, а #[sqlx::test] даёт тесту то же самое: базу данных с применёнными миграциями.
Тест читает адрес сервера из POSTGRES_TEST_URL и без него пропускается, поэтому cargo test проходит на машине без сервера; RUSTSTREAM_REQUIRE_LIVE=1 превращает этот пропуск в ошибку там, где сервер обязан быть.
Сценарий тот же, что и в процессе, без изменений.
cargo test
POSTGRES_TEST_URL=postgres://user:password@localhost:5432/postgres cargo test
Outbox в тестах
Тестовая сборка оставляет outbox выключенным, если его не включает RUSTSTREAM_SQLX_OUTBOX=on, поэтому собственные тесты сервиса запускают его обработчики без отслеживания.
Тест самого отслеживания работает с заданным переключателем и без него пропускается (Переключатель).