Testing a Pub/Sub service
A test runs the service's own app with TestApp, against an in-process Pub/Sub or against the emulator.
The service under test sends refunds.
A refund arrives on refunds, and the handler answers it with a Refunded message on refunds-done.
A refund without an amount is dropped.
A refund above the approval limit waits a minute for approval with retry_after.
After five deliveries a refund goes to refunds-review, the dead-letter topic.
The app is built by service(broker), so a test chooses the broker and main uses the one for the emulator.
The tests need the testing feature of ruststream-gcp-pubsub in [dev-dependencies].
A refund answered
- Macros
- Manual
/// 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 connects PubSubBroker to an in-process Pub/Sub.
Nothing is dialled and no credentials are read.
The test addresses the broker by its production type, tb.broker::<PubSubBroker>().
Refund names its topic, so message(..).publish() needs no destination.
publish returns once the handler and its reply have settled.
So the next lines assert on finished state: how the subscriber settled and what reached refunds-done.
The body takes the started app as an argument, so the live test below runs it too.
A refund dropped
- Macros
- Manual
#[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 checks the outcome the handler returned.
assert_not_called checks that no answer was published.
A delay, without waiting for it
- Macros
- Manual
#[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(())
}
The test starts on a paused clock, and advance moves it forward.
A minute of APPROVAL_WAIT costs nothing.
Each wait ends in a redelivery of the same message, and assert_called counts them.
The fifth delivery waits too, and when its wait ends Pub/Sub moves the refund to refunds-review as it arrived.
published reads what reached a topic, whether the service or Pub/Sub put it there.
What the in-process Pub/Sub models
The in-process Pub/Sub reads the production broker's project and every descriptor the service mounts. A publish, a subscription or a declaration Pub/Sub refuses fails the test the same way. It models Pub/Sub rather than a queue:
- Topics and subscriptions are separate, and each subscription of a topic receives every message published after it was created.
- The consumers of one subscription share its messages.
retrydelivers a message again,retry_afterwaits on the test's clock first, anddropacknowledges.- A dead-letter policy counts the deliveries and moves a message that ran out of them.
The topology the service expects to find is taken to exist. A subscription the service only names reads the topic of its own name, and a topic the service publishes to accepts the message. Leases, flow control, ordered delivery by key and exactly-once delivery need the emulator or Pub/Sub itself.
The same test against the emulator
- Macros
- Manual
#[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 connects the same app to the emulator.
A publish then waits up to ten seconds for every subscription it reaches to handle it; start_live_within sets another deadline.
The test reads the emulator's address from PUBSUB_TEST_HOST with live::host, a helper of the example's own, and skips when it is unset.
The emulator keeps its subscriptions between runs, so live::fresh deletes refunds-workers first, and the run reads only what it publishes.
create_topic creates refunds-done the way operations would, because a reply to a topic that is not there fails.
The assertions are the in-process ones, unchanged.
cargo test
PUBSUB_TEST_HOST=localhost:8085 cargo test -- --test-threads=1