Everything a ResizeObserver reports comes from layout, and layout is the one thing a Node-based runner does not have. This is the half of observer testing that has to happen in a browser, and Playwright makes it cheap enough that there is no excuse for skipping it.

Problem / Scenario Context

A dashboard card switches from a stacked layout to a side-by-side one when its container passes 420 pixels, driven by a ResizeObserver that writes a data-size attribute. The unit tests cover the branch logic: given an entry with an inline size of 500, the attribute becomes wide. They pass.

What they cannot cover is everything that decides which entry arrives. Whether the container is actually 500 pixels at that viewport width. Whether flexbox rounds it to 499.5 and the comparison flips. Whether the observer is watching the element that has the width, or a wrapper that does not. Whether the callback fires at all after the card is moved into a different grid area. Every one of those has produced a production bug that a green unit suite did not see, and every one of them is a single Playwright assertion away from being covered.

Mechanics Explanation

The difference is not that Playwright is "more integrated". It is that the numbers exist.

In a Playwright run the page is laid out by a real engine, so entry.contentRect.width is whatever the CSS produced, devicePixelContentBoxSize is populated, and a change to the viewport or to a sibling's flex basis propagates through layout the way it does for a user. That means an assertion can be about the outcome — the attribute the component wrote — without the test having to state what size the container was, which is the assertion that stays correct when the design changes.

The one genuinely new problem a browser test introduces is waiting. ResizeObserver delivers asynchronously, so an assertion immediately after a viewport change races the callback. The wrong fix is a fixed timeout, which is slow when it is generous and flaky when it is not. The right fix is to wait for the observable consequence, which Playwright's auto-retrying assertions do for you.

Waiting for the Callback: Three StrategiesThree approaches after changing the viewport. An immediate assertion races the callback and fails intermittently. A fixed timeout of five hundred milliseconds is slow on every run and still fails on a loaded machine. An auto-retrying expectation polls the observable consequence and settles as soon as the attribute changes, typically within one frame.await page.setViewportSize(...); expect(attr).toBe('wide')Races the callback. Passes on a fast machine, fails in CI — the classic intermittent.await page.waitForTimeout(500)Half a second added to every run, and still not a guarantee on a loaded runner.await expect(card).toHaveAttribute('data-size', 'wide')Polls until it matches or the timeout expires. Settles in a frame, and states the intent.

Comparison Table: Where Each Layer Belongs

Assertion JSDOM + mock Playwright
The branch handles a 500px entry ✔ fast, cheap possible but wasteful
The container is 500px at this viewport ✘ no layout ✔ the only place
The attribute flips at the breakpoint ✘ depends on layout
devicePixelContentBoxSize is populated ✘ never present
Teardown removes the target possible, but harder to observe
No ResizeObserver loop limit exceeded warning ✘ no loop exists ✔ the only place

The last row is worth stressing. The loop-limit error is a browser-only phenomenon — it requires a real layout pass to re-enter — so a unit suite can never catch a callback that writes back into the box it observes.

Minimal Reproducible Example

TypeScript
// tests/card-resize.spec.ts
import { test, expect } from '@playwright/test';

test('card switches layout at its own breakpoint, not the viewport width', async ({ page }) => {
  await page.goto('/dashboard');
  const card = page.getByTestId('metric-card');

  // Wide window, but the card sits in a narrow sidebar
  await page.setViewportSize({ width: 1440, height: 900 });
  await expect(card).toHaveAttribute('data-size', 'narrow');

  // Widen the SIDEBAR, not the window — a media query would not notice this at all
  await page.getByTestId('sidebar-expand').click();
  await expect(card).toHaveAttribute('data-size', 'wide');
});

This is the test that proves the feature is a container query rather than a disguised media query, and it is not expressible anywhere else.

Production-Safe Solution

Three techniques make a resize suite fast and stable.

Wait on the consequence, never on a clock.

TypeScript
await expect(card).toHaveAttribute('data-size', 'wide');   // auto-retries
await expect(canvas).toHaveJSProperty('width', 1200);      // backing store, after the resize

Assert the numbers the browser computed, when the number is the point.

TypeScript
const box = await card.evaluate((el) => {
  const r = el.getBoundingClientRect();
  return { w: Math.round(r.width), h: Math.round(r.height) };
});
expect(box.w).toBeGreaterThanOrEqual(420);

