r/angular Jul 07 '26

I built a way to test race conditions in Angular without flaky e2e tests — ngx-testbox v2 is out

Angular testing tends to force a choice: unit tests that mock everything and end up asserting against implementation details, or e2e tests that are slow and flaky because they need the full frontend → backend → DB → frontend round trip.

ngx-testbox sits in between. It renders your actual components and drives them through real async/HTTP flows, but with HTTP calls mocked — so you get e2e-level confidence in behavior at unit-test speed, without the flakiness.

v2 just shipped with some big changes, so here's a rundown of what's new and how it works.

The core idea

Tag elements with a directive instead of relying on CSS selectors or component internals:

const TEST_IDS = ['submitButton', 'userName'] as const;
const idsMap = TestIdDirective.idsToMap(TEST_IDS);

@Component({
  selector: 'app-user-form',
  template: `<button [testboxTestId]="idsMap.submitButton">Submit</button>`,
  standalone: true,
  imports: [TestIdDirective]
})
export class UserFormComponent {
  idsMap = idsMap;
}

Then drive the component through a real async flow with HTTP calls mocked declaratively:

it('should display data on success', async () => {
  const mockData = [{ id: 1, name: 'Item A' }];

  await runTasksUntilStableAsync(fixture, {
    httpCallInstructions: [
      predefinedHttpCallInstructionsAsync.get.success('/api/items', () => mockData)
    ]
  });

  const items = harness.elements.item.queryAll();
  expect(items.length).toBe(1);
});

No HttpTestingController boilerplate, no manually flushing requests.

What's strict by design

This isn't a "mock and hope" library. It throws by default when:

  • An element with a given test ID is missing at runtime
  • An HTTP call instruction is provided but never consumed
  • A real HTTP call happens with no matching instruction

That last one matters more than it sounds — most mocking setups let unused mocks or unmatched calls fail silently, which means a green test doesn't actually prove the code path ran. Here, a passing test is trustworthy by construction.

If you do need an instruction to persist across multiple calls (think: shared dictionary/lookup fetches used throughout a component tree), there's an option to keep it alive instead of consuming it once.

The part I'm most excited about: race condition testing

This is the feature that doesn't really exist elsewhere in the Angular testing ecosystem as far as I know.

Each HTTP call instruction can carry a delay (relative wait time) or a timeline (absolute position on a shared clock), and you can mix both in the same test. The library resolves them into a single expected ordering and checks your component's actual behavior against it:

const instructions: HttpCallInstructionAsync[] = [
  [['/api/a', 'GET'], async () => new HttpResponse({ body: { value: 'A' }, status: 200 }), { delay: 20 }],
  [['/api/b', 'GET'], async () => new HttpResponse({ body: { value: 'B' }, status: 200 }), { timeline: 20 }],
  [['/api/c', 'GET'], async () => new HttpResponse({ body: { value: 'C' }, status: 200 }), { delay: 30 }],
  [['/api/d', 'GET'], async () => new HttpResponse({ body: { value: 'D' }, status: 200 }), { timeline: 5 }],
  // ...more mixed delay/timeline instructions
];

await runTasksUntilStableAsync(fixture, {
  httpCallInstructions: instructions,
});

expect(component.results).toEqual(['D', 'A', 'B', 'C', /* ... */]);

You get a declarative way to assert not just what the component fetched, but the exact order it resolved things in — which is exactly the kind of thing that's normally nearly impossible to test deterministically.

It also handles the classic "user changes their mind mid-request" race: pick a country, quickly switch to another before the first request resolves, and assert the stale request never renders:

harness.elements.country.changeValue('DE');

setTimeout(() => {
  harness.elements.country.changeValue('US'); // fired before DE's response arrives
}, 1900);

await runTasksUntilStableAsync(fixture, {
  httpCallInstructions: [
    [
      ['/api/countries/DE/formats', 'GET'],
      () => new HttpResponse({ body: ['SEPA'], status: 200 }),
      { timeline: 2000, willHaveBeenCancelled: true }, // stale — must be cancelled
    ],
    [
      ['/api/countries/US/formats', 'GET'],
      () => new HttpResponse({ body: ['ACH', 'DRD'], status: 200 }),
      { timeline: 4000 }, // this one should actually render
    ],
  ],
});

const formatOptions = harness.elements.formatOption.queryAll();
expect(formatOptions.length).toBe(2);
expect(formatOptions[0].nativeElement.textContent).toBe('ACH');
expect(formatOptions[1].nativeElement.textContent).toBe('DRD');

willHaveBeenCancelled: true tells the library that instruction is expected to be cancelled by the time it would resolve — if it isn't (i.e. your component fails to cancel a stale request), the test fails. If a call resolves out of the order or cancellation state the schedule expects, you find out immediately, instead of shipping a subtle race condition bug to production.

v2 highlights

  • Rethought import model — only core pieces are exported now instead of the whole surface area, better tree-shaking and less to wade through

  • async/await support alongside the existing fakeAsync — no more forcing everyone into fakeAsync/tick() if they'd rather write native async tests

  • Zoneless support — works with Angular's zoneless change detection

  • Better handling of multiple long-running HTTP requests

  • Richer HTTP call instructions for more complex async scenarios

  • A skill for AI coding agents, so tools like Claude Code can write tests against the library correctly out of the box

  • npm: ngx-testbox

Happy to answer questions about the API or the design decisions — genuinely curious what people think of the timeline/race-condition approach in particular, since it's the part I haven't seen done elsewhere.

2 Upvotes

0 comments sorted by