Your Blazor Test Is Racing the Renderer

작성자

카테고리:

← 피드로
DEV Community · Ivan Rossouw · 2026-08-04 개발(SW)

A flaky component test can look almost insultingly simple: the test finds a button, raises its event, and occasionally observes nothing. The tempting responses are familiar – add a delay, retry the interaction, or blame the component’s asynchronous work.

Sometimes the more useful question is: which render did the test actually interact with?

A recent focused test correction highlighted a narrow Blazor lesson. The test located an element and dispatched its event as separate steps outside one renderer-owned action. An asynchronous re-render could occur between those steps. The stored element then belonged to an older render tree, and the test no longer had a reliable path to the current handler.

The correction was small: schedule element lookup and event dispatch together through the renderer, then wait for an observable postcondition. The principle is broader than that one test.

The gap between finding and dispatching

Blazor’s renderer owns component rendering and event processing. A component may update after a callback, a completed task, a parameter change, or a service notification. Those updates can replace elements and their associated handlers.

Test code, meanwhile, often reads like ordinary sequential C#:

var save = rendered.Find("[data-action='save']");

// An asynchronous state change may render here.

save.Click();

Enter fullscreen mode Exit fullscreen mode

The selector can be perfectly correct when Find runs. That does not guarantee that the captured wrapper still represents the active element when Click runs.

The vulnerable window may be tiny. It can disappear while debugging, widen under load, or change when unrelated assertions are added. That makes arbitrary delays especially seductive. A delay changes the timing, but it does not define ownership or prove that the current render received the event.

Make the interaction one renderer-owned action

The safer shape is to keep acquisition and dispatch inside a single action scheduled through the renderer:

await rendered.InvokeAsync(() =>
{
    rendered.Find("[data-action='save']").Click();
});

Enter fullscreen mode Exit fullscreen mode

Exact APIs differ between test libraries, but the important unit is the same:

  1. Enter the renderer’s dispatch context.
  2. Find the element from the current render.
  3. Raise its event before leaving that action.

Re-querying avoids carrying an element across a possible render boundary. Dispatching through the renderer aligns the test interaction with Blazor’s event-processing model.

This is not a call to put the entire test inside one renderer callback. Keep the critical interaction small. Arrange inputs first, wait for any required precondition, then combine only the final lookup and event dispatch.

Wait for behaviour, not elapsed time

Dispatching an event is not the behaviour the user cares about. The useful contract is what becomes observable afterwards.

Depending on the component, that might be:

  • rendered confirmation or validation text;
  • a button becoming disabled or enabled;
  • navigation to an expected route;
  • one recorded call to a dependency;
  • a loading indicator appearing and then clearing; or
  • preserved input after a failed operation.

Use the test library’s bounded wait-for-state or wait-for-assertion facility around that outcome:

await rendered.InvokeAsync(() =>
{
    rendered.Find("[data-action='save']").Click();
});

rendered.WaitForAssertion(() =>
{
    Assert.Contains("Saved", rendered.Markup);
});

Enter fullscreen mode Exit fullscreen mode

A bounded wait says, “this state must eventually become true.” An arbitrary sleep says only, “pause and hope the machine is fast enough.” The first expresses a contract and can fail with useful evidence. The second tends to be both slow and fragile.

The trade-off: more ceremony, clearer intent

This pattern adds ceremony. Repeated interactions may need a small helper, and every test must identify a meaningful postcondition. That costs more than storing an element once and clicking it later.

There is also a risk of over-correction. Wrapping broad portions of a test in the renderer dispatcher can over-serialise the scenario and hide useful concurrency signals. Dispatcher usage should not bypass a real readiness requirement either. If the button should appear only after data loads, first wait for that visible precondition; then find and dispatch against the current tree.

The payoff is precision. The test says which boundary owns the interaction and which behaviour proves completion. Failures become less dependent on machine timing and more closely describe the user-facing contract.

What the evidence does – and does not – show

This lesson comes from reviewing a focused committed correction and the surrounding component flow. The change is consistent with a race between element acquisition, asynchronous rendering, and event dispatch.

I did not run the full suite, repeat the test under stress, or prove that every intermittent component failure has this cause. A passing correction also does not prove the production component is free of concurrency defects. The evidence supports a boundary worth protecting, not a universal diagnosis.

A practical review checklist is:

  • Can the component re-render between lookup and dispatch?
  • Are lookup and event dispatch one renderer-scheduled action?
  • Does the assertion describe an observable outcome?
  • Is waiting bounded and state-based rather than time-based?
  • Has repeated execution been used before making a stability claim?

The smallest reliable component tests respect the renderer without making timing itself part of the specification. Where could one of your tests be holding an element across a render boundary?

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다