Fail the test on a loop warning, rather than letting it scroll past.

TypeScript
test.beforeEach(async ({ page }) => {
  const problems: string[] = [];
  page.on('console', (msg) => {
    if (/ResizeObserver loop/.test(msg.text())) problems.push(msg.text());
  });
  page.on('pageerror', (err) => problems.push(String(err)));
  (page as any).__problems = problems;
});

test.afterEach(async ({ page }) => {
  expect((page as any).__problems, 'observer warnings during the test').toEqual([]);
});

That last one converts a silent, intermittent frame drop into a hard, reproducible failure — which is the whole reason to run these tests in a browser.

To exercise a resize without depending on window size, drive the element's own CSS:

TypeScript
await page.getByTestId('shell').evaluate((el) => { (el as HTMLElement).style.width = '640px'; });
await expect(card).toHaveAttribute('data-size', 'wide');

Four Ways to Make Something Resize, and What Each ProvesFour triggers listed with what they cover. Changing the viewport exercises the whole responsive chain. Setting an element style width isolates one container. Clicking a real control that changes layout is the closest to user behaviour. Loading a web font exercises the reflow path that catches teams by surprise. Each is paired with the class of bug it is best at finding.TriggerThe bug it is best at catchingpage.setViewportSize()the whole responsive chain, end to endel.style.width = '640px'a container query that is secretly a media queryclick a real layout controlobservers attached to the wrong node in the treedocument.fonts.readya late reflow that fires a callback nobody expected

Keeping the Two Projects ApartA configuration split showing a JSDOM project that loads the observer stub and a browser project that must not. A shared setup file applied to both is the common misconfiguration, and it makes the browser suite silently exercise the mock instead of the real API.unit projectenvironment: jsdomsetupFiles: [observer-stub]the stub is required herebrowser projectreal ChromiumsetupFiles: [] — no stubassert toString() says [native code]A shared setup file across both is the misconfiguration: the browser suite then tests the mock, through a browser.

Verification Steps

  • Confirm you are on the real API: await page.evaluate(() => ResizeObserver.toString().includes('[native code]')) should be true — proof the JSDOM setup file has not leaked into this project.
  • Confirm the wait is on a consequence: search the spec for waitForTimeout and remove every occurrence.
  • Confirm the console guard is active: temporarily introduce a synchronous write in the callback and check the suite goes red.
  • Confirm device-pixel sizing: assert canvas.width equals the CSS width times devicePixelRatio at a deviceScaleFactor of 2, as covered in responsive canvas and chart resizing.

Common Mistakes to Avoid

  • Reusing the JSDOM setup file. If the mock is installed here, the suite tests the mock through a browser, which is the worst of both worlds. Scope setup files per project in the Playwright and unit configurations.
  • Asserting exact fractional widths. Flex distribution produces values like 419.99. Round, or assert a threshold rather than an equality.
  • Setting a viewport and asserting in the same tick. Layout, the observer and the resulting DOM write are three separate steps; the auto-retrying matcher exists for exactly this.
  • Testing only the breakpoint you designed. Test one width either side of it. A component that is correct at 400 and 500 but wrong at exactly 420 is the common off-by-one.

FAQ

Do I still need unit tests if I have Playwright coverage?

Yes, and for different things. Browser tests are slower and there will be fewer of them, so they should cover the layout-dependent behaviour that only a browser can produce. Branch logic, error handling and teardown belong in fast unit tests where you can afford one per case.

How do I wait for a ResizeObserver callback without a timeout?

Assert on the observable consequence with an auto-retrying expectation. Playwright polls until the attribute, class or JS property matches, so the test settles as soon as the callback has run, usually within one frame, and fails with a clear message if it never does.

Can Playwright catch the ResizeObserver loop limit error?

Yes, and it is one of the strongest reasons to run these tests in a browser at all. Listen on the page console for the warning and assert in an afterEach that none were recorded. A unit test can never see this, because the error requires a real layout pass to re-enter.

Should I change the viewport or the element's own width?

Both, for different reasons. Changing the viewport exercises the full responsive chain; setting one container's width proves the feature responds to its container rather than the window, which is exactly the distinction a container-query implementation is claiming to make.


↑ Back to Testing Observers in JSDOM & Real Browsers