Testland
Browse all skills & agents

async-ordering-tests

Test async ordering - event-loop / queue / channel ordering assertions, JS Promise microtask vs macrotask ordering, Python `asyncio.gather` vs `asyncio.wait_for` semantics, Go goroutine + channel happens-before relationships, async/await re-entrancy. Use deterministic schedulers (sinon fake timers, asyncio test mode) to remove run-to-run variance.

async-ordering-tests

Async ordering bugs ("user A's update overwrote user B's" / "callback fired twice" / "promise resolved before fetch returned") look like race conditions but operate at the runtime level (event loop / scheduler). Tests must control the scheduler to be deterministic.

When to use

  • UI updates that depend on multiple in-flight requests resolving in the right order.
  • Server-side: parallel I/O whose completion order affects the final state.
  • Callback-heavy code where re-entrancy (sync vs async dispatch) matters.

Step 1 - JS Promise microtask vs macrotask

// Microtasks (Promise callbacks) drain BEFORE next macrotask (setTimeout)
test('microtasks drain before setTimeout', async () => {
  const order: string[] = [];

  setTimeout(() => order.push('setTimeout'), 0);
  Promise.resolve().then(() => order.push('promise'));

  // Wait for both to fire
  await new Promise(r => setTimeout(r, 0));

  expect(order).toEqual(['promise', 'setTimeout']);
});

Critical: promise resolution callbacks run in the microtask queue, which drains between macrotasks. Misunderstanding this causes "why did this fire twice" bugs.

Step 2 - Use deterministic timers (Sinon / Vitest fake)

import { vi } from 'vitest';

test('debounced search fires once after 300ms', () => {
  vi.useFakeTimers();
  const search = vi.fn();
  const debounced = debounce(search, 300);

  debounced('a');
  debounced('ab');
  debounced('abc');

  vi.advanceTimersByTime(299);
  expect(search).not.toHaveBeenCalled();

  vi.advanceTimersByTime(1);
  expect(search).toHaveBeenCalledOnce();
  expect(search).toHaveBeenCalledWith('abc');
});

Real timers + sleep = flake. Fake timers = deterministic.

Step 3 - Promise.all vs sequential await

test('parallel fetch starts both before either completes', async () => {
  const order: string[] = [];

  function trackedFetch(name: string) {
    order.push(`start-${name}`);
    return new Promise<string>((resolve) =>
      setTimeout(() => {
        order.push(`done-${name}`);
        resolve(name);
      }, 50)
    );
  }

  await Promise.all([trackedFetch('A'), trackedFetch('B')]);

  // Both started before either finished
  expect(order.indexOf('done-A')).toBeGreaterThan(order.indexOf('start-B'));
});

vs sequential:

test('sequential await runs A fully before B starts', async () => {
  const order: string[] = [];
  await trackedFetch('A');
  await trackedFetch('B');

  expect(order).toEqual(['start-A', 'done-A', 'start-B', 'done-B']);
});

Test the actual model your code uses, especially when "parallelize" is an intentional optimization.

Step 4 - Python asyncio ordering

import asyncio

async def test_gather_concurrency_with_first_completed():
    started = []
    finished = []

    async def task(name, delay):
        started.append(name)
        await asyncio.sleep(delay)
        finished.append(name)

    await asyncio.gather(task("A", 0.05), task("B", 0.01))

    # Both started before either finished
    assert started == ["A", "B"]
    # B finishes first (shorter sleep)
    assert finished == ["B", "A"]

For race-style: use asyncio.wait(..., return_when=FIRST_COMPLETED) to test "whoever finishes first wins" semantics.

Step 5 - Async re-entrancy

A callback fires while another callback is still running. Tests:

test('handler not invoked while previous invocation in progress', async () => {
  const calls: string[] = [];
  let inProgress = false;

  const handler = async (msg: string) => {
    calls.push(`enter-${msg}`);
    if (inProgress) {
      throw new Error('Re-entrant call detected');
    }
    inProgress = true;
    await new Promise(r => setTimeout(r, 10));
    inProgress = false;
    calls.push(`exit-${msg}`);
  };

  const queue = createSerializingQueue(handler);
  queue.enqueue('a');
  queue.enqueue('b');

  await queue.drain();
  expect(calls).toEqual(['enter-a', 'exit-a', 'enter-b', 'exit-b']);
});

Test guards: queue serializes enqueued work even if event loop allows interleaving.

Step 6 - Go channel happens-before

func TestChannelHappensBefore(t *testing.T) {
    var x int
    done := make(chan struct{})

    go func() {
        x = 42        // (1)
        done <- struct{}{}  // (2)
    }()

    <-done           // (3)
    if x != 42 {     // (4) — guaranteed by happens-before
        t.Fatalf("expected 42, got %d", x)
    }
}

Go memory model: (1) happens-before (2); (2) happens-before (3); (3) happens-before (4). Without channel sync, (4) could see 0 even though (1) ran in the goroutine.

For tests, run with -race:

go test -race ./...

Step 7 - Backpressure / queue overflow

async def test_queue_drops_when_full():
    queue = asyncio.Queue(maxsize=3)
    dropped = []

    async def producer():
        for i in range(10):
            try:
                queue.put_nowait(i)
            except asyncio.QueueFull:
                dropped.append(i)

    await producer()
    assert len(dropped) == 7

Bounded queues + drop-on-full = silent data loss. Test the policy explicitly.

Step 8 - Cancellation propagation

async def test_cancellation_propagates_to_children():
    child_started = asyncio.Event()
    child_cancelled = asyncio.Event()

    async def child():
        try:
            child_started.set()
            await asyncio.sleep(60)
        except asyncio.CancelledError:
            child_cancelled.set()
            raise

    async def parent():
        await child()

    parent_task = asyncio.create_task(parent())
    await child_started.wait()

    parent_task.cancel()
    with pytest.raises(asyncio.CancelledError):
        await parent_task

    assert child_cancelled.is_set()

Common bug: parent cancelled, child task leaks (continues running).

Anti-patterns

Anti-patternWhy it failsFix
await sleep(100) to "let things settle"Flake on slow CIFake timers / explicit waits (Step 2)
Test Promise.all order assuming sequentialNon-deterministic interleaveTest model explicitly (Step 3)
Skip cancellation testsCancelled tasks leak foreverCancellation propagation test (Step 8)
Use real timers + setTimeout(0) for "next tick"Race with microtask drainqueueMicrotask() or Promise.resolve()
Test only "happy path" with single taskConcurrency bugs hideMulti-task scenarios (Step 4)

Limitations

  • Microtask vs macrotask semantics differ across runtimes (Node process.nextTick is a third queue between).
  • asyncio's behavior changed across Python versions (3.10's task-cancellation handling differs from 3.7).
  • Go's -race finds data races, not all happens-before violations.
  • Some libraries (RxJS) layer their own scheduling over the runtime - test their semantics, not just the runtime's.

References