feat: initial Claude Code configuration scaffold
Comprehensive Claude Code guidance system with: - 5 agents: tdd-guardian, code-reviewer, security-scanner, refactor-scan, dependency-audit - 18 skills covering languages (Python, TypeScript, Rust, Go, Java, C#), infrastructure (AWS, Azure, GCP, Terraform, Ansible, Docker/K8s, Database, CI/CD), testing (TDD, UI, Browser), and patterns (Monorepo, API Design, Observability) - 3 hooks: secret detection, auto-formatting, TDD git pre-commit - Strict TDD enforcement with 80%+ coverage requirements - Multi-model strategy: Opus for planning, Sonnet for execution (opusplan) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
389
.claude/skills/testing/browser-testing/SKILL.md
Normal file
389
.claude/skills/testing/browser-testing/SKILL.md
Normal file
@@ -0,0 +1,389 @@
|
||||
---
|
||||
name: browser-testing
|
||||
description: Browser automation with Playwright for E2E tests and Claude's Chrome MCP for rapid development verification. Use when writing E2E tests or verifying UI during development.
|
||||
---
|
||||
|
||||
# Browser Testing Skill
|
||||
|
||||
## Two Approaches
|
||||
|
||||
### 1. Claude Chrome MCP (Development Verification)
|
||||
Quick, interactive testing during development to verify UI works before writing permanent tests.
|
||||
|
||||
### 2. Playwright (Permanent E2E Tests)
|
||||
Automated, repeatable tests that run in CI/CD.
|
||||
|
||||
---
|
||||
|
||||
## Claude Chrome MCP (Quick Verification)
|
||||
|
||||
Use Chrome MCP tools for rapid UI verification during development.
|
||||
|
||||
### Basic Workflow
|
||||
|
||||
```
|
||||
1. Navigate to page
|
||||
2. Take screenshot to verify state
|
||||
3. Find and interact with elements
|
||||
4. Verify expected outcome
|
||||
5. Record GIF for complex flows
|
||||
```
|
||||
|
||||
### Navigation
|
||||
```typescript
|
||||
// Navigate to URL
|
||||
mcp__claude-in-chrome__navigate({ url: "http://localhost:5173", tabId: 123 })
|
||||
|
||||
// Go back/forward
|
||||
mcp__claude-in-chrome__navigate({ url: "back", tabId: 123 })
|
||||
```
|
||||
|
||||
### Finding Elements
|
||||
```typescript
|
||||
// Find by natural language
|
||||
mcp__claude-in-chrome__find({ query: "login button", tabId: 123 })
|
||||
mcp__claude-in-chrome__find({ query: "email input field", tabId: 123 })
|
||||
mcp__claude-in-chrome__find({ query: "product card containing organic", tabId: 123 })
|
||||
```
|
||||
|
||||
### Reading Page State
|
||||
```typescript
|
||||
// Get accessibility tree (best for understanding structure)
|
||||
mcp__claude-in-chrome__read_page({ tabId: 123, filter: "interactive" })
|
||||
|
||||
// Get text content (for articles/text-heavy pages)
|
||||
mcp__claude-in-chrome__get_page_text({ tabId: 123 })
|
||||
```
|
||||
|
||||
### Interactions
|
||||
```typescript
|
||||
// Click element by reference
|
||||
mcp__claude-in-chrome__computer({
|
||||
action: "left_click",
|
||||
ref: "ref_42",
|
||||
tabId: 123
|
||||
})
|
||||
|
||||
// Type text
|
||||
mcp__claude-in-chrome__computer({
|
||||
action: "type",
|
||||
text: "user@example.com",
|
||||
tabId: 123
|
||||
})
|
||||
|
||||
// Fill form input
|
||||
mcp__claude-in-chrome__form_input({
|
||||
ref: "ref_15",
|
||||
value: "test@example.com",
|
||||
tabId: 123
|
||||
})
|
||||
|
||||
// Press keys
|
||||
mcp__claude-in-chrome__computer({
|
||||
action: "key",
|
||||
text: "Enter",
|
||||
tabId: 123
|
||||
})
|
||||
```
|
||||
|
||||
### Screenshots and GIFs
|
||||
```typescript
|
||||
// Take screenshot
|
||||
mcp__claude-in-chrome__computer({ action: "screenshot", tabId: 123 })
|
||||
|
||||
// Record GIF for complex interaction
|
||||
mcp__claude-in-chrome__gif_creator({ action: "start_recording", tabId: 123 })
|
||||
// ... perform actions ...
|
||||
mcp__claude-in-chrome__gif_creator({
|
||||
action: "export",
|
||||
download: true,
|
||||
filename: "login-flow.gif",
|
||||
tabId: 123
|
||||
})
|
||||
```
|
||||
|
||||
### Verification Pattern
|
||||
|
||||
```
|
||||
1. Navigate to the page
|
||||
2. Screenshot to see current state
|
||||
3. Find the element you want to interact with
|
||||
4. Interact with it
|
||||
5. Screenshot to verify the result
|
||||
6. If complex flow, record a GIF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Playwright (Permanent E2E Tests)
|
||||
|
||||
### Project Setup
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html'],
|
||||
['junit', { outputFile: 'results.xml' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'mobile',
|
||||
use: { ...devices['iPhone 13'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Page Object Pattern
|
||||
|
||||
```typescript
|
||||
// e2e/pages/login.page.ts
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
|
||||
export class LoginPage {
|
||||
readonly page: Page;
|
||||
readonly emailInput: Locator;
|
||||
readonly passwordInput: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly errorMessage: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.emailInput = page.getByLabel('Email');
|
||||
this.passwordInput = page.getByLabel('Password');
|
||||
this.submitButton = page.getByRole('button', { name: 'Sign in' });
|
||||
this.errorMessage = page.getByRole('alert');
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
await this.emailInput.fill(email);
|
||||
await this.passwordInput.fill(password);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
|
||||
async expectError(message: string) {
|
||||
await expect(this.errorMessage).toContainText(message);
|
||||
}
|
||||
|
||||
async expectLoggedIn() {
|
||||
await expect(this.page).toHaveURL('/dashboard');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Patterns
|
||||
|
||||
```typescript
|
||||
// e2e/auth/login.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login.page';
|
||||
|
||||
test.describe('Login', () => {
|
||||
let loginPage: LoginPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
});
|
||||
|
||||
test('should show validation errors for empty form', async () => {
|
||||
await loginPage.submitButton.click();
|
||||
|
||||
await expect(loginPage.page.getByText('Email is required')).toBeVisible();
|
||||
await expect(loginPage.page.getByText('Password is required')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error for invalid credentials', async () => {
|
||||
await loginPage.login('wrong@example.com', 'wrongpassword');
|
||||
|
||||
await loginPage.expectError('Invalid credentials');
|
||||
});
|
||||
|
||||
test('should redirect to dashboard on successful login', async () => {
|
||||
await loginPage.login('user@example.com', 'correctpassword');
|
||||
|
||||
await loginPage.expectLoggedIn();
|
||||
await expect(loginPage.page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should persist session after page reload', async ({ page }) => {
|
||||
await loginPage.login('user@example.com', 'correctpassword');
|
||||
await loginPage.expectLoggedIn();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### API Mocking
|
||||
|
||||
```typescript
|
||||
// e2e/dashboard/projects.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Projects Dashboard', () => {
|
||||
test('should display projects from API', async ({ page }) => {
|
||||
// Mock API response
|
||||
await page.route('**/api/projects', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ id: '1', name: 'Project A', status: 'active' },
|
||||
{ id: '2', name: 'Project B', status: 'completed' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByText('Project A')).toBeVisible();
|
||||
await expect(page.getByText('Project B')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error state on API failure', async ({ page }) => {
|
||||
await page.route('**/api/projects', async (route) => {
|
||||
await route.fulfill({ status: 500 });
|
||||
});
|
||||
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('Failed to load projects');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
```typescript
|
||||
// e2e/visual/components.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Visual Regression', () => {
|
||||
test('login page matches snapshot', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page).toHaveScreenshot('login-page.png');
|
||||
});
|
||||
|
||||
test('dashboard matches snapshot', async ({ page }) => {
|
||||
// Setup authenticated state
|
||||
await page.goto('/dashboard');
|
||||
|
||||
await expect(page).toHaveScreenshot('dashboard.png', {
|
||||
mask: [page.locator('.timestamp')], // Mask dynamic content
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Accessibility Testing
|
||||
|
||||
```typescript
|
||||
// e2e/a11y/accessibility.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('login page should have no accessibility violations', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('dashboard should have no accessibility violations', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.exclude('.third-party-widget') // Exclude third-party content
|
||||
.analyze();
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Playwright
|
||||
npx playwright test # Run all tests
|
||||
npx playwright test --project=chromium # Specific browser
|
||||
npx playwright test login.spec.ts # Specific file
|
||||
npx playwright test --ui # Interactive UI mode
|
||||
npx playwright test --debug # Debug mode
|
||||
npx playwright show-report # View HTML report
|
||||
npx playwright codegen # Generate tests by recording
|
||||
|
||||
# Update snapshots
|
||||
npx playwright test --update-snapshots
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use role-based locators** (accessible to all)
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Submit' })
|
||||
page.getByLabel('Email')
|
||||
```
|
||||
|
||||
2. **Wait for specific conditions** (not arbitrary timeouts)
|
||||
```typescript
|
||||
await expect(page.getByText('Success')).toBeVisible();
|
||||
```
|
||||
|
||||
3. **Isolate tests** (each test sets up its own state)
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
});
|
||||
```
|
||||
|
||||
4. **Use Page Objects** (DRY, maintainable)
|
||||
```typescript
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login(email, password);
|
||||
```
|
||||
|
||||
5. **Mock external APIs** (fast, reliable)
|
||||
```typescript
|
||||
await page.route('**/api/**', handler);
|
||||
```
|
||||
349
.claude/skills/testing/tdd/SKILL.md
Normal file
349
.claude/skills/testing/tdd/SKILL.md
Normal file
@@ -0,0 +1,349 @@
|
||||
---
|
||||
name: tdd-workflow
|
||||
description: Test-Driven Development workflow with RED-GREEN-REFACTOR cycle, coverage verification, and quality gates. Use when planning or implementing features with TDD.
|
||||
---
|
||||
|
||||
# TDD Workflow Skill
|
||||
|
||||
## The TDD Cycle
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─────┐ ┌───────┐ ┌──────────┐ │
|
||||
│ │ RED │ ──▶ │ GREEN │ ──▶ │ REFACTOR │ ──┐ │
|
||||
│ └─────┘ └───────┘ └──────────┘ │ │
|
||||
│ ▲ │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Phase 1: RED (Write Failing Test)
|
||||
|
||||
### Rules
|
||||
- Write ONE test that describes desired behavior
|
||||
- Test must fail for the RIGHT reason
|
||||
- NO production code exists yet
|
||||
|
||||
### Checklist
|
||||
- [ ] Test describes behavior, not implementation
|
||||
- [ ] Test name clearly states expected outcome
|
||||
- [ ] Test uses factory functions for data
|
||||
- [ ] Test fails with meaningful error message
|
||||
- [ ] Error indicates missing behavior, not syntax/import errors
|
||||
|
||||
### Example
|
||||
```typescript
|
||||
// STEP 1: Write the failing test
|
||||
describe('PaymentProcessor', () => {
|
||||
it('should reject payments with negative amounts', () => {
|
||||
const payment = getMockPayment({ amount: -100 });
|
||||
|
||||
const result = processPayment(payment);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Amount must be positive');
|
||||
});
|
||||
});
|
||||
|
||||
// At this point:
|
||||
// - processPayment doesn't exist
|
||||
// - Running test gives: "ReferenceError: processPayment is not defined"
|
||||
// - This is the RIGHT failure - the function doesn't exist yet
|
||||
```
|
||||
|
||||
## Phase 2: GREEN (Write Minimum Code)
|
||||
|
||||
### Rules
|
||||
- Write ONLY enough code to pass the test
|
||||
- No additional functionality
|
||||
- No "while you're there" improvements
|
||||
- Ugly code is acceptable (we'll refactor)
|
||||
|
||||
### Checklist
|
||||
- [ ] Implementation makes test pass
|
||||
- [ ] No code beyond what test demands
|
||||
- [ ] All existing tests still pass
|
||||
- [ ] Coverage increased for new code
|
||||
|
||||
### Example
|
||||
```typescript
|
||||
// STEP 2: Write minimum code to pass
|
||||
export function processPayment(payment: Payment): PaymentResult {
|
||||
if (payment.amount < 0) {
|
||||
return { success: false, error: 'Amount must be positive' };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// This passes the test - that's enough for now
|
||||
// Don't add validation for zero, max amounts, etc.
|
||||
// Those need their own tests first!
|
||||
```
|
||||
|
||||
## Phase 3: REFACTOR (Improve Code Quality)
|
||||
|
||||
### Rules
|
||||
- COMMIT before refactoring
|
||||
- Only refactor if it adds value
|
||||
- All tests must stay green
|
||||
- No new functionality
|
||||
|
||||
### When to Refactor
|
||||
- Duplicate knowledge (not just similar code)
|
||||
- Unclear names
|
||||
- Complex conditionals
|
||||
- Magic numbers/strings
|
||||
|
||||
### When NOT to Refactor
|
||||
- Code is already clean
|
||||
- Similarity is structural, not semantic
|
||||
- Would add unnecessary abstraction
|
||||
|
||||
### Example
|
||||
```typescript
|
||||
// STEP 3: Assess and refactor
|
||||
// BEFORE REFACTORING: git commit -m "feat: add negative amount validation"
|
||||
|
||||
// Assessment:
|
||||
// - Magic number 0 could be a constant
|
||||
// - Error message could be a constant
|
||||
// - But... it's simple enough. Skip refactoring.
|
||||
|
||||
// If we had multiple validations:
|
||||
const VALIDATION_ERRORS = {
|
||||
NEGATIVE_AMOUNT: 'Amount must be positive',
|
||||
ZERO_AMOUNT: 'Amount must be greater than zero',
|
||||
EXCEEDS_LIMIT: 'Amount exceeds maximum limit',
|
||||
} as const;
|
||||
|
||||
const MAX_PAYMENT_AMOUNT = 10000;
|
||||
|
||||
export function processPayment(payment: Payment): PaymentResult {
|
||||
if (payment.amount < 0) {
|
||||
return { success: false, error: VALIDATION_ERRORS.NEGATIVE_AMOUNT };
|
||||
}
|
||||
if (payment.amount === 0) {
|
||||
return { success: false, error: VALIDATION_ERRORS.ZERO_AMOUNT };
|
||||
}
|
||||
if (payment.amount > MAX_PAYMENT_AMOUNT) {
|
||||
return { success: false, error: VALIDATION_ERRORS.EXCEEDS_LIMIT };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
## Complete TDD Session Example
|
||||
|
||||
```typescript
|
||||
// === CYCLE 1: Negative amounts ===
|
||||
|
||||
// RED: Write failing test
|
||||
it('should reject negative amounts', () => {
|
||||
const payment = getMockPayment({ amount: -100 });
|
||||
const result = processPayment(payment);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
// RUN: ❌ FAIL - processPayment is not defined
|
||||
|
||||
// GREEN: Minimal implementation
|
||||
function processPayment(payment: Payment): PaymentResult {
|
||||
if (payment.amount < 0) return { success: false };
|
||||
return { success: true };
|
||||
}
|
||||
// RUN: ✅ PASS
|
||||
|
||||
// REFACTOR: Assess - simple enough, skip
|
||||
// COMMIT: "feat: reject negative payment amounts"
|
||||
|
||||
// === CYCLE 2: Zero amounts ===
|
||||
|
||||
// RED: Write failing test
|
||||
it('should reject zero amounts', () => {
|
||||
const payment = getMockPayment({ amount: 0 });
|
||||
const result = processPayment(payment);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
// RUN: ❌ FAIL - expected false, got true
|
||||
|
||||
// GREEN: Add zero check
|
||||
function processPayment(payment: Payment): PaymentResult {
|
||||
if (payment.amount <= 0) return { success: false };
|
||||
return { success: true };
|
||||
}
|
||||
// RUN: ✅ PASS
|
||||
|
||||
// REFACTOR: Assess - still simple, skip
|
||||
// COMMIT: "feat: reject zero payment amounts"
|
||||
|
||||
// === CYCLE 3: Maximum amount ===
|
||||
|
||||
// RED: Write failing test
|
||||
it('should reject amounts over 10000', () => {
|
||||
const payment = getMockPayment({ amount: 10001 });
|
||||
const result = processPayment(payment);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
// RUN: ❌ FAIL - expected false, got true
|
||||
|
||||
// GREEN: Add max check
|
||||
function processPayment(payment: Payment): PaymentResult {
|
||||
if (payment.amount <= 0) return { success: false };
|
||||
if (payment.amount > 10000) return { success: false };
|
||||
return { success: true };
|
||||
}
|
||||
// RUN: ✅ PASS
|
||||
|
||||
// REFACTOR: Now we have magic number 10000
|
||||
// COMMIT first: "feat: reject payments over maximum"
|
||||
// Then refactor:
|
||||
const MAX_PAYMENT_AMOUNT = 10000;
|
||||
|
||||
function processPayment(payment: Payment): PaymentResult {
|
||||
if (payment.amount <= 0) return { success: false };
|
||||
if (payment.amount > MAX_PAYMENT_AMOUNT) return { success: false };
|
||||
return { success: true };
|
||||
}
|
||||
// RUN: ✅ PASS
|
||||
// COMMIT: "refactor: extract max payment constant"
|
||||
```
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Before committing, verify:
|
||||
|
||||
```bash
|
||||
# 1. All tests pass
|
||||
npm test # or pytest, cargo test
|
||||
|
||||
# 2. Coverage meets threshold
|
||||
npm test -- --coverage
|
||||
# Verify: 80%+ overall
|
||||
|
||||
# 3. Type checking passes
|
||||
npm run typecheck # or mypy, cargo check
|
||||
|
||||
# 4. Linting passes
|
||||
npm run lint # or ruff, cargo clippy
|
||||
|
||||
# 5. No secrets in code
|
||||
git diff --staged | grep -i "password\|secret\|api.key"
|
||||
```
|
||||
|
||||
## Coverage Verification
|
||||
|
||||
**CRITICAL: Never trust claimed coverage - always verify.**
|
||||
|
||||
```bash
|
||||
# Python
|
||||
pytest --cov=src --cov-report=term-missing --cov-fail-under=80
|
||||
|
||||
# TypeScript
|
||||
npm test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80}}'
|
||||
|
||||
# Rust
|
||||
cargo tarpaulin --fail-under 80
|
||||
```
|
||||
|
||||
### Interpreting Coverage
|
||||
|
||||
| Metric | Meaning | Target |
|
||||
|--------|---------|--------|
|
||||
| Lines | % of lines executed | 80%+ |
|
||||
| Branches | % of if/else paths taken | 80%+ |
|
||||
| Functions | % of functions called | 80%+ |
|
||||
| Statements | % of statements executed | 80%+ |
|
||||
|
||||
### Coverage Exceptions (Document!)
|
||||
|
||||
```python
|
||||
# pragma: no cover - Reason: Debug utility only used in development
|
||||
def debug_print(data):
|
||||
print(json.dumps(data, indent=2))
|
||||
```
|
||||
|
||||
```typescript
|
||||
/* istanbul ignore next -- @preserve Reason: Error boundary for React */
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error(error);
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Writing Test After Code
|
||||
```
|
||||
❌ WRONG:
|
||||
1. Write all production code
|
||||
2. Write tests to cover it
|
||||
3. "I have 80% coverage!"
|
||||
|
||||
✅ CORRECT:
|
||||
1. Write failing test
|
||||
2. Write code to pass
|
||||
3. Repeat until feature complete
|
||||
```
|
||||
|
||||
### Testing Implementation
|
||||
```typescript
|
||||
// ❌ BAD: Tests implementation detail
|
||||
it('should call validatePayment', () => {
|
||||
const spy = jest.spyOn(service, 'validatePayment');
|
||||
processPayment(payment);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ✅ GOOD: Tests behavior
|
||||
it('should reject invalid payments', () => {
|
||||
const payment = getMockPayment({ amount: -1 });
|
||||
const result = processPayment(payment);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
### Writing Too Many Tests at Once
|
||||
```
|
||||
❌ WRONG:
|
||||
1. Write 10 tests
|
||||
2. Implement everything
|
||||
3. All tests pass
|
||||
|
||||
✅ CORRECT:
|
||||
1. Write 1 test
|
||||
2. Make it pass
|
||||
3. Refactor if needed
|
||||
4. Repeat
|
||||
```
|
||||
|
||||
### Skipping Refactor Assessment
|
||||
```
|
||||
❌ WRONG:
|
||||
1. Test passes
|
||||
2. Immediately write next test
|
||||
3. Code becomes messy
|
||||
|
||||
✅ CORRECT:
|
||||
1. Test passes
|
||||
2. Ask: "Should I refactor?"
|
||||
3. If yes: commit, then refactor
|
||||
4. If no: continue
|
||||
```
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Follow this pattern:
|
||||
|
||||
```bash
|
||||
# After GREEN (feature added)
|
||||
git commit -m "feat: add payment amount validation"
|
||||
|
||||
# After REFACTOR
|
||||
git commit -m "refactor: extract payment validation constants"
|
||||
|
||||
# Test-only changes
|
||||
git commit -m "test: add edge cases for payment validation"
|
||||
|
||||
# Bug fixes (driven by failing test)
|
||||
git commit -m "fix: handle null payment amount"
|
||||
```
|
||||
445
.claude/skills/testing/ui-testing/SKILL.md
Normal file
445
.claude/skills/testing/ui-testing/SKILL.md
Normal file
@@ -0,0 +1,445 @@
|
||||
---
|
||||
name: ui-testing
|
||||
description: React component testing with React Testing Library, user-centric patterns, and accessibility testing. Use when writing tests for React components or UI interactions.
|
||||
---
|
||||
|
||||
# UI Testing Skill
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Test behavior, not implementation** - What users see and do
|
||||
2. **Query by accessibility** - Role, label, text (what assistive tech sees)
|
||||
3. **User events over fire events** - Real user interactions
|
||||
4. **Avoid test IDs when possible** - Last resort only
|
||||
|
||||
## Query Priority
|
||||
|
||||
```typescript
|
||||
// Best: Accessible to everyone
|
||||
screen.getByRole('button', { name: 'Submit' })
|
||||
screen.getByLabelText('Email address')
|
||||
screen.getByPlaceholderText('Search...')
|
||||
screen.getByText('Welcome back')
|
||||
|
||||
// Good: Semantic queries
|
||||
screen.getByAltText('Company logo')
|
||||
screen.getByTitle('Close dialog')
|
||||
|
||||
// Acceptable: Test IDs (when no other option)
|
||||
screen.getByTestId('custom-datepicker')
|
||||
```
|
||||
|
||||
## Component Testing Pattern
|
||||
|
||||
```typescript
|
||||
// tests/features/dashboard/Dashboard.test.tsx
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { Dashboard } from '@/features/dashboard/Dashboard';
|
||||
import { getMockUser, getMockProjects } from './factories';
|
||||
|
||||
describe('Dashboard', () => {
|
||||
it('should display user name in header', () => {
|
||||
const user = getMockUser({ name: 'Alice' });
|
||||
|
||||
render(<Dashboard user={user} projects={[]} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /welcome, alice/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when no projects', () => {
|
||||
render(<Dashboard user={getMockUser()} projects={[]} />);
|
||||
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /create project/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should list all projects with their status', () => {
|
||||
const projects = [
|
||||
getMockProject({ name: 'Project A', status: 'active' }),
|
||||
getMockProject({ name: 'Project B', status: 'completed' }),
|
||||
];
|
||||
|
||||
render(<Dashboard user={getMockUser()} projects={projects} />);
|
||||
|
||||
const projectList = screen.getByRole('list', { name: /projects/i });
|
||||
const items = within(projectList).getAllByRole('listitem');
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(screen.getByText('Project A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Project B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should navigate to project when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
const projects = [getMockProject({ id: 'proj-1', name: 'My Project' })];
|
||||
|
||||
render(
|
||||
<Dashboard
|
||||
user={getMockUser()}
|
||||
projects={projects}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('link', { name: /my project/i }));
|
||||
|
||||
expect(onNavigate).toHaveBeenCalledWith('/projects/proj-1');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Form Testing
|
||||
|
||||
```typescript
|
||||
describe('LoginForm', () => {
|
||||
it('should show validation errors for empty fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error for invalid email format', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should submit form with valid data', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<LoginForm onSubmit={onSubmit} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
|
||||
await user.type(screen.getByLabelText(/password/i), 'securepass123');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
email: 'user@example.com',
|
||||
password: 'securepass123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable submit button while loading', async () => {
|
||||
render(<LoginForm onSubmit={vi.fn()} isLoading />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show server error message', () => {
|
||||
render(
|
||||
<LoginForm onSubmit={vi.fn()} error="Invalid credentials" />
|
||||
);
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/invalid credentials/i);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Async Testing
|
||||
|
||||
```typescript
|
||||
describe('UserProfile', () => {
|
||||
it('should show loading state initially', () => {
|
||||
render(<UserProfile userId="user-1" />);
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display user data when loaded', async () => {
|
||||
// Mock API returns user data
|
||||
server.use(
|
||||
http.get('/api/users/user-1', () => {
|
||||
return HttpResponse.json(getMockUser({ name: 'Alice' }));
|
||||
})
|
||||
);
|
||||
|
||||
render(<UserProfile userId="user-1" />);
|
||||
|
||||
// Wait for data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show error state on failure', async () => {
|
||||
server.use(
|
||||
http.get('/api/users/user-1', () => {
|
||||
return HttpResponse.error();
|
||||
})
|
||||
);
|
||||
|
||||
render(<UserProfile userId="user-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Modal and Dialog Testing
|
||||
|
||||
```typescript
|
||||
describe('ConfirmDialog', () => {
|
||||
it('should not be visible when closed', () => {
|
||||
render(<ConfirmDialog isOpen={false} onConfirm={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be visible when open', () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
title="Delete item?"
|
||||
message="This action cannot be undone."
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /delete item/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onConfirm when confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfirmDialog isOpen={true} onConfirm={onConfirm} onCancel={vi.fn()} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onCancel when cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfirmDialog isOpen={true} onConfirm={vi.fn()} onCancel={onCancel} />
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should close on escape key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfirmDialog isOpen={true} onConfirm={vi.fn()} onCancel={onCancel} />
|
||||
);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Accessibility Testing
|
||||
|
||||
```typescript
|
||||
import { axe, toHaveNoViolations } from 'jest-axe';
|
||||
|
||||
expect.extend(toHaveNoViolations);
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have no accessibility violations', async () => {
|
||||
const { container } = render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
const results = await axe(container);
|
||||
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it('should have proper focus management', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
// Tab through form
|
||||
await user.tab();
|
||||
expect(screen.getByLabelText(/email/i)).toHaveFocus();
|
||||
|
||||
await user.tab();
|
||||
expect(screen.getByLabelText(/password/i)).toHaveFocus();
|
||||
|
||||
await user.tab();
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toHaveFocus();
|
||||
});
|
||||
|
||||
it('should announce errors to screen readers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginForm onSubmit={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
const errorMessage = screen.getByText(/email is required/i);
|
||||
expect(errorMessage).toHaveAttribute('role', 'alert');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Select/Dropdown Components
|
||||
|
||||
```typescript
|
||||
describe('CountrySelect', () => {
|
||||
it('should show selected country', () => {
|
||||
render(<CountrySelect value="GB" onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveTextContent('United Kingdom');
|
||||
});
|
||||
|
||||
it('should show options when opened', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CountrySelect value="" onChange={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /united states/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /united kingdom/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onChange when option selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<CountrySelect value="" onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.click(screen.getByRole('option', { name: /germany/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('DE');
|
||||
});
|
||||
|
||||
it('should filter options when typing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CountrySelect value="" onChange={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.type(screen.getByRole('combobox'), 'united');
|
||||
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options).toHaveLength(2); // US and UK
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing with Context Providers
|
||||
|
||||
```typescript
|
||||
// tests/utils/render.tsx
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@/shared/context/ThemeContext';
|
||||
import { AuthProvider } from '@/features/auth/AuthContext';
|
||||
|
||||
type WrapperProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
function Wrapper({ children }: WrapperProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...render(ui, { wrapper: Wrapper, ...options }),
|
||||
queryClient,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
```typescript
|
||||
// BAD: Testing implementation details
|
||||
it('should update state', () => {
|
||||
const { result } = renderHook(() => useState(0));
|
||||
act(() => result.current[1](1));
|
||||
expect(result.current[0]).toBe(1);
|
||||
});
|
||||
|
||||
// GOOD: Test user-visible behavior
|
||||
it('should increment counter when clicked', async () => {
|
||||
render(<Counter />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /increment/i }));
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
// BAD: Using test IDs when accessible query exists
|
||||
screen.getByTestId('submit-button')
|
||||
|
||||
// GOOD: Use accessible query
|
||||
screen.getByRole('button', { name: /submit/i })
|
||||
|
||||
|
||||
// BAD: Waiting with arbitrary timeout
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// GOOD: Wait for specific condition
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Loaded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
// BAD: Using fireEvent for user interactions
|
||||
fireEvent.click(button);
|
||||
|
||||
// GOOD: Use userEvent for realistic interactions
|
||||
await userEvent.click(button);
|
||||
|
||||
|
||||
// BAD: Querying by class or internal structure
|
||||
container.querySelector('.btn-primary')
|
||||
|
||||
// GOOD: Query by role and accessible name
|
||||
screen.getByRole('button', { name: /save/i })
|
||||
```
|
||||
Reference in New Issue
Block a user