
portfolio-testing
by plipowczan
SKILL.md
name: portfolio-testing description: 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. license: Apache-2.0
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)
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新がある
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon