Avoid logging in on every test by persisting authentication state with Playwright's storageState, use API-based login for speed, and test multiple user roles with separate browser contexts.
Logging in through the UI on every test is the most common performance bottleneck in E2E suites. A login flow that takes 3 seconds repeated across 50 tests adds 2.5 minutes to your test run — just for authentication setup. Beyond speed, UI-based login in every test also means every test is implicitly testing the login page, making failures harder to diagnose.
Playwright's storageState mechanism solves this elegantly: log in once in global setup, save the browser's cookies and localStorage to a file, and restore that state in tests that need an authenticated user. This lesson builds a professional authentication setup that supports multiple user roles and keeps tests fast.
1// e2e/global-setup.ts — runs once before all tests
2import { chromium, type FullConfig } from '@playwright/test';
3
4async function globalSetup(config: FullConfig) {
5 const { baseURL } = config.projects[0].use;
6 const browser = await chromium.launch();
7 const context = await browser.newContext();
8 const page = await context.newPage();
9
10 // Log in as student via UI
11 await page.goto(`${baseURL}/login`);
12 await page.getByLabel('Email').fill('[email protected]');
13 await page.getByLabel('Password').fill('TestPass1!');
14 await page.getByRole('button', { name: 'Sign in' }).click();
15 await page.waitForURL('**/dashboard');
16
17 // Save cookies and localStorage to file
18 await context.storageState({ path: 'e2e/.auth/student.json' });
19
20 // Log in as instructor
21 await page.goto(`${baseURL}/login`);
22 await page.getByLabel('Email').fill('[email protected]');
23 await page.getByLabel('Password').fill('InstructorPass1!');
24 await page.getByRole('button', { name: 'Sign in' }).click();
25 await page.waitForURL('**/dashboard');
26 await context.storageState({ path: 'e2e/.auth/instructor.json' });
27
28 // Log in as admin
29 await page.goto(`${baseURL}/login`);
30 await page.getByLabel('Email').fill('[email protected]');
31 await page.getByLabel('Password').fill('AdminPass1!');
32 await page.getByRole('button', { name: 'Sign in' }).click();
33 await page.waitForURL('**/admin');
34 await context.storageState({ path: 'e2e/.auth/admin.json' });
35
36 await browser.close();
37}
38
39export default globalSetup;
40
41// .gitignore — never commit auth state files
42// e2e/.auth/
43
44// playwright.config.ts
45export default defineConfig({
46 globalSetup: './e2e/global-setup.ts',
47 // ...
48});UI login through the browser is slow (3-5s per role). API login via direct HTTP is 10x faster (< 300ms).
1// e2e/global-setup.ts — fast API-based authentication
2import { chromium, request } from '@playwright/test';
3
4async function loginViaApi(
5 baseURL: string,
6 email: string,
7 password: string,
8 outputPath: string
9): Promise<void> {
10 // Create an API request context
11 const apiContext = await request.newContext({ baseURL });
12
13 const response = await apiContext.post('/api/auth/login', {
14 data: { email, password },
15 });
16
17 if (!response.ok()) {
18 throw new Error(`Login failed for ${email}: ${response.status()}`);
19 }
20
21 const { accessToken } = await response.json();
22
23 // Create browser context, set the token as a cookie, and save state
24 const browser = await chromium.launch();
25 const context = await browser.newContext({ baseURL });
26 const page = await context.newPage();
27
28 // Set the auth cookie that your app reads on page load
29 await context.addCookies([{
30 name: 'accessToken',
31 value: accessToken,
32 domain: new URL(baseURL).hostname,
33 path: '/',
34 httpOnly: true,
35 secure: baseURL.startsWith('https'),
36 }]);
37
38 // Navigate to trigger session validation
39 await page.goto('/dashboard');
40 await page.waitForURL('**/dashboard');
41
42 await context.storageState({ path: outputPath });
43 await browser.close();
44 await apiContext.dispose();
45}
46
47export default async function globalSetup(config: FullConfig) {
48 const baseURL = config.projects[0].use.baseURL ?? 'http://localhost:3000';
49 await Promise.all([
50 loginViaApi(baseURL, '[email protected]', 'TestPass1!', 'e2e/.auth/student.json'),
51 loginViaApi(baseURL, '[email protected]', 'InstructorPass1!', 'e2e/.auth/instructor.json'),
52 loginViaApi(baseURL, '[email protected]', 'AdminPass1!', 'e2e/.auth/admin.json'),
53 ]);
54 // All three logins run in parallel — total time: ~300ms instead of ~9s
55}Use Playwright projects or fixtures to apply storageState per test file or per test.
1// playwright.config.ts — project-based auth
2export default defineConfig({
3 projects: [
4 // Setup project — runs globalSetup scripts
5 { name: 'setup', testMatch: '**/global-setup.ts' },
6
7 // Student tests — authenticated as student
8 {
9 name: 'student-tests',
10 use: { storageState: 'e2e/.auth/student.json' },
11 dependencies: ['setup'],
12 testMatch: '**/student/**/*.spec.ts',
13 },
14
15 // Instructor tests
16 {
17 name: 'instructor-tests',
18 use: { storageState: 'e2e/.auth/instructor.json' },
19 dependencies: ['setup'],
20 testMatch: '**/instructor/**/*.spec.ts',
21 },
22
23 // Admin tests
24 {
25 name: 'admin-tests',
26 use: { storageState: 'e2e/.auth/admin.json' },
27 dependencies: ['setup'],
28 testMatch: '**/admin/**/*.spec.ts',
29 },
30
31 // Public tests — no auth needed
32 {
33 name: 'public-tests',
34 testMatch: '**/public/**/*.spec.ts',
35 },
36 ],
37});
38
39// OR: fixture-based approach for mixed auth in one file
40// e2e/auth-fixtures.ts
41export const test = base.extend<{
42 studentPage: Page;
43 instructorPage: Page;
44}>({
45 studentPage: async ({ browser }, use) => {
46 const context = await browser.newContext({
47 storageState: 'e2e/.auth/student.json',
48 });
49 const page = await context.newPage();
50 await use(page);
51 await context.close();
52 },
53
54 instructorPage: async ({ browser }, use) => {
55 const context = await browser.newContext({
56 storageState: 'e2e/.auth/instructor.json',
57 });
58 const page = await context.newPage();
59 await use(page);
60 await context.close();
61 },
62});Test interactions between two users by running two browser contexts simultaneously.
1test('instructor publishes course, student sees it immediately', async ({ browser }) => {
2 // Instructor context
3 const instructorContext = await browser.newContext({
4 storageState: 'e2e/.auth/instructor.json',
5 });
6 const instructorPage = await instructorContext.newPage();
7
8 // Student context
9 const studentContext = await browser.newContext({
10 storageState: 'e2e/.auth/student.json',
11 });
12 const studentPage = await studentContext.newPage();
13
14 // Instructor publishes a draft course
15 await instructorPage.goto('/instructor/courses/draft-001');
16 await instructorPage.getByRole('button', { name: 'Publish' }).click();
17 await expect(instructorPage.getByRole('status')).toContainText('Published');
18
19 // Student immediately sees the newly published course
20 await studentPage.goto('/courses');
21 await studentPage.reload(); // refresh to pick up new course
22 await expect(studentPage.getByText('Draft Course Title')).toBeVisible();
23
24 await instructorContext.close();
25 await studentContext.close();
26});
27
28// Test access control: admin action affects student view
29test('admin bans user, banned user cannot log in', async ({ browser }) => {
30 const adminCtx = await browser.newContext({ storageState: 'e2e/.auth/admin.json' });
31 const adminPage = await adminCtx.newPage();
32
33 await adminPage.goto('/admin/users/banned-user-001');
34 await adminPage.getByRole('button', { name: 'Ban user' }).click();
35 await expect(adminPage.getByRole('status')).toContainText('User banned');
36
37 // Try to log in as the banned user
38 const bannedCtx = await browser.newContext();
39 const bannedPage = await bannedCtx.newPage();
40 await bannedPage.goto('/login');
41 await bannedPage.getByLabel('Email').fill('[email protected]');
42 await bannedPage.getByLabel('Password').fill('BannedPass1!');
43 await bannedPage.getByRole('button', { name: 'Sign in' }).click();
44 await expect(bannedPage.getByRole('alert')).toContainText('account has been suspended');
45
46 await adminCtx.close();
47 await bannedCtx.close();
48});E2E suites that run for many minutes can encounter expired tokens. Handle gracefully.
1// e2e/fixtures.ts — fixture that re-authenticates when session expires
2export const test = base.extend<{ authedPage: Page }>({
3 authedPage: async ({ browser }, use) => {
4 const context = await browser.newContext({
5 storageState: 'e2e/.auth/student.json',
6 });
7 const page = await context.newPage();
8
9 // Intercept 401 responses and re-authenticate
10 await context.route('**/api/**', async (route) => {
11 const response = await route.fetch();
12 if (response.status() === 401) {
13 // Re-login and retry the request
14 const apiCtx = await request.newContext();
15 const loginRes = await apiCtx.post('/api/auth/login', {
16 data: { email: '[email protected]', password: 'TestPass1!' },
17 });
18 const { accessToken } = await loginRes.json();
19 await context.addCookies([{
20 name: 'accessToken', value: accessToken,
21 domain: 'localhost', path: '/',
22 }]);
23 await route.continue();
24 } else {
25 await route.fulfill({ response });
26 }
27 });
28
29 await use(page);
30 await context.close();
31 },
32});
33
34// Simpler approach: reduce token expiry in test environment
35// .env.test:
36// JWT_EXPIRY=24h (long enough for a full test run)
37// REFRESH_TOKEN_EXPIRY=7dE2E tests for student, instructor, and admin role-specific features using pre-authenticated storageState files and parallel context setup.
1// e2e/global-setup.ts
2import { chromium, request, type FullConfig } from '@playwright/test';
3import { mkdir } from 'fs/promises';
4
5async function setupAuth(baseURL: string, email: string, password: string, path: string) {
6 const api = await request.newContext({ baseURL });
7 const res = await api.post('/api/auth/login', { data: { email, password } });
8 if (!res.ok()) throw new Error(`Auth failed for ${email}`);
9 const { accessToken } = await res.json();
10 await api.dispose();
11
12 const browser = await chromium.launch();
13 const ctx = await browser.newContext({ baseURL });
14 await ctx.addCookies([{ name: 'accessToken', value: accessToken, domain: new URL(baseURL).hostname, path: '/' }]);
15 const page = await ctx.newPage();
16 await page.goto('/dashboard');
17 await ctx.storageState({ path });
18 await browser.close();
19}
20
21export default async function globalSetup(config: FullConfig) {
22 const baseURL = config.projects[0].use.baseURL ?? 'http://localhost:3000';
23 await mkdir('e2e/.auth', { recursive: true });
24 await Promise.all([
25 setupAuth(baseURL, '[email protected]', 'TestPass1!', 'e2e/.auth/student.json'),
26 setupAuth(baseURL, '[email protected]', 'InstructorPass1!', 'e2e/.auth/instructor.json'),
27 setupAuth(baseURL, '[email protected]', 'AdminPass1!', 'e2e/.auth/admin.json'),
28 ]);
29}
30
31// e2e/rbac.spec.ts
32import { test, expect, type Browser } from '@playwright/test';
33
34async function pageAs(browser: Browser, role: 'student' | 'instructor' | 'admin') {
35 const ctx = await browser.newContext({ storageState: `e2e/.auth/${role}.json` });
36 return { page: await ctx.newPage(), ctx };
37}
38
39test.describe('Role-Based Access Control', () => {
40 test('student cannot access instructor dashboard', async ({ browser }) => {
41 const { page, ctx } = await pageAs(browser, 'student');
42 await page.goto('/instructor/dashboard');
43 await expect(page).toHaveURL(/\/login|\//); // redirected away
44 await ctx.close();
45 });
46
47 test('instructor can access instructor dashboard', async ({ browser }) => {
48 const { page, ctx } = await pageAs(browser, 'instructor');
49 await page.goto('/instructor/dashboard');
50 await expect(page).toHaveURL('/instructor/dashboard');
51 await expect(page.getByRole('heading', { name: /instructor/i })).toBeVisible();
52 await ctx.close();
53 });
54
55 test('student cannot access admin panel', async ({ browser }) => {
56 const { page, ctx } = await pageAs(browser, 'student');
57 await page.goto('/admin');
58 await expect(page).not.toHaveURL('/admin');
59 await ctx.close();
60 });
61
62 test('admin can access all admin sections', async ({ browser }) => {
63 const { page, ctx } = await pageAs(browser, 'admin');
64 await page.goto('/admin');
65 await expect(page).toHaveURL('/admin');
66 await expect(page.getByRole('link', { name: 'Users' })).toBeVisible();
67 await expect(page.getByRole('link', { name: 'Courses' })).toBeVisible();
68 await ctx.close();
69 });
70
71 test('student sees personalised dashboard content', async ({ browser }) => {
72 const { page, ctx } = await pageAs(browser, 'student');
73 await page.goto('/dashboard');
74 await expect(page.getByRole('region', { name: 'My Courses' })).toBeVisible();
75 await expect(page.getByRole('region', { name: 'Instructor Panel' })).not.toBeVisible();
76 await ctx.close();
77 });
78});globalSetup runs three API logins in parallel (Promise.all) — total setup time under 1 second instead of 15 seconds for three sequential UI logins. The pageAs helper creates a fresh browser context with the right storageState for each role, ensuring no cookie bleed between contexts. The RBAC tests verify redirects away from forbidden routes, which is the critical security behaviour to verify in E2E tests rather than unit-testing middleware.
The next lesson covers network interception in Playwright: mocking API responses, intercepting requests to assert on what the browser sends, and testing offline and slow network conditions.
Your progress is saved in this browser, on this device only.