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.
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
// 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.
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.
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.
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:
await page.getByTestId('shell').evaluate((el) => { (el as HTMLElement).style.width = '640px'; });
await expect(card).toHaveAttribute('data-size', 'wide');
Verification Steps
- Confirm you are on the real API:
await page.evaluate(() => ResizeObserver.toString().includes('[native code]'))should betrue— proof the JSDOM setup file has not leaked into this project. - Confirm the wait is on a consequence: search the spec for
waitForTimeoutand 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.widthequals the CSS width timesdevicePixelRatioat adeviceScaleFactorof 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.
Related
- Testing Observers in JSDOM & Real Browsers — the boundary between the two layers
- Fixing ResizeObserver Loop Limit Exceeded — the browser-only failure this suite can catch
- Debugging Flaky Observer Tests in CI — when the wait is right and the machine is slow anyway