Initial commit
This commit is contained in:
128
skills/testing-next-stack/assets/examples/component-test.tsx
Normal file
128
skills/testing-next-stack/assets/examples/component-test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { axe, toHaveNoViolations } from 'jest-axe'
|
||||
|
||||
expect.extend(toHaveNoViolations)
|
||||
|
||||
/**
|
||||
* Example component test demonstrating RTL best practices
|
||||
* with accessibility testing via axe-core
|
||||
*/
|
||||
|
||||
// Example component
|
||||
interface EntityCardProps {
|
||||
entity: {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
}
|
||||
onEdit?: (id: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
function EntityCard({ entity, onEdit, onDelete }: EntityCardProps) {
|
||||
return (
|
||||
<article aria-label={`Entity: ${entity.name}`}>
|
||||
<header>
|
||||
<h2>{entity.name}</h2>
|
||||
<span className="badge">{entity.type}</span>
|
||||
</header>
|
||||
{entity.description && <p>{entity.description}</p>}
|
||||
<footer>
|
||||
{onEdit && (
|
||||
<button onClick={() => onEdit(entity.id)} aria-label={`Edit ${entity.name}`}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button onClick={() => onDelete(entity.id)} aria-label={`Delete ${entity.name}`}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
describe('EntityCard', () => {
|
||||
const mockEntity = {
|
||||
id: '1',
|
||||
name: 'Test Character',
|
||||
type: 'character',
|
||||
description: 'A brave adventurer'
|
||||
}
|
||||
|
||||
it('renders entity information', () => {
|
||||
render(<EntityCard entity={mockEntity} />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Test Character' })).toBeInTheDocument()
|
||||
expect(screen.getByText('character')).toBeInTheDocument()
|
||||
expect(screen.getByText('A brave adventurer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render description when not provided', () => {
|
||||
const entityWithoutDesc = { ...mockEntity, description: undefined }
|
||||
render(<EntityCard entity={entityWithoutDesc} />)
|
||||
|
||||
expect(screen.queryByText('A brave adventurer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onEdit when edit button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEdit = vi.fn()
|
||||
|
||||
render(<EntityCard entity={mockEntity} onEdit={onEdit} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /edit test character/i }))
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
expect(onEdit).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('calls onDelete when delete button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDelete = vi.fn()
|
||||
|
||||
render(<EntityCard entity={mockEntity} onDelete={onDelete} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete test character/i }))
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
expect(onDelete).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('does not render action buttons when handlers not provided', () => {
|
||||
render(<EntityCard entity={mockEntity} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible structure', () => {
|
||||
render(<EntityCard entity={mockEntity} onEdit={vi.fn()} onDelete={vi.fn()} />)
|
||||
|
||||
const article = screen.getByRole('article', { name: /entity: test character/i })
|
||||
expect(article).toBeInTheDocument()
|
||||
|
||||
// Check heading hierarchy
|
||||
const heading = within(article).getByRole('heading', { level: 2 })
|
||||
expect(heading).toHaveTextContent('Test Character')
|
||||
|
||||
// Check buttons have accessible names
|
||||
const editButton = within(article).getByRole('button', { name: /edit test character/i })
|
||||
const deleteButton = within(article).getByRole('button', { name: /delete test character/i })
|
||||
expect(editButton).toBeInTheDocument()
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no accessibility violations', async () => {
|
||||
const { container } = render(
|
||||
<EntityCard entity={mockEntity} onEdit={vi.fn()} onDelete={vi.fn()} />
|
||||
)
|
||||
|
||||
const results = await axe(container)
|
||||
expect(results).toHaveNoViolations()
|
||||
})
|
||||
})
|
||||
196
skills/testing-next-stack/assets/examples/e2e-test.ts
Normal file
196
skills/testing-next-stack/assets/examples/e2e-test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
|
||||
/**
|
||||
* Example E2E test demonstrating Playwright best practices
|
||||
* with accessibility testing via axe-core
|
||||
*/
|
||||
|
||||
test.describe('Entity Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to entities page
|
||||
await page.goto('/entities')
|
||||
})
|
||||
|
||||
test('displays entity list', async ({ page }) => {
|
||||
// Wait for content to load
|
||||
await page.waitForSelector('[role="list"]')
|
||||
|
||||
// Verify entities are displayed
|
||||
const entities = page.getByRole('listitem')
|
||||
await expect(entities).not.toHaveCount(0)
|
||||
|
||||
// Verify entity cards have proper structure
|
||||
const firstEntity = entities.first()
|
||||
await expect(firstEntity.getByRole('heading')).toBeVisible()
|
||||
})
|
||||
|
||||
test('creates new entity', async ({ page }) => {
|
||||
// Click create button
|
||||
await page.getByRole('button', { name: /create entity/i }).click()
|
||||
|
||||
// Verify form is displayed
|
||||
await expect(page.getByRole('heading', { name: /new entity/i })).toBeVisible()
|
||||
|
||||
// Fill in form
|
||||
await page.getByLabel(/name/i).fill('Mysterious Stranger')
|
||||
await page.getByLabel(/type/i).selectOption('character')
|
||||
await page.getByLabel(/description/i).fill('A traveler from distant lands')
|
||||
|
||||
// Submit form
|
||||
await page.getByRole('button', { name: /save|create/i }).click()
|
||||
|
||||
// Verify success message or redirect
|
||||
await expect(
|
||||
page.getByText(/entity created|success/i)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify new entity appears in list
|
||||
await page.goto('/entities')
|
||||
await expect(page.getByText('Mysterious Stranger')).toBeVisible()
|
||||
})
|
||||
|
||||
test('edits existing entity', async ({ page }) => {
|
||||
// Find and click edit button for first entity
|
||||
const firstEntity = page.getByRole('listitem').first()
|
||||
const entityName = await firstEntity.getByRole('heading').textContent()
|
||||
|
||||
await firstEntity.getByRole('button', { name: /edit/i }).click()
|
||||
|
||||
// Update name
|
||||
const nameInput = page.getByLabel(/name/i)
|
||||
await nameInput.clear()
|
||||
await nameInput.fill(`${entityName} (Updated)`)
|
||||
|
||||
// Save changes
|
||||
await page.getByRole('button', { name: /save|update/i }).click()
|
||||
|
||||
// Verify update
|
||||
await expect(page.getByText(/updated|success/i)).toBeVisible()
|
||||
await page.goto('/entities')
|
||||
await expect(page.getByText(`${entityName} (Updated)`)).toBeVisible()
|
||||
})
|
||||
|
||||
test('deletes entity with confirmation', async ({ page }) => {
|
||||
// Click delete button
|
||||
const firstEntity = page.getByRole('listitem').first()
|
||||
const entityName = await firstEntity.getByRole('heading').textContent()
|
||||
|
||||
await firstEntity.getByRole('button', { name: /delete/i }).click()
|
||||
|
||||
// Confirm deletion in dialog
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog.getByText(/confirm|sure/i)).toBeVisible()
|
||||
await dialog.getByRole('button', { name: /delete|confirm/i }).click()
|
||||
|
||||
// Verify entity is removed
|
||||
await expect(page.getByText(entityName!)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('searches entities', async ({ page }) => {
|
||||
// Enter search query
|
||||
const searchInput = page.getByRole('searchbox', { name: /search/i })
|
||||
await searchInput.fill('character')
|
||||
|
||||
// Wait for filtered results
|
||||
await page.waitForTimeout(500) // Debounce
|
||||
|
||||
// Verify filtered results
|
||||
const results = page.getByRole('listitem')
|
||||
const count = await results.count()
|
||||
|
||||
// All visible results should match search
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = results.nth(i)
|
||||
await expect(item.getByText(/character/i)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('filters entities by type', async ({ page }) => {
|
||||
// Select filter
|
||||
await page.getByLabel(/filter by type/i).selectOption('location')
|
||||
|
||||
// Wait for filtered results
|
||||
await page.waitForSelector('[role="listitem"]')
|
||||
|
||||
// Verify all results are locations
|
||||
const badges = page.locator('.badge')
|
||||
const count = await badges.count()
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(badges.nth(i)).toHaveText('location')
|
||||
}
|
||||
})
|
||||
|
||||
test('keyboard navigation works', async ({ page }) => {
|
||||
// Focus first interactive element
|
||||
await page.keyboard.press('Tab')
|
||||
|
||||
// Navigate through entities with arrow keys
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
|
||||
// Activate focused element with Enter
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Verify navigation worked
|
||||
await expect(page.getByRole('heading', { name: /entity/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('meets accessibility standards', async ({ page }) => {
|
||||
// Run axe accessibility scan
|
||||
const accessibilityScanResults = await new AxeBuilder({ page }).analyze()
|
||||
|
||||
// Expect no violations
|
||||
expect(accessibilityScanResults.violations).toEqual([])
|
||||
})
|
||||
|
||||
test('is responsive on mobile', async ({ page }) => {
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 })
|
||||
|
||||
// Verify mobile layout
|
||||
await expect(page.getByRole('button', { name: /menu/i })).toBeVisible()
|
||||
|
||||
// Test mobile navigation
|
||||
await page.getByRole('button', { name: /menu/i }).click()
|
||||
await expect(page.getByRole('navigation')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Entity Relationships', () => {
|
||||
test('creates relationship between entities', async ({ page }) => {
|
||||
// Navigate to first entity detail page
|
||||
await page.goto('/entities')
|
||||
await page.getByRole('listitem').first().click()
|
||||
|
||||
// Open relationship creation
|
||||
await page.getByRole('button', { name: /add relationship/i }).click()
|
||||
|
||||
// Select related entity
|
||||
await page.getByLabel(/related entity/i).fill('Location')
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Select relationship type
|
||||
await page.getByLabel(/relationship type/i).selectOption('lives_in')
|
||||
|
||||
// Save relationship
|
||||
await page.getByRole('button', { name: /create|save/i }).click()
|
||||
|
||||
// Verify relationship appears
|
||||
await expect(page.getByText(/lives_in/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('relationship section is accessible', async ({ page }) => {
|
||||
await page.goto('/entities')
|
||||
await page.getByRole('listitem').first().click()
|
||||
|
||||
// Scan relationships section
|
||||
const accessibilityScanResults = await new AxeBuilder({ page })
|
||||
.include('#relationships')
|
||||
.analyze()
|
||||
|
||||
expect(accessibilityScanResults.violations).toEqual([])
|
||||
})
|
||||
})
|
||||
86
skills/testing-next-stack/assets/examples/unit-test.ts
Normal file
86
skills/testing-next-stack/assets/examples/unit-test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Example unit test demonstrating best practices
|
||||
* for testing pure functions and business logic
|
||||
*/
|
||||
|
||||
// Example function to test
|
||||
function validateEntityName(name: string): { valid: boolean; error?: string } {
|
||||
if (!name || name.trim().length === 0) {
|
||||
return { valid: false, error: 'Name is required' }
|
||||
}
|
||||
if (name.length > 100) {
|
||||
return { valid: false, error: 'Name must be 100 characters or less' }
|
||||
}
|
||||
if (!/^[a-zA-Z0-9\s-_']+$/.test(name)) {
|
||||
return { valid: false, error: 'Name contains invalid characters' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
describe('validateEntityName', () => {
|
||||
it('accepts valid entity names', () => {
|
||||
expect(validateEntityName('John Doe').valid).toBe(true)
|
||||
expect(validateEntityName("O'Brien").valid).toBe(true)
|
||||
expect(validateEntityName('Location-123').valid).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty names', () => {
|
||||
const result = validateEntityName('')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBe('Name is required')
|
||||
})
|
||||
|
||||
it('rejects names with only whitespace', () => {
|
||||
const result = validateEntityName(' ')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBe('Name is required')
|
||||
})
|
||||
|
||||
it('rejects names exceeding max length', () => {
|
||||
const longName = 'a'.repeat(101)
|
||||
const result = validateEntityName(longName)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('100 characters')
|
||||
})
|
||||
|
||||
it('rejects names with invalid characters', () => {
|
||||
const result = validateEntityName('Name@#$')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toContain('invalid characters')
|
||||
})
|
||||
})
|
||||
|
||||
// Example async function test
|
||||
async function fetchEntityData(id: string): Promise<any> {
|
||||
const response = await fetch(`/api/entities/${id}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
describe('fetchEntityData', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
it('fetches entity data successfully', async () => {
|
||||
const mockData = { id: '1', name: 'Test Entity' }
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockData
|
||||
})
|
||||
|
||||
const result = await fetchEntityData('1')
|
||||
expect(result).toEqual(mockData)
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/entities/1')
|
||||
})
|
||||
|
||||
it('throws error on failed fetch', async () => {
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false
|
||||
})
|
||||
|
||||
await expect(fetchEntityData('1')).rejects.toThrow('Failed to fetch')
|
||||
})
|
||||
})
|
||||
50
skills/testing-next-stack/assets/playwright.config.ts
Normal file
50
skills/testing-next-stack/assets/playwright.config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:3000'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './test/e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html'],
|
||||
['json', { outputFile: 'test-results/results.json' }],
|
||||
['list']
|
||||
],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] }
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] }
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] }
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] }
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120 * 1000
|
||||
}
|
||||
})
|
||||
58
skills/testing-next-stack/assets/test-setup.ts
Normal file
58
skills/testing-next-stack/assets/test-setup.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { expect, afterEach, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Mock Next.js router
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: '/',
|
||||
query: {},
|
||||
asPath: '/'
|
||||
}),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useParams: () => ({})
|
||||
}))
|
||||
|
||||
// Mock Next.js image component
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ src, alt, ...props }: any) => {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={src} alt={alt} {...props} />
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
})
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
constructor() {}
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
unobserve() {}
|
||||
} as any
|
||||
45
skills/testing-next-stack/assets/vitest.config.ts
Normal file
45
skills/testing-next-stack/assets/vitest.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist', '.next', 'test/e2e/**'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'test/',
|
||||
'**/*.config.{ts,js}',
|
||||
'**/*.d.ts',
|
||||
'.next/',
|
||||
'dist/',
|
||||
'public/',
|
||||
'**/__mocks__/**',
|
||||
'**/types/**'
|
||||
],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@/components': path.resolve(__dirname, './src/components'),
|
||||
'@/lib': path.resolve(__dirname, './src/lib'),
|
||||
'@/hooks': path.resolve(__dirname, './src/hooks'),
|
||||
'@/types': path.resolve(__dirname, './src/types'),
|
||||
'@/test': path.resolve(__dirname, './test')
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user