iTestBDD

Testing Eventually-Consistent Systems

Distributed systems have a dirty secret: your integration tests pass on a Tuesday morning, fail on a Thursday afternoon, and nobody can reproduce the failure locally. The root cause is almost never a bug in the business logic. It's a test that asserts synchronous state in a system that guarantees nothing of the sort. Eventual consistency isn't an edge case in modern architectures — it's the default contract, and most test suites are still written as if every write is immediately readable.

The problem sharpens when you're running microservices over Kafka or Pulsar, coordinating state across CDC pipelines, or testing read replicas that lag by design. A GET /orders/{id} returning 404 two milliseconds after a POST /orders isn't a bug — it's physics. Writing a test that treats it as a bug is a test that will lie to you at scale.

By the end of this article you'll have concrete patterns for polling-based assertions, consumer-driven contract tests with Pact, and OpenTelemetry-backed observability hooks that let you distinguish "not yet consistent" from "actually broken." The patterns apply whether you're using Pytest, Behave, or Cucumber-JVM 7.

API Testing using Python, Behave, VS Code & GitHub Copilot

Smarter API Test Automation — Python, Behave, VS Code, AI with GitHub Copilot & CI/CD Pipelines. Complete in a Weekend!

Learn more

Eventual Consistency as a First-Class Test Constraint

Eventual consistency, as defined by the CAP theorem's practical descendants, means a system guarantees that — absent new writes — all replicas will converge to the same value eventually. The operative word is eventually, not immediately, not within 200ms, and not within your default HTTP client timeout. In systems built on Kafka, DynamoDB Streams, or Postgres logical replication, the propagation window is typically 50ms–2s under normal load, but can spike to 30s+ under backpressure or partition rebalancing. Your tests need to encode that contract explicitly, not ignore it.

In a modern test architecture, this means eventual consistency is a test-layer concern, not just an ops concern. Contract tests (Pact 10.x) verify the shape of async messages between producers and consumers. State-polling helpers replace naive assertions. Observability hooks via OpenTelemetry let you trace exactly when a state transition completed so you can bound your retry windows with data rather than gut feel. These aren't nice-to-haves — they're the difference between a suite that catches real regressions and one that generates noise.

Building Reliable Assertions Over Asynchronous State

The foundational pattern is a polling assertion with exponential backoff and a hard deadline. The naive version — a fixed time.sleep(2) — is the source of most flakiness in distributed test suites. The correct version retries on a predicate, not on a timer, and fails fast when the predicate is structurally impossible (e.g., 404 on a resource that should exist vs. a stale read).

# pytest + httpx — reusable polling helper
import time, httpx

def poll_until(url: str, predicate, *, timeout=10.0, interval=0.3, headers=None):
    deadline = time.monotonic() + timeout
    last_exc = None
    while time.monotonic() < deadline:
        try:
            resp = httpx.get(url, headers=headers, timeout=5.0)
            if predicate(resp):
                return resp
        except httpx.HTTPError as e:
            last_exc = e
        time.sleep(interval)
        interval = min(interval * 1.5, 2.0)  # cap at 2s
    raise TimeoutError(f"Predicate unsatisfied after {timeout}s on {url}") from last_exc


def test_order_eventually_visible(api_base, auth_headers):
    order_id = create_order(api_base, auth_headers)
    resp = poll_until(
        f"{api_base}/orders/{order_id}",
        predicate=lambda r: r.status_code == 200 and r.json()["status"] == "confirmed",
        timeout=15.0,
        headers=auth_headers,
    )
    assert resp.json()["total"] > 0

The interval * 1.5 backoff matters: under Kafka consumer lag, hammering the read API at 300ms intervals can itself cause backpressure. Capping at 2s keeps the test responsive without amplifying load. In a CI run on GitHub Actions with a real Kafka cluster (Confluent Cloud, m1.small), this pattern reduced false-failure rate from ~12% to under 0.5% on a 400-scenario suite.

For Gherkin-driven suites, encode the consistency expectation in the step definition, not the feature file. The scenario should read as business intent:

