End-to-end tests are the ultimate confidence check โ they drive a real browser through a real application flow, from clicking buttons to reading results, exactly as a user would. Playwright is the modern E2E testing tool that supports Chromium, Firefox, and WebKit, runs tests in parallel, records videos on failure, and has a powerful auto-waiting API that makes tests less flaky than older tools. In this lesson you will install Playwright, write E2E tests for the MERN Blog’s register, login, and create post flows, and understand how to integrate them into a CI pipeline so every push is verified.
Installation and Configuration
cd mern-blog # monorepo root
npm install --save-dev @playwright/test
npx playwright install # download browser binaries (Chromium, Firefox, WebKit)
// playwright.config.js โ at monorepo root
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e', // test files in /e2e folder
timeout: 30 * 1000, // 30s per test
retries: process.env.CI ? 2 : 0, // retry on CI, not locally
reporter: 'html', // generate HTML report
use: {
baseURL: 'http://localhost:5173', // React dev server
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry',
},
// Start dev servers before running tests
webServer: [
{
command: 'npm run dev', // start React (in client/)
cwd: './client',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
{
command: 'node index.js', // start Express (in server/)
cwd: './server',
url: 'http://localhost:5000/api/health',
reuseExistingServer: !process.env.CI,
env: { NODE_ENV: 'test', MONGODB_URI: 'mongodb://localhost:27017/blogdb_test' },
},
],
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});
webServer option starts your React and Express servers automatically before tests run and shuts them down after. In CI, always use a fresh test database โ set MONGODB_URI to a test database in the webServer env. Use Playwright’s beforeEach hooks to seed required data (like a test user) and afterEach to clean up, either via API calls or direct database access.page.locator() uses accessible queries by default and auto-waits for elements to be visible and stable before interacting with them. You rarely need explicit page.waitForSelector() calls โ Playwright handles timing automatically. Use getByRole(), getByLabel(), and getByText() as your primary locators, mirroring what React Testing Library encourages for unit tests.E2E Tests โ Register and Login Flow
// e2e/auth.spec.js
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can register a new account', async ({ page }) => {
await page.goto('/register');
// Fill in the registration form
await page.getByLabel('Name').fill('E2E Test User');
await page.getByLabel('Email').fill(`e2e-${Date.now()}@example.com`);
await page.getByLabel('Password').fill('TestPass@1234');
await page.getByLabel('Confirm Password').fill('TestPass@1234');
// Submit the form
await page.getByRole('button', { name: /create account/i }).click();
// After registration โ should land on dashboard
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText(/E2E Test User/i)).toBeVisible();
});
test('user can log in with valid credentials', async ({ page }) => {
// Seed a test user via API before the test
await page.request.post('/api/auth/register', {
data: { name: 'Login Test', email: 'login-e2e@example.com', password: 'TestPass@1234' },
});
await page.goto('/login');
await page.getByLabel('Email').fill('login-e2e@example.com');
await page.getByLabel('Password').fill('TestPass@1234');
await page.getByRole('button', { name: /log in/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('nobody@example.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: /log in/i }).click();
await expect(page.getByText(/invalid email or password/i)).toBeVisible();
await expect(page).toHaveURL(/\/login/); // stays on login page
});
test('protected route redirects to login when not authenticated', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
});
E2E Tests โ Create Post Flow
// e2e/posts.spec.js
import { test, expect } from '@playwright/test';
// Reusable login helper
const login = async (page, email = 'post-e2e@example.com', password = 'TestPass@1234') => {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /log in/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
};
test.describe('Post management', () => {
test.beforeEach(async ({ page }) => {
// Seed test user before each test
await page.request.post('/api/auth/register', {
data: { name: 'Post Author', email: 'post-e2e@example.com', password: 'TestPass@1234' },
}).catch(() => {}); // ignore duplicate if already created
await login(page);
});
test('authenticated user can create a post', async ({ page }) => {
await page.goto('/posts/new');
await page.getByLabel('Title').fill('My E2E Test Post');
await page.getByLabel('Content').fill(
'This is the body of my test post with enough content.'
);
await page.getByLabel('Publish immediately').check();
await page.getByRole('button', { name: /publish post/i }).click();
// Should redirect to the new post's detail page
await expect(page).toHaveURL(/\/posts\//);
await expect(page.getByRole('heading', { name: 'My E2E Test Post' })).toBeVisible();
});
test('displays validation error for missing title', async ({ page }) => {
await page.goto('/posts/new');
await page.getByLabel('Content').fill('Some content');
await page.getByRole('button', { name: /publish/i }).click();
await expect(page.getByText(/title is required/i)).toBeVisible();
await expect(page).toHaveURL(/\/posts\/new/); // stays on create page
});
});
CI Integration โ GitHub Actions
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
server-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: cd server && npm ci && npm test
e2e-tests:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:7
ports: ['27017:27017']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: cd server && npm ci
- run: cd client && npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
MONGODB_URI: mongodb://localhost:27017/blogdb_test
JWT_SECRET: test-secret-for-ci
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Common Mistakes
Mistake 1 โ Writing too many E2E tests
โ Wrong โ testing every edge case in E2E:
// E2E tests for every validation error, empty state, and error message
// โ Slow test suite (minutes per run), fragile, hard to maintain
โ Correct โ E2E tests cover the critical happy paths; integration tests cover edge cases.
Mistake 2 โ Hardcoding test data that causes conflicts
โ Wrong โ same email used in multiple tests:
await page.getByLabel('Email').fill('test@example.com'); // used in 5 tests
// Second test fails with "email already registered" from the first test's data
โ Correct โ use unique emails per test:
const email = `test-${Date.now()}@example.com`; // unique per test run โ
Mistake 3 โ Not taking advantage of Playwright’s auto-waiting
โ Wrong โ adding explicit waits that make tests slow:
await page.waitForTimeout(2000); // arbitrary 2-second sleep
await page.click('button');
await page.waitForTimeout(1000); // waiting for navigation
โ Correct โ use Playwright’s built-in wait assertions:
await page.getByRole('button').click(); // auto-waits for button to be ready
await expect(page).toHaveURL(/\/dashboard/); // auto-waits for navigation โ
Quick Reference
| Task | Playwright Code |
|---|---|
| Navigate to page | await page.goto('/login') |
| Fill input by label | await page.getByLabel('Email').fill('a@b.com') |
| Click button by name | await page.getByRole('button', { name: /submit/i }).click() |
| Assert URL | await expect(page).toHaveURL(/\/dashboard/) |
| Assert text visible | await expect(page.getByText('Welcome')).toBeVisible() |
| Make API request | await page.request.post('/api/...', { data: {...} }) |
| Run tests | npx playwright test |
| Run with UI | npx playwright test --ui |
| Debug mode | npx playwright test --debug |