Edge cases · must-have checklist

The bugs juniors miss and seniors hunt for

Twelve recruiter-facing edge cases with one-line Playwright assertions. Use them as a smoke checklist before your next release — or as interview-grade talking points.

Junioredge / empty-state

Empty states

Every list/table must render a deliberate empty surface — not a blank screen — when filtered to zero rows.

1
await expect(page.getByText(/no users matched/i)).toBeVisible();
Junioredge / boundary-values

Boundary values

Min, min+1, max-1, max — the four amounts that catch off-by-one errors in caps, lengths, and quotas.

1
2
3
await page.getByLabel(/withdraw/i).fill('5000');
await page.getByRole('button', { name: /withdraw/i }).click();
await expect(page.getByText(/posted/i).first()).toBeVisible();
Junioredge / slow-api

Slow API

Skeletons, loaders and disabled CTAs must hold for a 3-second backend without losing user input.

1
2
3
4
await page.route('**/api/**', async (r) => {
  await new Promise((res) => setTimeout(res, 3000));
  await r.continue();
});
Junioredge / duplicate-clicks

Duplicate clicks

Idempotency keys + disabled-on-pending CTAs prevent double-charged orders and duplicate transactions.

1
2
3
4
5
await Promise.all([
  page.getByRole('button', { name: /place order/i }).click(),
  page.getByRole('button', { name: /place order/i }).click(),
]);
await expect(page.locator('[data-testid="order-row"]')).toHaveCount(1);
Junioredge / wrong-format

File upload wrong format

Server-side MIME check must reject a renamed .exe; client-side check is not a security boundary.

1
2
await page.getByTestId('upload-input').setInputFiles('fixtures/malware.png');
await expect(page.getByTestId('upload-error')).toContainText(/unsupported/i);
Senioredge / token-expiration

Token expiration

Expired JWTs must redirect to /login with a deep link, not crash the route.

1
2
3
await page.evaluate(() => localStorage.removeItem('vl-lab-auth'));
await page.goto('https://lab.hakdogan.com/wallet');
await expect(page).toHaveURL(/\/login\?next=%2Fwallet$/);
Senioredge / currency-precision

Invalid currency precision

Amounts with more than 2 decimal places must be rejected at the boundary, not silently rounded.

1
2
3
await page.getByLabel(/^deposit$/i).fill('250.123');
await page.getByRole('button', { name: /deposit funds/i }).click();
await expect(page.getByRole('alert')).toContainText(/two decimal/i);
Senioredge / modal-overlay

Modal overlay blocks click

An invisible overlay can swallow clicks; pointer-events + z-index must be tested explicitly.

1
2
await page.getByRole('button', { name: /open modal/i }).click();
await expect(page.locator('[data-testid="modal-overlay"]')).toHaveCSS('pointer-events', 'auto');
Senioredge / race-visible

Hidden vs. visible race

An element can be in the DOM but visibility:hidden — assertions must use toBeVisible(), not toBeAttached().

1
await expect(page.getByTestId('lazy-panel')).toBeVisible({ timeout: 5_000 });
Senioredge / pagination-reset

Pagination reset after filter

Applying a filter while on page 5 should not leave the user on an empty page 5 of the new dataset.

1
2
await page.getByLabel(/search users/i).fill('xx');
await expect(page.getByTestId('pager-current')).toHaveText('1');
Senioredge / rbac

RBAC unauthorized access

Direct navigation to /admin as a non-admin must reject — and the test must verify no admin payload was fetched.

1
2
3
4
5
6
const adminCalls: string[] = [];
page.on('request', (r) => {
  if (r.url().includes('/api/admin/')) adminCalls.push(r.url());
});
await page.goto('https://lab.hakdogan.com/admin');
expect(adminCalls).toHaveLength(0);
Senioredge / offline

Network offline recovery

Disabling the network mid-session must produce a recoverable banner, not stuck spinners.

1
2
3
4
await context.setOffline(true);
await page.getByRole('button', { name: /refresh/i }).click();
await expect(page.getByRole('alert')).toContainText(/offline/i);
await context.setOffline(false);