QA How-To
Playwright Assert Realtime Notifications Examples: A Practical Tutorial
Use playwright assert realtime notifications examples to test badges, ordering, duplicates, accessibility, dismissal, and reconnect behavior reliably.
18 min read | 2,408 words
TL;DR
Use page.routeWebSocket() to inject controlled notification frames, then assert the rendered list, order, unread badge, ARIA live region, deduplication, dismissal, and navigation with Playwright's retrying locator assertions. Register the route before page.goto() and avoid fixed sleeps.
Key Takeaways
- Intercept the WebSocket before navigation so notification delivery is deterministic.
- Assert user-visible outcomes such as content, order, unread count, and accessible announcements.
- Use stable event IDs to test duplicate suppression and replay safety.
- Create notification promises before triggering frames to avoid timing races.
- Verify dismissal and navigation as separate product behaviors.
- Keep a small real-backend smoke suite because browser mocks do not prove infrastructure behavior.
The best playwright assert realtime notifications examples control the incoming event and verify what the user actually experiences: message text, ordering, unread count, accessibility, dismissal, and replay safety. Use page.routeWebSocket() to deliver deterministic frames, then use Playwright's web-first locator assertions instead of fixed delays.
This tutorial focuses on the UI assertion layer. Read the complete Playwright WebSocket testing guide for transport inspection, mocking, reconnection, and test-layer strategy across an entire real-time application.
You will build a small notification center and a paste-ready TypeScript suite. The examples run without a separate WebSocket server, so failures point to application behavior instead of an unstable test environment.
TL;DR
| Requirement | Test input | Best assertion |
|---|---|---|
| Delivery | One valid notification frame | toContainText() |
| Ordering | Two sequenced frames | toHaveText([...]) |
| Unread state | New and read events | toHaveText() on badge |
| Deduplication | Same event ID twice | toHaveCount(1) |
| Accessibility | Incoming message | Live region name or text |
| Dismissal | User clicks close | not.toBeVisible() |
| Navigation | User opens item | toHaveURL() |
Mock the transport for broad browser behavior coverage. Keep a few real-service smoke tests for authentication, proxies, deployment configuration, and end-to-end compatibility.
What You Will Build
By the end, you will have:
- A local notification page with a WebSocket connection, unread badge, list, live region, open action, and dismiss action.
- A reusable mock that waits for the browser subscription before sending server events.
- Tests for content, order, badge state, duplicates, accessibility, dismissal, and navigation.
- Failure diagnostics that identify whether delivery, state, or rendering broke.
- A clear boundary between mocked UI tests and real-backend smoke tests.
Prerequisites
Use Node.js 20 or newer and a current Playwright Test release. Start in an empty directory:
mkdir playwright-notification-examples
cd playwright-notification-examples
npm init -y
npm install -D @playwright/test@latest
npx playwright install chromium
You need basic TypeScript, DOM, JSON, and locator knowledge. The tutorial uses Playwright's built-in WebSocket routing API, so you do not need a third-party socket package.
Create these directories:
mkdir tests
Verify the setup with npx playwright --version. Then run npx playwright test --list; Playwright should start without a missing package or browser error.
Step 1: Configure the Playwright Notification Project
Create playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'retain-on-failure',
screenshot: 'only-on-failure'
},
webServer: {
command: 'node server.mjs',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
]
});
The configuration starts a local HTTP server for every run. Begin with Chromium while developing the behavior, then add Firefox and WebKit projects after the suite is stable. Traces and screenshots preserve evidence only when useful.
Verify the step: run npx playwright test --list. The configuration should load. A missing server.mjs error is expected only when a test tries to start the server, which you add next.
Step 2: Build the Real-Time Notification Page
Create server.mjs with a small page that connects to /notifications, renders unique events, and tracks unread state:
import http from 'node:http';
const html = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Notifications</title></head>
<body>
<h1>Notification Center</h1>
<p data-testid="connection">connecting</p>
<button data-testid="mark-read">Mark all read</button>
<span aria-label="Unread notifications" data-testid="badge">0</span>
<div role="status" aria-live="polite" data-testid="announcer"></div>
<ul aria-label="Notifications" data-testid="list"></ul>
<script>
const connection = document.querySelector('[data-testid=connection]');
const badge = document.querySelector('[data-testid=badge]');
const list = document.querySelector('[data-testid=list]');
const announcer = document.querySelector('[data-testid=announcer]');
const seen = new Set();
let unread = 0;
function render(message) {
if (message.type !== 'notification' || seen.has(message.id)) return;
seen.add(message.id);
unread += 1;
badge.textContent = String(unread);
announcer.textContent = message.text;
const item = document.createElement('li');
item.dataset.id = message.id;
const link = document.createElement('a');
link.href = '/jobs/' + encodeURIComponent(message.jobId);
link.textContent = message.text;
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.setAttribute('aria-label', 'Dismiss ' + message.text);
dismiss.textContent = 'Dismiss';
dismiss.addEventListener('click', () => item.remove());
item.append(link, ' ', dismiss);
list.prepend(item);
}
document.querySelector('[data-testid=mark-read]').addEventListener('click', () => {
unread = 0;
badge.textContent = '0';
});
const socket = new WebSocket('ws://127.0.0.1:4173/notifications');
socket.addEventListener('open', () => {
connection.textContent = 'online';
socket.send(JSON.stringify({ type: 'subscribe', channel: 'jobs' }));
});
socket.addEventListener('message', event => render(JSON.parse(event.data)));
socket.addEventListener('close', () => connection.textContent = 'offline');
</script>
</body>
</html>`;
http.createServer((request, response) => {
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
response.end(html);
}).listen(4173, '127.0.0.1');
The page prepends new messages, so newest appears first. A stable ID prevents duplicate rendering. The status live region announces the latest message without forcing focus. The fixture assigns server-provided text through textContent and encodes the path segment, matching the safe DOM construction you should use in production.
Verify the step: run node server.mjs and open http://127.0.0.1:4173. The heading and zero badge should appear. The connection becomes offline because no socket server exists. Stop the process before the test runner starts it.
Step 3: Create a Deterministic WebSocket Notification Mock
Create tests/notifications.spec.ts. Define the event shape and a helper that sends frames only after the client subscribes:
import { test, expect, type Page } from '@playwright/test';
type Notification = {
type: 'notification';
id: string;
jobId: string;
text: string;
};
async function mockNotifications(
page: Page,
notifications: Notification[]
) {
await page.routeWebSocket('**/notifications', ws => {
ws.onMessage(message => {
const request = JSON.parse(String(message));
if (request.type !== 'subscribe' || request.channel !== 'jobs') return;
for (const notification of notifications) {
ws.send(JSON.stringify(notification));
}
});
});
}
const first: Notification = {
type: 'notification',
id: 'event-101',
jobId: 'qa-101',
text: 'Senior QA role matched'
};
Register page.routeWebSocket() before page.goto(). The callback behaves like the server endpoint and ws.onMessage() reads client messages. Waiting for the subscription models a realistic protocol boundary and prevents the test from sending data before application listeners are ready. For more routing patterns, follow the Playwright mock WebSocket responses tutorial.
Verify the step: add a temporary console.log(request) inside the handler, run the upcoming test, and confirm it prints { type: 'subscribe', channel: 'jobs' }. Remove the log afterward.
Step 4: Playwright Assert Realtime Notifications Examples for Content and Order
Add the first two tests:
test('renders a real-time notification and unread badge', async ({ page }) => {
await mockNotifications(page, [first]);
await page.goto('/');
await expect(page.getByTestId('connection')).toHaveText('online');
await expect(page.getByRole('link', { name: first.text })).toBeVisible();
await expect(page.getByTestId('badge')).toHaveText('1');
});
test('shows newest notification first', async ({ page }) => {
await mockNotifications(page, [
first,
{ type: 'notification', id: 'event-102', jobId: 'qa-102', text: 'Test lead role matched' }
]);
await page.goto('/');
const items = page.getByRole('list', { name: 'Notifications' }).getByRole('listitem');
await expect(items).toHaveCount(2);
await expect(items).toHaveText([
'Test lead role matched Dismiss',
'Senior QA role matched Dismiss'
]);
await expect(page.getByTestId('badge')).toHaveText('2');
});
toHaveText() retries and compares the full ordered collection. This is stronger than checking that both strings exist because it protects the product rule that newer notifications appear first. Scope the item locator to the named list so unrelated page items cannot satisfy the count.
Use getByRole() for user-facing controls and links. A test ID is appropriate for state indicators such as the badge where the visual label may be separate.
Verify the step: run npx playwright test --project=chromium. Both tests should pass. Reverse the expected array and confirm Playwright reports the ordered text mismatch.
Step 5: Assert Duplicate Suppression and Read State
Real systems can redeliver an event after retry or reconnect. Send the same ID twice and prove the user sees one item and one unread increment:
test('ignores a replayed notification ID', async ({ page }) => {
await mockNotifications(page, [first, first]);
await page.goto('/');
await expect(page.getByRole('link', { name: first.text })).toHaveCount(1);
await expect(page.getByTestId('badge')).toHaveText('1');
});
test('clears unread state without deleting history', async ({ page }) => {
await mockNotifications(page, [first]);
await page.goto('/');
await expect(page.getByTestId('badge')).toHaveText('1');
await page.getByRole('button', { name: 'Mark all read' }).click();
await expect(page.getByTestId('badge')).toHaveText('0');
await expect(page.getByRole('link', { name: first.text })).toBeVisible();
});
Do not deduplicate by text. Two legitimate events may share a message, while one replayed event should carry the same stable identifier. The read-state test separates acknowledgement from deletion, which protects the visible notification history.
For recovery-specific delivery and replay cases, use the Playwright WebSocket reconnection logic tutorial. It shows how to close the first mocked socket, observe another connection, and verify resubscription.
Verify the step: run npx playwright test --grep "replayed|read state". Change the application to increment before checking seen; the replay test should fail with badge text 2.
Step 6: Assert Accessibility, Dismissal, and Navigation
A notification is incomplete if assistive technology cannot discover it or its actions do not work. Add three focused assertions:
test('announces the newest notification politely', async ({ page }) => {
await mockNotifications(page, [first]);
await page.goto('/');
const status = page.getByRole('status');
await expect(status).toHaveAttribute('aria-live', 'polite');
await expect(status).toHaveText(first.text);
});
test('dismisses one notification without changing the unread badge', async ({ page }) => {
await mockNotifications(page, [first]);
await page.goto('/');
await page.getByRole('button', { name: `Dismiss ${first.text}` }).click();
await expect(page.getByRole('link', { name: first.text })).not.toBeVisible();
await expect(page.getByTestId('badge')).toHaveText('1');
});
test('opens the job linked by the notification', async ({ page }) => {
await mockNotifications(page, [first]);
await page.goto('/');
await page.getByRole('link', { name: first.text }).click();
await expect(page).toHaveURL('/jobs/qa-101');
});
The dismissal expectation documents this sample's rule: removing a card does not mark it read. If your product uses a different rule, encode that decision explicitly. getByRole('status') checks the accessibility semantics, while its text assertion proves the announcement carries useful content.
The local server returns the same fixture for every path, but the browser URL still changes. In a routed SPA, assert the destination heading or loaded record as well as the URL.
Verify the step: run the three tests. Remove role="status" from the page to confirm the accessibility test fails even though the visible card still renders.
Step 7: Add Diagnostics and a Real-Backend Boundary
Record sanitized frame types and attach them to the report. Avoid storing tokens, personal data, or full private messages:
test('records bounded notification diagnostics', async ({ page }, testInfo) => {
const evidence: Array<{ direction: string; type: string; id?: string }> = [];
page.on('websocket', socket => {
socket.on('framesent', event => {
const value = JSON.parse(String(event.payload));
evidence.push({ direction: 'sent', type: value.type });
});
socket.on('framereceived', event => {
const value = JSON.parse(String(event.payload));
evidence.push({ direction: 'received', type: value.type, id: value.id });
});
});
await mockNotifications(page, [first]);
await page.goto('/');
await expect(page.getByTestId('badge')).toHaveText('1');
await testInfo.attach('notification-events', {
body: Buffer.from(JSON.stringify(evidence.slice(-20), null, 2)),
contentType: 'application/json'
});
});
Attach observers before navigation, just like routes. Keep only fields needed for diagnosis and bound the collection. If your protocol uses binary frames or heartbeats, branch by payload type before parsing instead of assuming every message is JSON. The step-by-step WebSocket frame inspection guide covers robust collection in more depth.
Mocked tests prove the browser's reducer, state, rendering, and actions. They do not prove TLS, authentication, gateway upgrades, production schemas, or service deployment. Preserve one real-environment smoke journey that creates a notification through an API or owned test hook and waits for the UI result.
Verify the step: run the diagnostic test, then npx playwright show-report. Open the attachment and confirm it contains only direction, message type, and event ID, with no notification text or credentials.
Playwright Assert Realtime Notifications Examples: Designing the Assertion Matrix
Do not combine every requirement into one large test. A focused matrix makes failures readable:
| Scenario | Input | UI contract | Best layer |
|---|---|---|---|
| First delivery | One unique ID | Card and badge appear | Mocked browser |
| Burst | Several unique IDs | Count and order are correct | Mocked browser |
| Replay | Same ID twice | One card and one unread item | Mocked browser |
| Invalid payload | Missing required field | Safe fallback, no crash | Mocked browser and unit |
| Reconnect | Close, reopen, replay | State recovers without duplicates | Mocked browser |
| Production delivery | Backend-generated event | One critical journey works | Real smoke |
Keep parsing and reducer edge cases in unit tests. Use browser tests where DOM behavior, accessibility, routing, or integration between application layers matters. This balance gives fast feedback without pretending a mock validates production infrastructure.
Treat unread count, visible cards, and announcement text as related but independent state. A reducer bug might render a card while leaving the badge unchanged. A component bug might update the badge while hiding the card. An accessibility regression might leave both visual signals correct while the live region stays silent. Separate assertions make each failure explain the broken contract.
Add boundary cases based on actual product rules. Test an empty message, a long title, Unicode text, an unknown event type, and a missing optional field only if the application defines their behavior. Do not invent a fallback inside the test. Agree on whether invalid events are ignored, logged, or displayed as a generic notification, then assert that decision. Validate fixtures against a shared runtime schema when one exists so mocks cannot silently drift from production.
Burst behavior deserves its own case. Send several events synchronously to expose lost state updates, incorrect counters, unstable keys, or accidental replacement of the list. Assert the final ordered collection and count, not each transient render. If the product caps visible history, send one event beyond the limit and verify which item leaves the DOM. Keep the number small and illustrative rather than turning a functional test into a load test.
Also test notification isolation when users, workspaces, or channels matter. The mock can send one subscribed-channel event and one unrelated event. Assert only the authorized, relevant event appears. This UI check does not replace server authorization testing, but it catches client-side filtering mistakes and documents the subscription contract. Never use a browser mock to claim that unauthorized production data cannot reach the client.
Finally, choose assertions that survive harmless presentation changes. Prefer roles and accessible names for controls, stable IDs for state containers, and exact arrays when order is the requirement. Avoid CSS classes that only describe styling. Assert timestamps only when time display is part of the contract, and control the browser clock or input timestamp so the expectation remains deterministic.
Troubleshooting
The mocked route never receives a connection -> Register page.routeWebSocket() before page.goto(). Confirm the pattern matches the complete URL and that the application actually uses WebSocket rather than server-sent events or polling.
The page stays online but no notification appears -> Wait for the client subscription in ws.onMessage() before sending. Validate the fixture's type and required fields against the application schema.
The assertion sometimes sees zero items -> Use await expect(locator).toHaveCount() or toHaveText() instead of an immediate count() comparison. Never use waitForTimeout() to guess when rendering finishes.
The order test fails after a UI redesign -> Confirm whether the product now sorts newest first, oldest first, or by priority. Update the application contract and expected array together, not merely the assertion.
Duplicate tests pass but users still see replays -> Exercise the reconnect path and events received across multiple socket instances. Ensure the ID cache survives transport replacement for the intended session.
The real-backend test fails only in CI -> Check authentication, test data isolation, reverse-proxy WebSocket upgrades, and cleanup. Use frame diagnostics to distinguish missing delivery from failed rendering.
Common Mistakes and Best Practices
- Do install routes and observers before the socket opens. Do not attach them after navigation.
- Do wait for a meaningful subscription frame. Do not send notifications on an arbitrary timer.
- Do assert user behavior, including text, order, count, accessibility, and actions. Do not stop after proving a frame arrived.
- Do deduplicate with a stable server event ID. Do not use visible text as identity.
- Do use retrying locator assertions. Do not read the DOM once and compare stale values.
- Do keep fixtures small and schema-aligned. Do not copy sensitive production payloads into source control.
- Do test mocked edge cases broadly and a real journey narrowly. Do not ask one environment to cover every risk.
- Do bound and redact diagnostics. Do not attach unlimited raw socket traffic.
Where To Go Next
Return to the Playwright WebSocket testing complete guide to place these assertions in a layered strategy. Then learn to inspect WebSocket frames with Playwright when delivery evidence is missing.
Use the Playwright mock WebSocket server responses tutorial for conditional replies, malformed payloads, and server-driven branches. Continue with testing WebSocket reconnection logic in Playwright to cover retry, resubscription, and replay across connections. For broader locator design, see Playwright locator best practices.
Interview Questions and Answers
Q: How should Playwright assert a real-time notification?
Control or trigger a known event, then assert the visible notification, unread state, ordering, and relevant action with retrying locators. Observe frames for diagnosis, but treat the user-facing state as the primary outcome.
Q: Why use page.routeWebSocket() for notification tests?
It lets the test emulate server messages inside the browser session without a separate socket service. This makes rare states such as duplicates, bursts, malformed events, and disconnects deterministic. Register it before the connection begins.
Q: How do you avoid a race when sending mocked notifications?
Wait until ws.onMessage() receives the expected client subscription, then call ws.send(). This proves the application is ready and models the protocol handshake instead of relying on a delay.
Q: How do you test notification ordering?
Locate the items inside the notification list and use toHaveText() with an expected string array. This simultaneously verifies count, content, and DOM order with automatic retries.
Q: How should duplicate notification delivery be tested?
Send the same stable event ID twice and assert one rendered item and one unread increment. Then repeat the case across reconnection if the application must survive server replay after transport recovery.
Q: What accessibility behavior should a notification test cover?
Verify an appropriate live region, usually polite for nonurgent updates, receives a concise announcement. Also verify controls have accessible names and that new content does not unexpectedly steal focus.
Q: What can a mocked WebSocket notification test not prove?
It cannot fully prove production authentication, TLS, gateways, load balancers, deployment compatibility, or the live service schema. Keep a small real-backend smoke test for those boundaries.
Conclusion
Reliable playwright assert realtime notifications examples start with deterministic delivery and end with user-centered assertions. Route the socket before navigation, wait for subscription, inject owned fixtures, and verify content, order, unread state, replay safety, accessibility, and actions with web-first assertions.
Keep each test focused so a failure identifies the broken contract. Add bounded frame evidence for diagnosis, broad mocked coverage for UI branches, and a small real-service smoke layer for infrastructure. Start with the single-delivery test, then add order, duplicate, accessibility, and reconnect cases as separate protections.
Interview Questions and Answers
How would you test a real-time notification with Playwright?
I would register page.routeWebSocket before navigation, wait for the application's subscription message, and send a known notification frame. Then I would assert the visible card, unread badge, order, and relevant action with retrying locators. I would capture sanitized frame evidence for diagnosis.
Why is a frame assertion insufficient for a notification feature?
A received frame proves transport delivery only. Parsing, validation, state management, rendering, and accessibility can still fail afterward. A UI assertion proves the user received the intended outcome, while frame evidence helps localize failures.
How do you avoid race conditions in mocked WebSocket tests?
I install the route before the socket can open and send server frames only after receiving the expected client subscription. I use Playwright's web-first assertions rather than fixed sleeps. Any targeted frame promise is created before its triggering action.
How would you verify notification deduplication?
I would send the same stable event ID more than once and assert one item and one unread increment. I would also replay the ID after reconnection if the requirement spans socket lifecycles. Text is not a safe identity because separate events may share text.
How do you test the order of real-time notifications?
I send fixtures in a known sequence and assert the scoped list items with toHaveText using an ordered array. The expected order should reflect the documented product rule, such as newest first or priority first.
What accessibility checks belong in notification tests?
I verify the correct live-region role and announcement text, accessible names for open and dismiss actions, and sensible focus behavior. Routine notifications usually use a polite status region, while urgent alerts require a deliberate product decision.
How would you divide mocked and real notification coverage?
I use mocked browser tests for delivery branches, ordering, duplicates, malformed events, accessibility, and actions. I keep a small real-service smoke suite for authentication, proxy upgrades, deployment, and end-to-end schema compatibility. Unit tests cover parsing and reducer edge cases.
Frequently Asked Questions
Can Playwright test real-time WebSocket notifications?
Yes. Playwright can observe WebSocket traffic and intercept connections with page.routeWebSocket(). A test can inject controlled server frames and assert the resulting cards, badges, live regions, and actions.
Should a notification test assert the WebSocket frame or the UI?
Assert the UI as the primary product outcome because a valid frame can still be lost during parsing, state updates, or rendering. Keep frame assertions and logs as supporting protocol evidence and diagnostics.
How do I prevent flaky timing in Playwright notification tests?
Register the route before navigation, wait for the client subscription before sending, and use retrying locator assertions. Avoid waitForTimeout because it guesses when asynchronous work will finish.
How do I test duplicate real-time notifications?
Send the same stable event ID twice, then assert one item and one unread increment. Also test replay across a reconnection if deduplication must survive a replaced socket.
How do I test notification order with Playwright?
Scope a list-item locator to the notification list and call toHaveText with the expected array. The assertion retries and verifies content, count, and DOM order together.
How do I test accessible notification announcements?
Locate the status or alert live region by role, verify its aria-live behavior when relevant, and assert the announced text. Also check that notification actions have accessible names and that focus is not stolen.
Do mocked notification tests replace real-backend tests?
No. Mocked tests cover application behavior deterministically, but they do not prove authentication, gateways, TLS, deployment configuration, or live schema compatibility. Retain a small real-backend smoke suite for those risks.
Related Guides
- Playwright Axe Scan Specific Components Examples: A Practical Tutorial
- Playwright Test WebSocket Reconnection Logic: A Practical Tutorial
- Selenium BiDi Capture JavaScript Errors Examples: A Practical Tutorial
- Appium 3 Driver Version Management: A Practical Tutorial
- How to Assert a downloaded PDF in Playwright (2026)
- Test AI Agent Loop Termination: A Practical Tutorial