I'd used Jest for years. Great for unit tests and React components, but it doesn't run a real browser. The moment I hit a Safari-specific bug that never appeared in Chrome, I realized I needed something different.
That's the gap Playwright is built for. It drives an actual browser - Chrome, Firefox, WebKit — and runs the same test in each one. The bug that only shows up in Safari shows up in the test run too, before it ships.
The project.
Secure Share is a file-sharing app I built — small enough to put together quickly, but complex enough to cover authentication, uploads, downloads, protected routes, and share-link generation.
User (Browser)
↓
React.js — Dashboard UI
↓
Node.js API — JWT Auth
↓
MongoDB — Users & Files
↓
Storage — Documents
Secure sharing workflow:
Login → Upload File → Generate Link → Set Expiry → Share URL → Protected Download
Every one of those six steps used to mean a manual click-through across two or three browsers before a release. That's what got automated.
Testing across browsers. Playwright defines browsers as projects in config. One test file, run once per browser listed:
// playwright.config.js
export default defineConfig({
testDir: './tests',
reporter: 'html',
use: { baseURL: 'http://localhost:5173' },
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
npx playwright test # all three browsers
npx playwright test --project=webkit # just Safari's engine
What this caught: a Safari click-handler bug. Chrome passed, WebKit failed on the same spec, and the report named the exact line. That's the kind of bug that lives entirely in the browser layer — invisible to anything running in Node.
Validating login.
test('user can login', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'test@gmail.com');
await page.fill('#password', 'Password123');
await page.click('button[type="submit"]');
const token = await page.evaluate(() => localStorage.getItem('token'));
expect(token).not.toBeNull();
});
Not just "did the page redirect" — checking the JWT actually landed in storage. A redirect can fire before the token write finishes; checking the token is what catches that.
Validating the rest, briefly.
// wrong password → correct error
await page.fill('#email', 'wrong@test.com');
await page.fill('#password', 'WrongPassword');
await page.click('button[type="submit"]');
await expect(page.getByText('Invalid email or password')).toBeVisible();
// upload → confirmation
await page.locator('input[type="file"]').setInputFiles('tests/files/sample.pdf');
await expect(page.getByText(/uploaded successfully/i)).toBeVisible({ timeout: 20000 });
// share link → copy confirmation
await page.click('text=Generate URL');
await page.click('text=Generate & Copy Link');
await expect(page.getByText(/link copied/i)).toBeVisible();
// download → real file, not a broken stream
const downloadPromise = page.waitForEvent('download');
await page.click('text=Download');
const download = await downloadPromise;
expect(download.suggestedFilename()).toBeTruthy();
Each test is small on purpose — one user action, one thing it should be true after. Stacked together, they cover the same path the manual checklist used to.
The HTML report.
reporter: 'html' generates a report after every run that shows pass/fail per browser, per test, with a trace of exactly where it broke. It's caught more than one selector that Safari resolves differently — something that would never show up in a Jest test.
One-time setup, then it's just a command.
git clone <repo>
cd e2e
npm install
npx playwright install
Before Playwright: 15–20 minutes of clicking through the app before every release. After Playwright: one command (npx playwright test) and a report in under a minute.
The report tells you — before a user does — whether login, upload, sharing, and download still work everywhere they need to.
I still use Jest for unit tests. Playwright didn't replace it. It solved a different problem: validating that the entire application works the way a real user expects. For this project, that meant fewer repetitive checks, faster releases, and more confidence that a change in one area didn't quietly break another.
Code and live demo:
Top comments (2)
Great...
How do you handle end-to-end tests with this setup, or is that still a manual process? I've struggled to integrate them with Jest. Would love to hear your thoughts on this.