Testing an SQS service
A test runs the service's own app with TestApp, against an in-process SQS or against a running stack.
The service under test is a checkout.
An order arrives on checkout-orders, and confirm answers it with a confirmation on checkout-confirmations, which notify reads.
An order without an amount is dropped.
A back-ordered order waits an hour with retry_after and comes back to be checked again.
The app is built by service(broker), so a test chooses the broker and main uses the production one.
The tests need the testing feature of ruststream-sqs-sns in [dev-dependencies].
An order and its confirmation
- Macros
- Manual
#[tokio::test]
async fn an_order_is_confirmed() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(service(SqsBroker::new())).await?;
a_confirmation_follows_the_order(tb).await
}
async fn a_confirmation_follows_the_order(tb: TestApp<()>) -> Result<(), Box<dyn Error>> {
tb.broker::<SqsBroker>()
.message(&order(1, 2_500, false))
.publish()
.await?;
tb.broker::<SqsBroker>()
.subscriber("checkout-orders")
.assert_called_once()
.with(&order(1, 2_500, false))
.settled(HandlerOutcome::ack());
tb.broker::<SqsBroker>()
.subscriber("checkout-confirmations")
.assert_called_once()
.with(&Confirmation {
order: 1,
amount_cents: 2_500,
});
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn an_order_is_confirmed() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(service(SqsBroker::new())).await?;
a_confirmation_follows_the_order(tb).await
}
async fn a_confirmation_follows_the_order(tb: TestApp<()>) -> Result<(), Box<dyn Error>> {
tb.broker::<SqsBroker>()
.message(&order(1, 2_500, false))
.publish()
.await?;
tb.broker::<SqsBroker>()
.subscriber("checkout-orders")
.assert_called_once()
.with(&order(1, 2_500, false))
.settled(HandlerOutcome::ack());
tb.broker::<SqsBroker>()
.subscriber("checkout-confirmations")
.assert_called_once()
.with(&Confirmation {
order: 1,
amount_cents: 2_500,
});
tb.shutdown().await?;
Ok(())
}
TestApp::start connects SqsBroker to an in-process SQS.
No endpoint is called and no credentials are needed.
The test addresses the broker by its production type, tb.broker::<SqsBroker>().
publish returns once the handlers it reached have settled, the reply and its reader included.
So the next lines assert on finished state: how confirm settled the order and what notify received.
The body takes the started app as an argument, so the live test below runs it too.
An empty order
- Macros
- Manual
#[tokio::test]
async fn an_empty_order_is_dropped() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(service(SqsBroker::new())).await?;
tb.broker::<SqsBroker>()
.message(&order(2, 0, false))
.publish()
.await?;
tb.broker::<SqsBroker>()
.subscriber("checkout-orders")
.assert_called_once()
.settled(HandlerOutcome::drop());
tb.broker::<SqsBroker>()
.published::<Confirmation>("checkout-confirmations")
.assert_not_called();
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn an_empty_order_is_dropped() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(service(SqsBroker::new())).await?;
tb.broker::<SqsBroker>()
.message(&order(2, 0, false))
.publish()
.await?;
tb.broker::<SqsBroker>()
.subscriber("checkout-orders")
.assert_called_once()
.settled(HandlerOutcome::drop());
tb.broker::<SqsBroker>()
.published::<Confirmation>("checkout-confirmations")
.assert_not_called();
tb.shutdown().await?;
Ok(())
}
settled checks the outcome the handler returned.
assert_not_called checks that no confirmation was published.
A delay, without waiting for it
- Macros
- Manual
#[tokio::test(start_paused = true)]
async fn a_back_ordered_order_comes_back_after_the_restock() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(service(SqsBroker::new())).await?;
let waiting = order(3, 2_500, true);
tb.broker::<SqsBroker>().message(&waiting).publish().await?;
tb.advance(RESTOCK).await?;
tb.broker::<SqsBroker>()
.subscriber("checkout-orders")
.assert_called(2)
.with(&waiting)
.settled(HandlerOutcome::retry_after(RESTOCK));
// The same message came back; nothing was published again to bring it.
tb.broker::<SqsBroker>()
.published::<Order>("checkout-orders")
.assert_called_once();
tb.shutdown().await?;
Ok(())
}
#[tokio::test(start_paused = true)]
async fn a_back_ordered_order_comes_back_after_the_restock() -> Result<(), Box<dyn Error>> {
let tb = TestApp::start(service(SqsBroker::new())).await?;
let waiting = order(3, 2_500, true);
tb.broker::<SqsBroker>().message(&waiting).publish().await?;
tb.advance(RESTOCK).await?;
tb.broker::<SqsBroker>()
.subscriber("checkout-orders")
.assert_called(2)
.with(&waiting)
.settled(HandlerOutcome::retry_after(RESTOCK));
// The same message came back; nothing was published again to bring it.
tb.broker::<SqsBroker>()
.published::<Order>("checkout-orders")
.assert_called_once();
tb.shutdown().await?;
Ok(())
}
The test starts on a paused clock, and advance moves it forward.
An hour of RESTOCK costs nothing.
retry_after makes the message invisible for the delay, as SQS does, and the queue hands it out again when the clock passes it.
published shows one order on checkout-orders: the second delivery is the same message, not a copy.
What the in-process SQS models
The in-process SQS is an account inside the test, and every queue and topic the service names exists in it. It models what a service's logic depends on:
- A queue hands each message to one of its subscriptions, and a received message stays invisible until it is deleted or its visibility timeout ends.
- The receive count grows with every receive, and a redrive policy moves a message whose receives ran out to the dead-letter queue.
- A batch holds at most the ten messages one receive returns.
- A FIFO group waits while one of its messages is in flight, and a deduplication id is remembered for five minutes.
- With
sns, a topic delivers a copy to every queue subscribed withsubscribe_queue_to_topic.
What SQS or SNS refuses is refused there too: more than ten headers, a header name SQS does not take, an empty body or header value, a message over the size limit, a queue name over 80 characters, a dead-letter queue of the other kind, a maxReceiveCount over 1000, a visibility that would end more than twelve hours after the receive, and a FIFO queue on a standard topic.
A standard queue keeps the order of its messages there, which SQS does not promise. A descriptor that names no visibility gets 30 seconds, the default of a new queue. A message sent to a queue no subscription reads is dropped. The visibility an operator set on a queue, the queues and topics the service expects to find, and credentials are exercised against a running stack.
The same test on a running stack
- Macros
- Manual
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_order_is_confirmed_on_a_live_stack() -> Result<(), Box<dyn Error>> {
let Some(endpoint) = live::endpoint("SQS_TEST_ENDPOINT") else {
return Ok(());
};
delete_queues(&endpoint).await?;
let tb = TestApp::start_live(service(live::broker(&endpoint))).await?;
a_confirmation_follows_the_order(tb).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_order_is_confirmed_on_a_live_stack() -> Result<(), Box<dyn Error>> {
let Some(endpoint) = live::endpoint("SQS_TEST_ENDPOINT") else {
return Ok(());
};
delete_queues(&endpoint).await?;
let tb = TestApp::start_live(service(live::broker(&endpoint))).await?;
a_confirmation_follows_the_order(tb).await
}
TestApp::start_live connects the same app to a running SQS.
The test reads the endpoint from SQS_TEST_ENDPOINT with live::endpoint, a helper of the example's own, and skips when it is unset.
It deletes the service's queues first, so nothing an earlier run left on them is counted; the service creates them again on start.
The assertions are the in-process ones, unchanged.
cargo test
SQS_TEST_ENDPOINT=http://localhost:4566 cargo test -- --test-threads=1