# Behave feature — business-readable, no polling noise in the DSL
Scenario: Order confirmation propagates to fulfillment service
  Given a customer places an order for SKU "WIDGET-42"
  When the payment service confirms the transaction
  Then the fulfillment service should eventually show the order as "ready_to_pick"
# behave step — polling lives here, not in the feature
@then('the fulfillment service should eventually show the order as "{expected_status}"')
def step_fulfillment_status(context, expected_status):
    resp = poll_until(
        f"{context.fulfillment_base}/orders/{context.order_id}",
        predicate=lambda r: r.status_code == 200
                            and r.json().get("status") == expected_status,
        timeout=20.0,
        headers=context.service_headers,
    )
    assert resp.json()["status"] == expected_status

For async message contracts, Pact's message pact format (Pact 10.x, pact-python or @pact-foundation/pact 12.x) lets you verify that a Kafka producer emits a payload the consumer can actually parse — without a live broker in CI. The provider verification step runs against a replay of the recorded interaction. Pair this with a GitHub Actions matrix that runs Pact broker verification on every producer merge; broken consumer contracts surface in under 3 minutes rather than in a staging environment two days later.

# GitHub Actions — Pact provider verification on producer PRs
jobs:
  pact-verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Pact provider tests
        env:
          PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_URL }}
          PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
        run: |
          pytest tests/pact/provider/ \
            --pact-provider-name=order-service \
            --pact-broker-url=$PACT_BROKER_BASE_URL \
            --pact-publish-verification-results

Where Distributed Test Suites Break Down in Practice

Hardcoded sleep values are the most common failure mode, and they survive code review because they look like a deliberate choice. They're not — they're a guess that becomes wrong the moment CI infrastructure changes or message throughput increases. The fix is mechanical: grep your test codebase for sleep, wait, and setTimeout calls inside test bodies, and replace each one with a predicate-based poll. Cypress 13's cy.waitUntil (via the plugin) and Playwright's expect(locator).toHaveText() with a custom timeout both handle this at the UI layer; the pattern is identical at the API layer.

The second pitfall is asserting on wall-clock timestamps from distributed sources. Two services with unsynchronized clocks (even with NTP, drift of 50–200ms is normal in containerized environments) will produce timestamp ordering that looks like a causality violation to your test. Use logical clocks — event sequence numbers, Kafka offsets, or monotonic counters — when ordering matters. If you need wall-clock assertions, build in a tolerance window and document it explicitly in the test. Undocumented tolerances become invisible technical debt.

Myths That Make Eventually-Consistent Systems Harder to Test Than They Need to Be

Myth 1: Contract tests replace integration tests. They don't. Pact verifies the shape and semantics of a message or HTTP interaction in isolation — it cannot verify that two services, running together under real load, converge to correct state within an acceptable window. Contract tests and polling-based integration tests address different failure modes. You need both. Teams that drop integration tests after adopting Pact routinely rediscover this when a timing-dependent race condition ships to production.

Myth 2: Flaky tests on eventually-consistent paths are inevitable and should be quarantined. Quarantine is a symptom of a missing abstraction, not a policy. If a test fails intermittently because the system hasn't converged yet, the test is encoding the wrong assertion — it's asserting immediate consistency on an eventually-consistent path. Fix the assertion model. A well-written polling test against a Kafka-backed read model should be as deterministic as a synchronous unit test; the only difference is the timeout bound. If you're still seeing flakiness after adding proper polling, instrument the propagation latency with OpenTelemetry spans and look at p95 — your timeout is probably too tight for your actual SLA.

The patterns here — predicate polling, Pact message contracts, logical-clock ordering, and OTel-instrumented propagation windows — compose into a test architecture that treats eventual consistency as a documented constraint rather than a source of noise. If you implement the polling helper and Pact verification pipeline, the next thing worth measuring is your p95 propagation latency per service boundary under CI load. That number will tell you exactly where to tighten timeouts and where to widen them — and it will make your flakiness conversations data-driven rather than anecdotal.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles