A Next.js application paired with a production-grade Playwright test suite — built as a working demonstration of authentication, session management, and role-based access control (RBAC) testing patterns.
The app is deliberately small; the tests are the point. The repository showcases how I structure E2E coverage for a real product: Page Object Model, authentication state reuse via storageState, tag-based test scoping, cross-browser execution, and CI integration.
| Area | Implementation |
|---|---|
| Page Object Model | LoginPage with readonly locators and intent-revealing methods (source) |
| Auth state reuse | Setup project authenticates once per role, persists session to disk, downstream tests skip the login flow (source) |
| Custom test fixtures | Type-safe extensions of test — loginPage, loggedInUser, loggedInAdmin — composed and lazily built per test (source) |
| RBAC / route guards | Verifies admin vs. user access boundaries, redirect rules, and unauthenticated access (source) |
| Session integrity | JWT shape validation, localStorage persistence, expiry handling, logout cleanup (source) |
| Accessibility (WCAG 2.0 / 2.1 / 2.2 AA) | Automated axe-core audits on every page — public, user-authenticated, admin-authenticated (source) |
| User-facing locators | getByRole, getByLabel, getByTestId — never CSS or XPath |
| Test tagging | @smoke, @functional, @security, @auth for scoped CI runs |
| Cross-browser | Chromium, Firefox, WebKit |
| CI | GitHub Actions, all browsers, retries on failure, HTML + JUnit + JSON reports |
| Code hygiene | TypeScript strict mode (noUnusedLocals, noUnusedParameters), ESLint, Knip dead-code detection |
# Install
npm install
npx playwright install --with-deps
# Run the app + full test suite
npm test
# Or run a scoped subset
npm run test:smoke # smoke checks only
npm run test:auth # everything tagged @auth
npm run test:security # security-focused tests
npm run test:functional # functional coverageThe dev server boots automatically (see webServer in playwright.config.ts).
| Script | What it does |
|---|---|
npm test |
Full suite, all browsers |
npm run test:chromium |
Chromium only |
npm run test:firefox |
Firefox only |
npm run test:webkit |
WebKit only |
npm run test:headed |
Chromium with visible browser |
npm run test:ui |
Playwright UI mode (live test runner) |
npm run test:debug |
Step-through debugger |
npm run test:smoke |
@smoke tagged tests |
npm run test:functional |
@functional tagged tests |
npm run test:non-functional |
@non-functional tagged tests |
npm run test:security |
@security tagged tests |
npm run test:auth |
@auth tagged tests |
npm run test:a11y |
@a11y accessibility audits (axe-core) |
npm run test:report |
Open the last HTML report |
Project-quality scripts:
| Script | What it does |
|---|---|
npm run lint |
ESLint over the codebase |
npm run typecheck |
TypeScript strict check, no emit |
npm run knip |
Detect unused files, exports, and dependencies |
A dedicated setup project runs once before the main test run. It logs in as each role, captures localStorage to disk (playwright/.auth/admin.json, user.json), and exits.
Downstream tests declare which session they need:
test.use({ storageState: 'playwright/.auth/admin.json' });That test starts already-authenticated — no login form, no redundant API calls. Force a logged-out state when needed:
test.use({ storageState: { cookies: [], origins: [] } });This is the difference between a 30-test suite that takes 90 seconds and one that takes 6 minutes.
LoginPage exposes the intent of a page, not its DOM. Tests read like product specs:
await loginPage.loginAsAdmin();
await expect(page).toHaveURL(/\/dashboard\/admin$/);Locators live as readonly properties on the page object — defined once, reused everywhere, refactor-safe.
Every test carries one or more tags:
test('admin can sign in', { tag: ['@smoke', '@functional', '@auth'] }, async () => { ... });The npm run test:* scripts grep tags so CI can run a 30-second smoke check on every PR and a full regression on merge to main.
.github/workflows/playwright.yml runs the full suite across all browsers on every push. Retries enabled (2× on CI), traces collected on first retry, HTML report uploaded as an artifact for download.
The app ships with two seeded accounts for testing:
| Role | Password | Lands on | |
|---|---|---|---|
| Admin | admin@test.com |
Admin123! |
/dashboard/admin |
| User | user@test.com |
User123! |
/dashboard/profile |
Authentication is mocked with a fake JWT — the focus is on the test patterns, not on building a real auth backend.
App: Next.js 16 · React 19 · TypeScript 5 · Tailwind CSS 4
Tests: Playwright 1.59 · Page Object Model · storageState setup projects
Quality: ESLint · TypeScript strict · Knip · GitHub Actions
app/ Next.js app (pages, layouts, components)
components/ Shared UI
lib/auth.tsx Auth provider + fake-JWT mock
tests/
auth.setup.ts One-time login per role → storageState files
auth-authorization.spec.ts RBAC, route guards, redirects
auth-session.spec.ts Session persistence, JWT shape, expiry
login.spec.ts Login form happy path + credential rejection
pages/auth/LoginPage.ts Page Object for the login page
playwright.config.ts Test runner config (projects, reporters, webServer)
.github/workflows/ CI pipeline
knip.json Dead-code detection config
CI runs npm ci on Linux, but local development happens on macOS / Windows. Some
native packages (sharp, axe-core) ship platform-specific optional binaries —
when npm install runs on macOS, it can omit the linux-x64 variants from
package-lock.json, breaking CI with:
npm error Missing: @emnapi/runtime — Missing: @emnapi/core
After adding, removing, or updating any dependency, run:
npm install # update node_modules for your local platform
npm run lock:refresh # rewrite the lock with all platform variants includedThen commit package.json and package-lock.json together. CI will install
deterministically with npm ci and the cross-platform lock keeps the runner happy.
MIT