Portfolio E2E Testing
E2E testing skill for Pawel Lipowczan portfolio project using Playwright.
Project Testing Context
Before writing tests, familiarize yourself with:
- •
docs/portfolio/testing/README.md- Full test documentation - •
docs/portfolio/testing/TESTING_QUICKSTART.md- Quick start guide - •
tests/utils/test-helpers.js- Helper functions - •
playwright.config.js- Test configuration
Stack: React 19 + Vite 7 + Tailwind CSS 3 + Framer Motion 12 + React Router 7
Test Framework: Playwright 1.56.1 (Chromium, Firefox, WebKit + Mobile viewports)
Workflow
Decision Tree
User request → What type? ├── "Create tests for [feature]" → New Test Workflow ├── "Test is failing/flaky" → Debug Workflow ├── "Add more test coverage" → Extend Coverage Workflow ├── "Run tests" → Execute & Interpret Workflow └── "Verify tests for PR" → Verification Workflow
New Test Workflow
- •Identify test category (navigation, form, blog, SEO, accessibility, responsiveness)
- •Select template from Test Writing Templates below
- •Use existing selectors from
references/test-patterns.md - •Use helper functions from
tests/utils/test-helpers.js - •Run
npm run test:headedto verify
Debug Workflow
- •Run
npm run test:debugto step through - •Check
references/debugging-guide.mdfor common issues - •Look for timing issues (add explicit waits)
- •Check viewport settings (mobile tests)
- •Verify selectors are correct
Extend Coverage Workflow
- •Check existing tests (home.spec.js, blog.spec.js, contact-form.spec.js)
- •Review checklists below for gaps
- •Add tests following existing patterns
- •Run full suite:
npm test
Test Commands
npm test # Run all tests (3 browsers) npm run test:headed # Visible browser for debugging npm run test:ui # Interactive Playwright UI npm run test:debug # Step-through debugging npm run test:chrome # Chromium only (faster) npm run test:mobile # Mobile viewports only npm run test:report # View HTML report
Test Writing Templates
Navigation Tests
import { test, expect } from "@playwright/test";
test.describe('Navigation - [Feature]', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000');
});
test('desktop menu navigates to [section]', async ({ page }) => {
await page.click('nav >> text=[Menu Item]');
await page.waitForSelector('#[section-id]', { state: 'visible' });
await expect(page.locator('#[section-id]')).toBeInViewport();
});
test('mobile menu works', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.reload(); // Viewport must be set before navigation
await page.click('[aria-label="Toggle menu"]');
await expect(page.locator('.mobile-menu')).toBeVisible();
await page.click('nav >> text=[Menu Item]');
await expect(page.locator('.mobile-menu')).not.toBeVisible();
});
test('smooth scroll works', async ({ page }) => {
await page.click('nav >> text=Kontakt');
await page.waitForTimeout(500); // Wait for scroll animation
await expect(page.locator('#contact')).toBeInViewport();
});
test('mobile menu closes on Escape', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.reload();
await page.click('[aria-label="Toggle menu"]');
await expect(page.locator('.mobile-menu')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.mobile-menu')).not.toBeVisible();
});
});
Form Validation Tests
import { test, expect } from "@playwright/test";
test.describe('Contact Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000/#contact');
await page.waitForSelector('form#contact-form', { state: 'visible' });
});
test('shows error for empty required fields', async ({ page }) => {
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
});
test('shows error for invalid email', async ({ page }) => {
await page.fill('input[name="name"]', 'Test User');
await page.fill('input[name="email"]', 'invalid-email');
await page.fill('textarea[name="message"]', 'Test message');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toContainText('email');
});
test('form has accessible labels', async ({ page }) => {
const nameInput = page.locator('input[name="name"]');
const label = await nameInput.getAttribute('aria-label') ||
await page.locator(`label[for="${await nameInput.getAttribute('id')}"]`).textContent();
expect(label).toBeTruthy();
});
test('tab navigation through fields works', async ({ page }) => {
await page.click('input[name="name"]');
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => document.activeElement?.name);
expect(focusedElement).toBe('email');
});
});
Blog Tests
import { test, expect } from "@playwright/test";
test.describe('Blog', () => {
test('blog listing shows posts', async ({ page }) => {
await page.goto('http://localhost:3000/blog');
await expect(page.locator('.blog-post-card').first()).toBeVisible();
});
test('post card shows required info', async ({ page }) => {
await page.goto('http://localhost:3000/blog');
const card = page.locator('.blog-post-card').first();
await expect(card.locator('img')).toBeVisible(); // Image
await expect(card.locator('h2, h3')).toBeVisible(); // Title
await expect(card.locator('.date, time')).toBeVisible(); // Date
});
test('post page renders markdown content', async ({ page }) => {
await page.goto('http://localhost:3000/blog/[slug]');
await expect(page.locator('article')).toBeVisible();
await expect(page.locator('article h1')).toBeVisible();
await expect(page.locator('article .prose, article p')).toBeVisible();
});
test('post page shows frontmatter data', async ({ page }) => {
await page.goto('http://localhost:3000/blog/[slug]');
await expect(page.locator('.reading-time, [data-reading-time]')).toBeVisible();
await expect(page.locator('.tags, [data-tags]')).toBeVisible();
});
test('back to blog navigation works', async ({ page }) => {
await page.goto('http://localhost:3000/blog/[slug]');
await page.click('text=Wróć, text=Blog, a[href="/blog"]');
await expect(page).toHaveURL(/\/blog\/?$/);
});
});
SEO Tests
import { test, expect } from "@playwright/test";
test.describe('SEO - [Page Name]', () => {
test('has unique title', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
await page.waitForFunction(() => document.title !== 'Loading...');
const title = await page.title();
expect(title).toContain('[Expected keyword]');
expect(title.length).toBeGreaterThan(10);
expect(title.length).toBeLessThan(70);
});
test('has meta description', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const description = await page.getAttribute('meta[name="description"]', 'content');
expect(description).toBeTruthy();
expect(description.length).toBeGreaterThan(50);
expect(description.length).toBeLessThan(160);
});
test('has OG tags', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const ogTitle = await page.getAttribute('meta[property="og:title"]', 'content');
const ogDescription = await page.getAttribute('meta[property="og:description"]', 'content');
const ogImage = await page.getAttribute('meta[property="og:image"]', 'content');
const ogUrl = await page.getAttribute('meta[property="og:url"]', 'content');
expect(ogTitle).toBeTruthy();
expect(ogDescription).toBeTruthy();
expect(ogImage).toMatch(/\.(png|jpg|jpeg|webp)$/i);
expect(ogUrl).toMatch(/^https?:\/\//);
});
test('has JSON-LD structured data', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const jsonLd = await page.$eval(
'script[type="application/ld+json"]',
el => JSON.parse(el.textContent)
);
expect(jsonLd['@type']).toBeTruthy();
});
test('has canonical URL', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const canonical = await page.getAttribute('link[rel="canonical"]', 'href');
expect(canonical).toMatch(/^https?:\/\//);
});
});
Accessibility Tests
import { test, expect } from "@playwright/test";
test.describe('Accessibility - [Component]', () => {
test('keyboard navigation works', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => document.activeElement?.tagName);
expect(['A', 'BUTTON', 'INPUT']).toContain(focused);
});
test('focus indicators are visible', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
const outline = await focusedElement.evaluate(el =>
getComputedStyle(el).outline || getComputedStyle(el).boxShadow
);
expect(outline).not.toBe('none');
});
test('images have alt text', async ({ page }) => {
await page.goto('http://localhost:3000');
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const alt = await images.nth(i).getAttribute('alt');
expect(alt).toBeTruthy();
}
});
test('only one H1 per page', async ({ page }) => {
await page.goto('http://localhost:3000/[path]');
const h1Count = await page.locator('h1').count();
expect(h1Count).toBe(1);
});
test('heading hierarchy is correct', async ({ page }) => {
await page.goto('http://localhost:3000');
const headings = await page.evaluate(() => {
return Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'))
.map(h => parseInt(h.tagName[1]));
});
// Check no level is skipped (e.g., h1 -> h3 without h2)
for (let i = 1; i < headings.length; i++) {
expect(headings[i] - headings[i-1]).toBeLessThanOrEqual(1);
}
});
test('form labels are properly linked', async ({ page }) => {
await page.goto('http://localhost:3000/#contact');
const inputs = page.locator('input:not([type="hidden"]), textarea, select');
const count = await inputs.count();
for (let i = 0; i < count; i++) {
const input = inputs.nth(i);
const id = await input.getAttribute('id');
const ariaLabel = await input.getAttribute('aria-label');
const ariaLabelledby = await input.getAttribute('aria-labelledby');
const hasLabel = id ? await page.locator(`label[for="${id}"]`).count() > 0 : false;
expect(hasLabel || ariaLabel || ariaLabelledby).toBeTruthy();
}
});
});
Responsiveness Tests
import { test, expect } from "@playwright/test";
const viewports = {
mobile: { width: 375, height: 667 },
tablet: { width: 768, height: 1024 },
desktop: { width: 1920, height: 1080 }
};
test.describe('Responsiveness', () => {
for (const [name, size] of Object.entries(viewports)) {
test(`content is readable on ${name}`, async ({ page }) => {
await page.setViewportSize(size);
await page.goto('http://localhost:3000');
// Check no horizontal overflow
const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
expect(bodyWidth).toBeLessThanOrEqual(size.width);
// Check main content is visible
await expect(page.locator('main, #hero, .hero')).toBeVisible();
});
test(`images scale correctly on ${name}`, async ({ page }) => {
await page.setViewportSize(size);
await page.goto('http://localhost:3000');
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < Math.min(count, 5); i++) {
const box = await images.nth(i).boundingBox();
if (box) {
expect(box.width).toBeLessThanOrEqual(size.width);
}
}
});
}
});
Verification Checklists
Navigation Tests Checklist
- • Desktop menu - all links work
- • Smooth scroll to sections
- • Mobile hamburger menu opens/closes
- • Mobile menu closes after navigation
- • Mobile menu closes on Escape key
- • Mobile menu closes on click outside
- • Logo links to home
- • Active section highlighting (if implemented)
Form Tests Checklist
- • Required field validation (name, email, message)
- • Email format validation
- • Error message display
- • Form labels present (accessibility)
- • Tab navigation through fields
- • Submit button state (disabled when invalid)
- • Success message after submission (if backend connected)
Blog Tests Checklist
- • Blog listing loads posts
- • Post cards show: image, title, excerpt, date
- • Individual post page renders
- • Markdown content renders correctly
- • Frontmatter displays: date, reading time, tags
- • Back to blog navigation works
- • 404 for non-existent slugs
SEO Tests Checklist
- • Title tag unique per page
- • Meta description present (50-160 chars)
- • OG tags: og:title, og:description, og:image, og:url
- • Twitter card tags
- • Canonical URL
- • JSON-LD structured data (Person on home, BlogPosting on posts)
- • Sitemap accessible at /sitemap.xml
Accessibility Tests Checklist (WCAG 2.1 AA)
- • Keyboard navigation (Tab, Enter, Escape)
- • Focus indicators visible
- • ARIA labels on icon-only buttons
- • Alt text on all images
- • One H1 per page
- • Heading hierarchy (H1 -> H2 -> H3, no skips)
- • Form labels linked to inputs
- • Color contrast >= 4.5:1 for text
Responsiveness Tests Checklist
- • Mobile (375px) - content readable, no overflow
- • Tablet (768px) - grid layouts adjust
- • Desktop (1920px) - full layout
- • Images scale correctly
- • Text doesn't overflow containers
- • Touch targets >= 44x44px on mobile
Helper Functions Reference
Available in tests/utils/test-helpers.js:
- •
waitForSection(page, sectionId)- Wait for section visibility - •
checkFormAccessibility(page)- Verify form accessibility - •
testResponsiveLayout(page, viewports)- Test across viewports - •
verifyMetaTags(page, expected)- Check SEO meta tags - •
navigateToSection(page, sectionName)- Navigate via menu - •
checkMobileMenu(page)- Test mobile menu behavior - •
scrollToElement(page, selector)- Smooth scroll helper
Examples
Example 1: Create tests for new Projects filtering
User: "Dodaj testy dla nowego filtrowania projektów"
Steps:
- •Read existing tests structure in
tests/home.spec.js - •Create new describe block for Projects filtering
- •Write tests:
- •Filter buttons are visible
- •Clicking filter shows only matching projects
- •"All" filter shows all projects
- •Filter state persists (if URL-based)
- •Run:
npm run test:headed
test.describe('Projects Filtering', () => {
test('filter buttons are visible', async ({ page }) => {
await page.goto('http://localhost:3000/#projects');
await expect(page.locator('.filter-buttons')).toBeVisible();
});
test('clicking filter shows matching projects', async ({ page }) => {
await page.goto('http://localhost:3000/#projects');
await page.click('button:has-text("React")');
const projects = page.locator('.project-card');
const count = await projects.count();
for (let i = 0; i < count; i++) {
await expect(projects.nth(i)).toContainText('React');
}
});
});
Example 2: Debug flaky mobile menu test
User: "Test mobile menu failuje losowo"
Steps:
- •Run
npm run test:debugto step through - •Check common issues in
references/debugging-guide.md:- •Viewport must be set BEFORE page.goto()
- •Animation timing - add waitForTimeout after clicks
- •Selector specificity - use more specific selectors
- •Fix the test:
// Before (flaky)
test('mobile menu', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.setViewportSize({ width: 375, height: 667 });
await page.click('[aria-label="Toggle menu"]');
});
// After (stable)
test('mobile menu', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('http://localhost:3000');
await page.waitForSelector('[aria-label="Toggle menu"]', { state: 'visible' });
await page.click('[aria-label="Toggle menu"]');
await page.waitForSelector('.mobile-menu', { state: 'visible' });
});
Example 3: SEO tests for new blog post
User: "Zweryfikuj SEO dla nowego posta o automatyzacji"
Steps:
- •Get the post slug (e.g.,
automatyzacja-email) - •Add test in
tests/blog.spec.js:
test.describe('SEO - Blog Post: Automatyzacja Email', () => {
const postUrl = 'http://localhost:3000/blog/automatyzacja-email';
test('has correct title', async ({ page }) => {
await page.goto(postUrl);
const title = await page.title();
expect(title).toContain('Automatyzacja');
});
test('has OG image', async ({ page }) => {
await page.goto(postUrl);
const ogImage = await page.getAttribute('meta[property="og:image"]', 'content');
expect(ogImage).toMatch(/og-automatyzacja-email\.webp/);
});
test('has BlogPosting schema', async ({ page }) => {
await page.goto(postUrl);
const jsonLd = await page.$eval(
'script[type="application/ld+json"]',
el => JSON.parse(el.textContent)
);
expect(jsonLd['@type']).toBe('BlogPosting');
});
});
- •Verify sitemap includes post:
test('sitemap includes new post', async ({ page }) => {
const response = await page.goto('http://localhost:3000/sitemap.xml');
const content = await response.text();
expect(content).toContain('/blog/automatyzacja-email');
});
Integration with portfolio-code-review
| portfolio-code-review | portfolio-testing |
|---|---|
| Reviews code changes | Verifies runtime behavior |
| Static analysis | E2E tests |
| Checks conventions | Checks functionality |
| Pre-merge review | Post-implementation verification |
Workflow:
- •Developer makes changes
- •
portfolio-code-reviewreviews code quality, edge cases, conventions - •
portfolio-testingcreates/runs tests to verify behavior - •Both pass → Ready to merge
When to use which:
- •Code review finds: "Missing rel='noopener' on external link"
- •Testing verifies: "External links open in new tab"
Test Report Template
After running tests, generate report:
# E2E Test Report - [Feature/Date] ## Summary - **Tests run:** [number] - **Passed:** [number] - **Failed:** [number] - **Skipped:** [number] ## Test Coverage ### Navigation - [x] Desktop menu: PASS - [x] Mobile menu: PASS - [ ] Smooth scroll: FAIL - timeout on #contact ### Forms - [x] Validation: PASS - [x] Accessibility: PASS ### Blog - [x] Listing: PASS - [x] Single post: PASS ### SEO - [x] Meta tags: PASS - [ ] OG image: FAIL - 404 for og-[slug].webp ## Failed Tests ### smooth scroll to contact section **Error:** Timeout 30000ms exceeded **Screenshot:** [link to report] **Likely cause:** Animation timing or element not found **Suggested fix:** Add explicit wait or check selector ### OG image for new post **Error:** 404 for /images/og-new-post.webp **Likely cause:** OG image not created **Suggested fix:** Run `npm run img:convert` or create WebP image ## Next Steps 1. Fix smooth scroll timing 2. Create missing OG image 3. Re-run tests: `npm test`
Guidelines
Philosophy
- •Test user flows - Focus on real user scenarios, not implementation details
- •Mobile first - Always test mobile viewports (majority of users)
- •Edge cases - Test what can go wrong (empty states, errors, timeouts)
- •Accessibility - Every test should consider keyboard/screen reader users
Process
- •Read existing tests first (understand patterns)
- •Use helper functions (avoid duplication)
- •Run
test:headedduring development (see what's happening) - •Run full suite before PR (catch cross-browser issues)
Common pitfalls
- •Viewport not set before goto (causes flaky mobile tests)
- •Missing waits for animations (Framer Motion needs ~300ms)
- •Hardcoded timeouts (prefer waitForSelector)
- •Testing implementation, not behavior (brittle tests)
- •Forgetting mobile menu close behavior (nav, Escape, outside click)