QA How-To
Mask Production Data Preview Environments: A Safe Tutorial
Learn to mask production data preview environments safely with deterministic transforms, referential integrity, CI checks, and automated cleanup in CI.
19 min read | 2,688 words
TL;DR
Restore a production snapshot only into an isolated masking job, transform every sensitive column before exposure, and publish the database only after automated privacy and integrity gates pass. Deterministic HMAC values preserve useful relationships without retaining real identifiers.
Key Takeaways
- Mask a restored database inside an isolated job before any preview application can reach it.
- Use deterministic HMAC transforms when tests must preserve joins, uniqueness, and repeatable identities.
- Replace free text and secrets instead of trying to sanitize them with fragile regular expressions.
- Verify masking with allowlist assertions, denylist scans, and referential integrity checks.
- Give the masking identity no access to the source production database and rotate its key separately.
- Destroy preview databases automatically so masked data does not become long-lived shadow production data.
To mask production data preview environments safely, restore a short-lived snapshot into an isolated database, transform every sensitive field, verify that no original values remain, and only then let the preview application connect. Never attach an unmasked production clone to a publicly reachable preview deployment, even for a few minutes.
This production data masking tutorial builds that workflow with PostgreSQL 16+, Node.js 22, Docker Compose, and GitHub Actions. It fits into the broader ephemeral test environments complete guide, which covers provisioning, routing, secrets, observability, and teardown around the data pipeline.
You will create deterministic masked identities, keep foreign keys valid, redact dangerous free text, block publication when privacy checks fail, and remove the environment when its pull request closes. The examples use a small commerce schema, but the same preview environment test data controls apply to any relational application.
What You Will Build
By the end, you will have:
- A local PostgreSQL source fixture that represents a production snapshot.
- A separate preview database that receives the restored snapshot.
- A Node.js masking command that uses keyed HMAC transforms.
- SQL verification gates for privacy, uniqueness, and referential integrity.
- A GitHub Actions job that publishes a masked artifact only after validation.
- A threat model that tells reviewers which fields to transform, delete, generalize, or retain.
The critical boundary is simple: the restore area is private, and the application receives access only after the mask and verification jobs succeed. Treat the masked result as confidential test data, not as harmless public sample data. This boundary is the foundation of ephemeral environment data security.
Prerequisites
Install these current, supported tools:
- Docker Engine 27+ with Docker Compose v2.
- Node.js 22 LTS or newer.
- npm 10 or newer.
- PostgreSQL client 16+ if you want to inspect the database outside the container.
- A GitHub repository with Actions enabled for the CI example.
Confirm the tools:
docker version --format '{{.Server.Version}}'
docker compose version
node --version
npm --version
Create a clean tutorial directory and install the PostgreSQL driver:
mkdir preview-data-mask && cd preview-data-mask
npm init -y
npm install pg@8
mkdir -p db scripts .github/workflows artifacts
Use synthetic fixtures while developing the pipeline. Connect a real snapshot only after security review, restricted networking, encryption, audit logging, and a documented deletion policy are in place. If your preview database starts empty instead, follow the ephemeral database seeding with Testcontainers tutorial and avoid production data entirely.
Step 1: Classify Data Before You Mask Production Data Preview Environments
Start with a field inventory. A masking script built before classification usually misses JSON payloads, support notes, audit tables, or copied authentication secrets. Put each field into one action category.
| Action | Use it for | Example | Preview result |
|---|---|---|---|
| Replace deterministically | Joins and stable test identities | Email, external customer ID | Same input maps to same fake value |
| Replace randomly | Values that need no stable relationship | Session token, reset token | New random value |
| Generalize | Analytics where rough distribution matters | Birth date, precise location | Year band, region |
| Redact | Unstructured text with unpredictable PII | Support notes | Fixed safe sentence |
| Delete | Data with no preview purpose | Payment token, biometric data | NULL or removed row |
| Retain | Non-sensitive operational data | Product status | Original value |
Create data-policy.json as a reviewable contract:
{
"users.id": "retain",
"users.email": "deterministic",
"users.full_name": "deterministic",
"users.phone": "deterministic",
"users.password_hash": "delete",
"orders.user_id": "retain",
"orders.shipping_address": "redact",
"support_tickets.body": "redact"
}
Keep direct identifiers, quasi-identifiers, secrets, and free text in scope. Quasi-identifiers such as postal code, age, employer, and timestamp can identify someone when combined, even if names are gone. Do not retain password hashes because they are still credential material. Do not retain payment provider tokens because a preview test does not need them.
Verify the step: ask a data owner and a security reviewer to approve every column. Compare the policy against information_schema.columns; the review fails if a new column has no classification. Your final CI gate will automate that comparison.
Step 2: Create an Isolated Source and Preview Database
Create compose.yaml. The source fixture and preview target use separate containers and volumes. Neither publishes a port to the host. Commands reach them through docker compose exec, reducing accidental exposure.
services:
source-db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: source-local-only
POSTGRES_DB: app
volumes:
- source-data:/var/lib/postgresql/data
- ./db/source.sql:/docker-entrypoint-initdb.d/01-source.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
interval: 2s
timeout: 3s
retries: 20
preview-db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: preview-local-only
POSTGRES_DB: app_preview
volumes:
- preview-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app_preview"]
interval: 2s
timeout: 3s
retries: 20
volumes:
source-data:
preview-data:
Create db/source.sql with realistic but synthetic source rows:
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL UNIQUE,
full_name text NOT NULL,
phone text,
password_hash text,
created_at timestamptz NOT NULL
);
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
total_cents integer NOT NULL CHECK (total_cents >= 0),
shipping_address text,
created_at timestamptz NOT NULL
);
CREATE TABLE support_tickets (
id bigint PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
body text,
created_at timestamptz NOT NULL
);
INSERT INTO users VALUES
(1, 'alice@example.com', 'Alice Rivera', '+1-202-555-0101', '$hash-a', '2026-06-01T10:00:00Z'),
(2, 'bob@example.net', 'Bob Chen', '+1-202-555-0102', '$hash-b', '2026-06-02T11:00:00Z');
INSERT INTO orders VALUES
(101, 1, 12900, '18 Market Street, Boston', '2026-06-10T12:00:00Z'),
(102, 2, 4500, '77 Lake Road, Austin', '2026-06-11T12:00:00Z');
INSERT INTO support_tickets VALUES
(201, 1, 'Call me at +1-202-555-0101 about order 101', '2026-06-12T12:00:00Z');
Start both services:
docker compose up -d --wait
docker compose ps
Verify the step: both services should show healthy. Run docker compose exec -T source-db psql -U postgres -d app -c 'SELECT count(*) FROM users;' and expect 2. The preview database should return an error for SELECT * FROM users because it has not been restored yet.
Step 3: Restore the Snapshot Inside the Private Boundary
Stream a custom-format dump from the source container into a temporary local artifact, then restore it to the preview container. In a real pipeline, download an encrypted snapshot into a private runner with no inbound internet route. Never store the unmasked dump as a CI artifact.
docker compose exec -T source-db pg_dump \
-U postgres -d app -Fc > artifacts/source.dump
docker compose exec -T preview-db pg_restore \
-U postgres -d app_preview --clean --if-exists \
< artifacts/source.dump
The --clean --if-exists flags make repeated local runs predictable. They drop restored objects before recreating them. Use a fresh preview database for every pull request in production rather than sharing one mutable database across branches.
Protect the boundary with two database roles. The restore-and-mask role can write tables but cannot serve application traffic. The application role is created or granted access only after verification. At the infrastructure layer, allow the restore job to reach snapshot storage and the private database, but do not expose the database through a public load balancer.
Delete the raw artifact as soon as restoration succeeds:
rm -f artifacts/source.dump
Verify the step: run test ! -e artifacts/source.dump and expect exit code 0. Then run docker compose exec -T preview-db psql -U postgres -d app_preview -c 'SELECT email FROM users ORDER BY id;'. At this point it intentionally displays the source fixture, which proves why the preview application must still have no network path or credentials.
Step 4: Mask Production Data Preview Environments with HMAC
Deterministic masking maps the same source value to the same substitute. That behavior keeps equality checks and cross-table relationships useful. Use HMAC with a secret key, not a plain unsalted hash. A plain hash makes common emails and phone numbers easy to guess with a dictionary.
Create scripts/mask.mjs:
import { createHmac } from 'node:crypto';
import pg from 'pg';
const { Client } = pg;
const connectionString = process.env.DATABASE_URL;
const key = process.env.MASKING_KEY;
if (!connectionString || !key || key.length < 32) {
throw new Error('Set DATABASE_URL and a MASKING_KEY of at least 32 characters');
}
const token = (namespace, value, length = 20) =>
createHmac('sha256', key)
.update(`${namespace}:${value}`)
.digest('hex')
.slice(0, length);
const client = new Client({ connectionString });
await client.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
'SELECT id, email, full_name, phone FROM users ORDER BY id FOR UPDATE'
);
for (const row of rows) {
const emailId = token('email', row.email);
const nameId = token('name', row.full_name, 10);
const phoneId = token('phone', row.phone ?? String(row.id), 10);
await client.query(
`UPDATE users
SET email = $1, full_name = $2, phone = $3, password_hash = NULL
WHERE id = $4`,
[
`user-${emailId}@preview.invalid`,
`Preview User ${nameId}`,
`+1-555-${phoneId.slice(0, 3)}-${phoneId.slice(3, 7)}`,
row.id
]
);
}
await client.query(
`UPDATE orders SET shipping_address = 'REDACTED PREVIEW ADDRESS'`
);
await client.query(
`UPDATE support_tickets SET body = 'REDACTED PREVIEW TICKET'`
);
await client.query('COMMIT');
console.log(`Masked ${rows.length} users`);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
await client.end();
}
Run it on the private Docker network. The hostname is preview-db, not localhost:
docker run --rm --network preview-data-mask_default \
-v "$PWD:/work" -w /work node:22-alpine \
sh -c 'DATABASE_URL=postgres://postgres:preview-local-only@preview-db:5432/app_preview MASKING_KEY=local-only-key-change-me-1234567890 node scripts/mask.mjs'
The transaction prevents a half-masked database. Namespaces ensure the same literal used as an email and a phone does not generate the same token. The reserved .invalid domain prevents mail delivery. For very large tables, process stable primary-key ranges in separate transactions, but keep the database inaccessible until all ranges pass.
Verify the step: expect Masked 2 users. Query SELECT id,email,full_name,password_hash FROM users ORDER BY id;. Emails should end in @preview.invalid, names should start with Preview User, and every password hash should be NULL. Rerun the masking command against a freshly restored copy with the same key and confirm identical output.
Step 5: Add Privacy and Integrity Verification Gates
Masking is not complete because the update command returned zero errors. Add positive assertions for the expected masked shape, negative assertions for known source patterns, and integrity checks for relationships. These checks preserve referential integrity masking guarantees while catching privacy regressions.
Create db/verify.sql:
\set ON_ERROR_STOP on
DO $
BEGIN
IF EXISTS (SELECT 1 FROM users WHERE email !~ '^[a-z0-9-]+@preview\.invalid#39;) THEN
RAISE EXCEPTION 'unmasked or invalid email detected';
END IF;
IF EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL) THEN
RAISE EXCEPTION 'password hash detected';
END IF;
IF EXISTS (SELECT 1 FROM orders WHERE shipping_address <> 'REDACTED PREVIEW ADDRESS') THEN
RAISE EXCEPTION 'shipping address not redacted';
END IF;
IF EXISTS (SELECT 1 FROM support_tickets WHERE body <> 'REDACTED PREVIEW TICKET') THEN
RAISE EXCEPTION 'ticket body not redacted';
END IF;
IF EXISTS (
SELECT email FROM users GROUP BY email HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'masked emails are not unique';
END IF;
IF EXISTS (
SELECT 1 FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE u.id IS NULL
) THEN
RAISE EXCEPTION 'orphan order detected';
END IF;
END $;
SELECT 'mask verification passed' AS result;
Run the gate:
docker compose exec -T preview-db psql \
-U postgres -d app_preview -f - < db/verify.sql
Also scan schema drift. Generate a list from information_schema.columns and compare it with your classification policy. In a mature implementation, make the policy machine-readable and fail when a column is added without an action. This is how you detect PII in test databases before an unclassified field reaches a preview. Scan JSON, arrays, materialized views, audit logs, and object storage separately because a column-only check cannot see those copies.
Do not claim that pattern scanning proves anonymity. It catches regressions such as a real domain or phone format, but it cannot recognize every name or address. Combine PostgreSQL data anonymization checks with exhaustive classification and transforms that replace entire fields.
Verify the step: the command should print mask verification passed and return exit code 0. Temporarily set one email to alice@example.com; rerun the script and confirm it exits nonzero with unmasked or invalid email detected. Restore and remask afterward.
Step 6: Automate the CI Database Masking Pipeline
Create .github/workflows/mask-preview-data.yml. This safe demonstration uses the synthetic SQL fixture. Replace the fixture-loading step with private snapshot retrieval only on a self-hosted or appropriately isolated runner approved by your security team.
name: Mask preview data
on:
workflow_dispatch:
permissions:
contents: read
jobs:
mask:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: preview-ci-only
POSTGRES_DB: app_preview
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d app_preview"
--health-interval 2s --health-timeout 3s --health-retries 20
env:
DATABASE_URL: postgres://postgres:preview-ci-only@localhost:5432/app_preview
MASKING_KEY: ${{ secrets.PREVIEW_MASKING_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Load private restore area
run: psql "$DATABASE_URL" -f db/source.sql
- name: Mask before publication
run: node scripts/mask.mjs
- name: Verify privacy and integrity
run: psql "$DATABASE_URL" -f db/verify.sql
- name: Export masked artifact
run: pg_dump "$DATABASE_URL" -Fc > artifacts/masked.dump
- uses: actions/upload-artifact@v4
with:
name: masked-preview-database
path: artifacts/masked.dump
retention-days: 1
Store PREVIEW_MASKING_KEY as a secret with at least 32 random characters. Scope it to the masking environment, never print it, and rotate it independently from production credentials. Pin third-party actions to commit SHAs in a hardened production repository. The preview provisioning job should depend on this job and download only masked.dump, never the raw snapshot.
A stronger architecture streams the masked dump directly into the target database, so no reusable artifact exists. If an artifact is required, encrypt it, restrict readers, audit downloads, and set the shortest workable retention.
Verify the step: dispatch the workflow. Confirm the mask and verification steps pass before the upload step runs. Download the one-day artifact into a disposable database and rerun db/verify.sql. Confirm the workflow logs contain no source rows, connection passwords, or masking key.
Step 7: Connect the Preview Application and Enforce Cleanup
Only now create the application credential and network route. Grant the application the minimum permissions it needs. For most UI previews, read and normal application write permissions are enough; schema ownership and role creation are not. Disable outbound email, SMS, webhooks, payment calls, and analytics exports with environment-specific configuration. Masked addresses reduce risk, but side effects still need containment.
Use a deployment sequence with explicit gates:
restore privately -> mask -> verify -> create app credential -> deploy -> smoke test
|
+-> failure: destroy database
Run smoke tests that prove both usefulness and containment. Log in with a dedicated preview-only account, create an order, open a historical order, and confirm any notification is captured by a local sink. Never make authentication depend on a retained production password hash. Seed a known preview admin after masking instead.
Attach an expiration timestamp and pull request number to every database, volume, artifact, and secret. A scheduled reaper should delete resources past their deadline even if a webhook fails. The automatic stale preview environment cleanup guide shows the full label, TTL, and pull-request reconciliation pattern.
Verify the step: connect with the preview application credential and confirm expected screens work. Attempt a schema-changing command and confirm permission is denied. Close the pull request, run the cleanup workflow, and verify that the database, credential, artifact, DNS record, and storage volume no longer exist.
Troubleshooting
Problem: a unique constraint fails during masking -> Your token is too short or the transformation collapses distinct values. Increase the HMAC token length, include the complete original value, keep a unique namespace, and verify duplicates before committing. Do not append raw IDs if those IDs are sensitive.
Problem: foreign keys break after IDs are masked -> Prefer retaining meaningless internal surrogate IDs. If an identifier must change, build a mapping table inside the transaction and update parent and child tables consistently. Drop that mapping before publication.
Problem: the app sends messages to real users -> Mask destinations and disable all production integrations. Route email and SMS to local capture services, use provider sandbox modes, and deny outbound network access except to an allowlist.
Problem: free text still contains personal data -> Replace the whole field with a safe fixture. Regex-only redaction misses names, unusual phone formats, access tokens, and pasted documents. If text behavior matters, generate synthetic text with known test markers.
Problem: masking a large snapshot times out -> Process deterministic primary-key ranges, monitor lock duration, and run ANALYZE after bulk updates. Keep the target private throughout all batches. Consider subset extraction before masking if tests need only a representative slice.
Problem: repeated runs produce different users -> Confirm every job uses the intended masking key and the same normalization rules. Version the transform algorithm, but never commit the key. A key rotation deliberately changes all deterministic outputs.
Best Practices
- Prefer synthetic generation when production fidelity is not necessary. No masking method removes all re-identification risk.
- Deny the preview application access until verification passes. Network isolation is part of the algorithm.
- Keep the source snapshot encrypted, private, audited, and extremely short-lived.
- Transform full free-text fields instead of attempting clever partial cleanup.
- Preserve business distributions only when a test has a documented need for them.
- Test the masking code itself with fixtures containing every sensitive pattern you know.
- Treat logs, traces, backups, exports, search indexes, and object storage as part of the data set.
- Fail closed when classification is missing or a verification assertion cannot run.
- Use reserved domains such as
.invalidand non-routable integration endpoints. - Apply a TTL and ownership label to every preview resource.
Where To Go Next
Place this data gate inside a complete pull-request lifecycle. Start with the ephemeral test environments complete guide, then implement a preview environment for every pull request. Use the masked artifact only after that environment's private database exists.
For teams that do not require production-shaped history, use Testcontainers to seed ephemeral databases. Synthetic, version-controlled fixtures are easier to reason about and carry less privacy risk. Finally, implement automatic cleanup for stale preview environments so databases and masking artifacts cannot outlive their pull requests.
Interview Questions and Answers
Q: Why is hashing an email address not always safe masking?
A plain hash is deterministic and has no secret, so an attacker can hash a dictionary of likely emails and compare results. A keyed HMAC resists that lookup when the key remains secret. You should still use a separate key, a namespace, sufficient output length, and access controls.
Q: What is the difference between masking and encryption?
Encryption is reversible by anyone with the decryption key and is appropriate for protecting stored or transmitted source data. Masking replaces data so the preview system does not need the original value. A good pipeline encrypts the incoming snapshot and masks it before application access.
Q: How do you preserve referential integrity while masking?
Retain non-sensitive surrogate primary keys when possible. For sensitive natural keys, use the same deterministic transform everywhere or create a temporary mapping table and update all related tables in one controlled transaction. Verify orphan counts after the update.
Q: How do you prove a preview database is masked?
Use a layered gate: verify every schema field has a policy, assert expected masked formats, reject known sensitive patterns, check secrets are absent, and validate constraints and relationships. Then restore the exported result into a disposable database and run the same suite again.
Q: When should you avoid production data entirely?
Avoid it when synthetic fixtures can exercise the required behavior, when consent or regulation limits secondary use, or when the preview infrastructure cannot meet production-grade controls. Production realism is not automatically worth privacy and operational risk.
Q: Why must cleanup be part of data masking design?
Masked data can still expose business-sensitive patterns or be re-identified when combined with other information. Long-lived copies also drift outside normal governance. TTL-based teardown limits exposure and prevents forgotten shadow databases.
Common Mistakes
The most dangerous mistake is connecting the application before masking completes. A close second is scanning a few obvious columns and declaring the database anonymous. Inventory every data store, including denormalized copies and observability systems.
Teams also reuse production credentials, retain real password hashes, publish raw dumps as CI artifacts, or forget notification side effects. Keep identities separate, seed preview-only login accounts, and force integrations into sandbox or local-capture modes. Finally, do not confuse deterministic output with anonymity. Determinism improves test usefulness, but it also makes records linkable, so access controls and deletion still matter.
Conclusion
A reliable workflow to mask production data preview environments has four non-negotiable controls: isolate the restore, transform from an approved classification policy, verify privacy and integrity, and expose the result only after every gate passes. Deterministic HMAC transforms preserve useful equality while whole-field replacement handles dangerous unstructured content.
Run the tutorial with synthetic data first. Then adapt the policy to your schema, arrange a security review, and connect the verified masked database to a short-lived pull-request environment with automatic teardown.
Interview Questions and Answers
Why is a keyed HMAC better than a plain hash for deterministic masking?
A plain hash lets an attacker calculate hashes for likely values and find matches. HMAC requires a secret key, which makes offline dictionary matching much harder when the key is protected. Namespaces and adequate output length also reduce cross-field linkage and collisions.
What controls should run before a masked database is published?
Validate complete field classification, expected masked formats, absence of credentials and known sensitive patterns, uniqueness, constraints, and referential integrity. The preview application must have no route or credential until all checks pass. Any failed check should destroy or quarantine the target.
How would you mask unstructured support ticket text?
Replace the entire field with safe synthetic text unless the original prose is essential to a documented test. Regex redaction is unreliable because free text can contain names, tokens, addresses, and unusual identifiers. Generate controlled fixtures when search or formatting behavior must be tested.
How do you handle schema drift in a masking pipeline?
Keep the classification policy machine-readable and compare it with the live database catalog on every run. Fail closed when a table or column has no action. Include JSON fields, views, audit stores, object storage, and search indexes in separate inventories.
How do you maintain referential integrity during masking?
Keep non-sensitive surrogate keys unchanged where possible. Otherwise apply the same deterministic transformation to every copy of a natural key, or use a temporary source-to-target mapping inside a transaction. Run orphan and constraint checks before publication.
Why does a masked preview database still need a TTL?
Masking does not eliminate all re-identification or business confidentiality risk. A long-lived copy can also drift, accumulate new test data, and escape normal governance. A TTL plus scheduled reconciliation bounds exposure and removes abandoned resources.
Frequently Asked Questions
How do you mask production data for preview environments?
Restore the snapshot into an isolated database, apply approved transforms to every sensitive field, and run automated privacy and integrity checks. Give the preview application access only after those checks pass, then delete the database when the pull request closes.
Is hashed production data safe for a test environment?
A plain hash is often guessable with a dictionary and should not be treated as anonymous. Use a keyed HMAC for deterministic substitutions, keep the key outside the database, and combine masking with strict access controls and deletion.
Should preview environments use production data?
Prefer synthetic fixtures when they can reproduce the required behavior. Use masked production-derived data only when fidelity has a documented value and the restore, masking, access, audit, and cleanup controls have been reviewed.
How can data masking preserve database relationships?
Retain meaningless surrogate IDs, or transform sensitive natural keys deterministically everywhere they occur. If IDs must change, use a temporary mapping table and update every parent and child reference before verifying orphan counts.
How do you test that data masking worked?
Check that every schema field has a policy, assert allowed masked formats, deny known source patterns, confirm secrets and free text are removed, and validate uniqueness and foreign keys. Repeat the checks after restoring any exported masked artifact.
How long should masked preview data be retained?
Retain it only for the life of the preview environment and use the shortest practical artifact retention. Attach a TTL and pull request identifier, then use both close-event cleanup and a scheduled reaper to remove missed resources.
Related Guides
- Appium 3 Driver Version Management: A Practical Tutorial
- Building a synthetic test data generator (2026)
- Create a Preview Environment per Pull Request
- How to Build a data driven framework in TestNG (2026)
- How to Design a test data strategy (2026)
- Playwright Assert Realtime Notifications Examples: A Practical Tutorial