Letβs build a real End-to-End (E2E) testing pipeline like teams use in production using Playwright (recommended) and GitHub Actions. Iβll show you: E2E (End-to-End) testing means: βTest your app like a real user would use it.β Instead of testing functions, you test: π Weβll use Playwright (industry standard in 2025)
npm init playwright@latest
When prompted choose:
* JavaScript or TypeScript (TS recommended)
* Tests folder: `tests`
* GitHub Actions: YES
---
``` plaintext id="pw2"
my-app/
βββ tests/
β βββ example.spec.ts
βββ playwright.config.ts
βββ package.json
``` ts id="test1"
import { test, expect } from '@playwright/test';
test('user can login successfully', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/dashboard/);
});
---
``` bash id="run1"
npx playwright test
Open UI mode (very useful):
``` bash id="ui1"
npx playwright test --ui
---
Playwright automatically captures:
* screenshots
* videos
* traces
Enable in config:
``` ts id="cfg1"
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry'
}
.github/workflows/e2e.yml
``` yaml id="ci1"
name: E2E Tests (Playwright)
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
---
``` plaintext id="flow1"
Push code
β
GitHub Actions starts
β
Install dependencies
β
Install browsers (Chromium, Firefox, WebKit)
β
Run E2E tests
β
Pass β allow merge
Fail β block PR + show report
Enable report:
``` ts id="rep1"
reporter: [['html'], ['list']]
Then in CI:
``` yaml id="rep2"
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report
Instead of localhost:
``` ts id="prod1"
await page.goto('https://your-app.vercel.app/login');
π This turns it into **true production E2E testing**
---
---
## π’ UI navigation test
``` ts id="nav1"
test('navigate to dashboard', async ({ page }) => {
await page.goto('/');
await page.click('text=Dashboard');
await expect(page).toHaveURL(/dashboard/);
});
``` ts id="form1"
test('shows error for empty email', async ({ page }) => {
await page.goto('/login');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('Email is required');
});
---
## π΅ API + UI combined test
``` ts id="api1"
test('data loads after API call', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('.')).toBeHidden();
await expect(page.locator('.chart')).toBeVisible();
});
``` yaml id="bp1"
``` ts id="rt1"
retries: 2
---
### β Testing implementation instead of behavior
Bad:
``` ts id="bad1"
expect(component.state).toBe(true)
Good:
``` ts id="good1"
expect(page).toHaveText('Welcome')
---
### β No stable selectors
Use:
``` html id="sel1"
data-testid="login-button"
Then:
``` ts id="sel2"
page.getByTestId('login-button')
---
### β Running E2E without CI
Always run in GitHub Actions
---
``` plaintext id="final1"
Push / PR
β
CI (unit tests)
β
E2E tests (Playwright)
β
Build app
β
Deploy to staging/production
β
Report + screenshots stored in GitHub
You now have: