Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Тестирование сервиса на Pub/Sub
Тест запускает собственное приложение сервиса через TestApp на внутрипроцессном Pub/Sub или на эмуляторе.
Тестируемый сервис проводит возвраты.
Возврат приходит в refunds, и обработчик отвечает на него сообщением Refunded в refunds-done.
Возврат без суммы отбрасывается.
Возврат сверх лимита согласования ждёт согласования минуту с retry_after.
После пяти доставок возврат уходит в refunds-review, тему dead-letter.
Приложение строит service(broker), поэтому тест выбирает брокер сам, а main берёт брокер для эмулятора.
Тестам нужна фича testing крейта ruststream-gcp-pubsub в [dev-dependencies].
Возврат с ответом
- Макросы
- Вручную
/// The body both modes run: a refund is answered on `refunds-done`.
async fn a_refund_is_answered(tb: TestApp<()>) -> Result<(), Box<dyn Error>> {
tb.broker::<PubSubBroker>()
.message(&Refund {
id: 7,
amount_cents: 1250,
})
.publish()
.await?;
tb.broker::<PubSubBroker>()
.subscriber("refunds-workers")
.assert_called_once()
.with(&Refund {
id: 7,
amount_cents: 1250,
})
.settled(HandlerOutcome::ack());
tb.broker::<PubSubBroker>()
.published::<Refunded>("refunds-done")
.assert_called_once()
.with(&Refunded { refund_id: 7 });
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn in_process() -> Result<(), Box<dyn Error>> {
a_refund_is_answered(TestApp::start(app()).await?).await
}
/// The body both modes run: a refund is answered on `refunds-done`.
async fn a_refund_is_answered(tb: TestApp<()>) -> Result<(), Box<dyn Error>> {
tb.broker::<PubSubBroker>()
.message(&Refund {
id: 7,
amount_cents: 1250,
})
.publish()
.await?;
tb.broker::<PubSubBroker>()
.subscriber("refunds-workers")
.assert_called_once()
.with(&Refund {
id: 7,
amount_cents: 1250,
})
.settled(HandlerOutcome::ack());
tb.broker::<PubSubBroker>()
.published::<Refunded>("refunds-done")
.assert_called_once()
.with(&Refunded { refund_id: 7 });
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn in_process() -> Result<(), Box<dyn Error>> {
a_refund_is_answered(TestApp::start(app()).await?).await
}
TestApp::start подключает PubSubBroker к внутрипроцессному Pub/Sub.
Соединения не устанавливаются, и учётные данные не читаются.
Тест обращается к брокеру по его рабочему типу: tb.broker::<PubSubBroker>().
Refund называет свою тему, поэтому message(..).publish() не нужен адрес.
publish возвращает управление, когда обработчик и его ответ завершены.
Поэтому следующие строки проверяют уже готовое состояние: как подписчик завершил доставку и что дошло до refunds-done.
Тело теста принимает запущенное приложение аргументом, поэтому его же выполняет и тест на эмуляторе ниже.
Отброшенный возврат
- Макросы
- Вручную
#[tokio::test]
async fn a_refund_without_an_amount_is_dropped() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(app()).await?;
tb.broker::<PubSubBroker>()
.message(&Refund {
id: 8,
amount_cents: 0,
})
.publish()
.await?;
tb.broker::<PubSubBroker>()
.subscriber("refunds-workers")
.assert_called_once()
.settled(HandlerOutcome::drop());
tb.broker::<PubSubBroker>()
.published::<Refunded>("refunds-done")
.assert_not_called();
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn a_refund_without_an_amount_is_dropped() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(app()).await?;
tb.broker::<PubSubBroker>()
.message(&Refund {
id: 8,
amount_cents: 0,
})
.publish()
.await?;
tb.broker::<PubSubBroker>()
.subscriber("refunds-workers")
.assert_called_once()
.settled(HandlerOutcome::drop());
tb.broker::<PubSubBroker>()
.published::<Refunded>("refunds-done")
.assert_not_called();
tb.shutdown().await?;
Ok(())
}
settled проверяет исход, который вернул обработчик.
assert_not_called проверяет, что ответ не опубликован.
Задержка без ожидания
- Макросы
- Вручную
#[tokio::test(start_paused = true)]
async fn a_refund_never_approved_goes_to_review() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(app()).await?;
let large = Refund {
id: 9,
amount_cents: 250_000,
};
tb.broker::<PubSubBroker>()
.message(&large)
.publish()
.await?;
tb.broker::<PubSubBroker>()
.subscriber("refunds-workers")
.assert_called_once()
.settled(HandlerOutcome::retry_after(APPROVAL_WAIT));
// Each wait ends in a redelivery, and the fifth delivery is the last one allowed.
for deliveries in 2..=5 {
tb.advance(APPROVAL_WAIT).await?;
tb.broker::<PubSubBroker>()
.subscriber("refunds-workers")
.assert_called(deliveries);
}
// The fifth delivery waits too; when its wait ends, Pub/Sub moves the refund to review.
tb.advance(APPROVAL_WAIT).await?;
tb.broker::<PubSubBroker>()
.published::<Refund>("refunds-review")
.assert_called_once()
.with(&large);
tb.shutdown().await?;
Ok(())
}
#[tokio::test(start_paused = true)]
async fn a_refund_never_approved_goes_to_review() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(app()).await?;
let large = Refund {
id: 9,
amount_cents: 250_000,
};
tb.broker::<PubSubBroker>()
.message(&large)
.publish()
.await?;
tb.broker::<PubSubBroker>()
.subscriber("refunds-workers")
.assert_called_once()
.settled(HandlerOutcome::retry_after(APPROVAL_WAIT));
// Each wait ends in a redelivery, and the fifth delivery is the last one allowed.
for deliveries in 2..=5 {
tb.advance(APPROVAL_WAIT).await?;
tb.broker::<PubSubBroker>()
.subscriber("refunds-workers")
.assert_called(deliveries);
}
// The fifth delivery waits too; when its wait ends, Pub/Sub moves the refund to review.
tb.advance(APPROVAL_WAIT).await?;
tb.broker::<PubSubBroker>()
.published::<Refund>("refunds-review")
.assert_called_once()
.with(&large);
tb.shutdown().await?;
Ok(())
}
Тест начинается с остановленными часами, а advance сдвигает их вперёд.
Минута APPROVAL_WAIT не стоит ничего.
Каждое ожидание заканчивается повторной доставкой того же сообщения, и assert_called их считает.
Пятая доставка тоже ждёт, а когда её ожидание заканчивается, Pub/Sub переносит возврат в refunds-review в том виде, в каком он пришёл.
published читает то, что дошло до темы, кто бы его туда ни поместил: сервис или Pub/Sub.
Что моделирует внутрипроцессный Pub/Sub
Внутрипроцессный Pub/Sub читает проект рабочего брокера и каждый дескриптор, который монтирует сервис. Публикация, подписка или объявление, которые отклонил бы Pub/Sub, так же прерывают тест. Он моделирует Pub/Sub, а не очередь:
- Темы и подписки разделены, и каждая подписка на тему получает каждое сообщение, опубликованное после её создания.
- Потребители одной подписки делят её сообщения между собой.
retryдоставляет сообщение снова,retry_afterсначала ждёт на часах теста, аdropподтверждает.- Политика dead-letter считает доставки и переносит сообщение, у которого они кончились.
Топология, которую сервис ожидает найти, считается существующей. Подписка, которую сервис только называет, читает одноимённую тему, а тема, в которую публикует сервис, принимает сообщение. Для аренды, управления потоком, упорядоченной доставки по ключу и доставки exactly-once нужен эмулятор или сам Pub/Sub.
Тот же тест на эмуляторе
- Макросы
- Вручную
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn against_the_emulator() -> Result<(), Box<dyn Error>> {
let Some(host) = live::host() else {
return Ok(());
};
live::fresh(&host, &["refunds-workers"]).await?;
create_topic(&host, "refunds-done").await?;
let broker = PubSubBroker::new("my-project").emulator(host);
a_refund_is_answered(TestApp::start_live(service(broker)).await?).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn against_the_emulator() -> Result<(), Box<dyn Error>> {
let Some(host) = live::host() else {
return Ok(());
};
live::fresh(&host, &["refunds-workers"]).await?;
create_topic(&host, "refunds-done").await?;
let broker = PubSubBroker::new("my-project").emulator(host);
a_refund_is_answered(TestApp::start_live(service(broker)).await?).await
}
TestApp::start_live подключает то же приложение к эмулятору.
Тогда публикация ждёт до десяти секунд, пока её обработает каждая подписка, до которой она дошла. Другой срок задаёт start_live_within.
Тест читает адрес эмулятора из PUBSUB_TEST_HOST через live::host, собственную вспомогательную функцию примера, и пропускается, если переменная не задана.
Эмулятор хранит подписки между запусками, поэтому live::fresh сначала удаляет refunds-workers, и запуск читает только то, что публикует сам.
create_topic создаёт refunds-done так, как это сделала бы эксплуатация, потому что ответ в несуществующую тему возвращает ошибку.
Проверки те же, что внутри процесса, без изменений.
cargo test
PUBSUB_TEST_HOST=localhost:8085 cargo test -- --test-threads=1