Skip to content

feat: add Eventually()/Poll() assertion modifier for async polling patterns #4762

@thomhurst

Description

@thomhurst

Problem

Integration tests frequently need to poll an async operation until it reaches an expected state. Currently this requires manual polling loops:

OrderResponse? order = null;
var deadline = DateTime.UtcNow.AddSeconds(25);
while (DateTime.UtcNow < deadline)
{
    order = await Customer.Client.GetFromJsonAsync<OrderResponse>($"/api/orders/{_orderId}");
    if (order?.Status == OrderStatus.Fulfilled) break;
    await Task.Delay(500);
}
await Assert.That(order!.Status).IsEqualTo(OrderStatus.Fulfilled);

This pattern appeared 3 times in a single example project (order workflow, resilience tests, event verification). It's verbose, error-prone (forgetting null checks, incorrect deadline logic), and obscures the test intent.

Proposed API

// Poll an async lambda until the assertion passes
await Assert.That(async () =>
{
    var order = await Client.GetFromJsonAsync<OrderResponse>($"/api/orders/{id}");
    return order!.Status;
}).Eventually().IsEqualTo(OrderStatus.Fulfilled)
  .Within(TimeSpan.FromSeconds(25))
  .WithInterval(TimeSpan.FromMilliseconds(500));

// Simpler variant for boolean conditions
await Assert.That(async () =>
{
    var response = await Client.GetAsync($"/api/orders/{id}");
    var order = await response.Content.ReadFromJsonAsync<OrderResponse>();
    return order?.Status == OrderStatus.Fulfilled;
}).Eventually().IsTrue()
  .Within(TimeSpan.FromSeconds(25));

Suggested defaults

  • Within() - required (no implicit infinite timeout)
  • WithInterval() - default 500ms

Use Cases

  • Waiting for background workers to process messages
  • Eventually-consistent reads after writes
  • Waiting for external service state changes
  • Health check readiness polling
  • Any async operation that needs time to complete

Context

Discovered while building the CloudShop Aspire + TUnit example (#4761). This was the single biggest source of boilerplate in integration tests.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions