Agent skill
portfolio-testing
E2E testing skill for Pawel Lipowczan portfolio project (Playwright + React/Vite). Use when user wants to create new E2E tests, debug flaky tests, extend test coverage, verify test completeness for features, or run/interpret test results. Covers navigation, forms, blog, SEO, accessibility (WCAG 2.1 AA), responsiveness. References docs/portfolio/testing/{README.md,TESTING_QUICKSTART.md}. Complements portfolio-code-review skill.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/portfolio-testing
SKILL.md
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 documentationdocs/portfolio/testing/TESTING_QUICKSTART.md- Quick start guidetests/utils/test-helpers.js- Helper functionsplaywright.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 visibilitycheckFormAccessibility(page)- Verify form accessibilitytestResponsiveLayout(page, viewports)- Test across viewportsverifyMetaTags(page, expected)- Check SEO meta tagsnavigateToSection(page, sectionName)- Navigate via menucheckMobileMenu(page)- Test mobile menu behaviorscrollToElement(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, conventionsportfolio-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)
Didn't find tool you were looking for?