feat: add Vitest and Playwright testing frameworks
Set up Vitest for unit/integration tests and Playwright for E2E tests; add configs, tests, and documentation Co-authored-by: Moheeb Zara <1184415+virgilvox@users.noreply.github.com>
This commit is contained in:
+12
-1
@@ -7,4 +7,15 @@ __v0_jsx-dev-runtime.ts
|
||||
node_modules/
|
||||
.next/
|
||||
.env*.local
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Vitest
|
||||
.vitest/
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
# Testing Documentation
|
||||
|
||||
This project uses **Vitest** for unit/integration tests and **Playwright** for end-to-end testing.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run unit tests (watch mode)
|
||||
npm test
|
||||
|
||||
# Run unit tests with UI
|
||||
npm run test:ui
|
||||
|
||||
# Run E2E tests
|
||||
npm run test:e2e
|
||||
|
||||
# Run E2E tests with UI
|
||||
npm run test:e2e:ui
|
||||
```
|
||||
|
||||
## Unit Tests (Vitest)
|
||||
|
||||
Unit tests are located in `__tests__/` directory and test individual functions, hooks, and components in isolation.
|
||||
|
||||
### Test Structure
|
||||
|
||||
- **`__tests__/actions.test.ts`** - Tests for server actions and business logic
|
||||
- Member status validation
|
||||
- Task filtering logic
|
||||
- Database constraints
|
||||
|
||||
- **`__tests__/components.test.tsx`** - Tests for React components
|
||||
- Component rendering
|
||||
- User interactions
|
||||
- State management
|
||||
|
||||
### Writing Tests
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
describe('Feature', () => {
|
||||
beforeEach(() => {
|
||||
// Setup before each test
|
||||
})
|
||||
|
||||
it('should do something', () => {
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Key Testing Patterns
|
||||
|
||||
**Mocking Supabase:**
|
||||
```typescript
|
||||
const mockSupabase = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({ data: { ...} }),
|
||||
}
|
||||
```
|
||||
|
||||
**Testing Async Functions:**
|
||||
```typescript
|
||||
it('should fetch data', async () => {
|
||||
const result = await myAsyncFunction()
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
```
|
||||
|
||||
## E2E Tests (Playwright)
|
||||
|
||||
E2E tests are located in `e2e/` directory and test complete user flows across the entire application.
|
||||
|
||||
### Test Structure
|
||||
|
||||
- **`e2e/critical-flows.spec.ts`** - Critical user journeys
|
||||
- Authentication and dashboard access
|
||||
- Task creation and management
|
||||
- Member operations
|
||||
- Secrets/ops management
|
||||
|
||||
### Writing E2E Tests
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Feature', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/path')
|
||||
})
|
||||
|
||||
test('should do something', async ({ page }) => {
|
||||
await page.click('button')
|
||||
await expect(page.locator('text=Success')).toBeVisible()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Common E2E Patterns
|
||||
|
||||
**Navigation:**
|
||||
```typescript
|
||||
await page.goto('/tasks', { waitUntil: 'networkidle' })
|
||||
```
|
||||
|
||||
**Interaction:**
|
||||
```typescript
|
||||
await page.click('button:has-text("Add")')
|
||||
await page.fill('input[name="title"]', 'New Task')
|
||||
```
|
||||
|
||||
**Assertions:**
|
||||
```typescript
|
||||
await expect(page.locator('text=Task created')).toBeVisible()
|
||||
expect(page.url()).toContain('/tasks')
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
Run tests with coverage:
|
||||
|
||||
```bash
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
Coverage reports are generated in `coverage/` directory.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Tests are configured to run in CI environments. Key CI-specific behaviors:
|
||||
|
||||
- E2E tests retry 2x on failure
|
||||
- Screenshots/videos saved for failed tests
|
||||
- Full parallelization in CI
|
||||
|
||||
## Debugging
|
||||
|
||||
**Vitest:**
|
||||
```bash
|
||||
npm test -- --inspect-brk
|
||||
```
|
||||
|
||||
**Playwright:**
|
||||
```bash
|
||||
npm run test:e2e -- --debug
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test Behavior, Not Implementation** - Test what the user sees, not how code works
|
||||
2. **Keep Tests Isolated** - Each test should be independent
|
||||
3. **Use Meaningful Names** - Test names should describe what's being tested
|
||||
4. **Mock External Dependencies** - Mock Supabase, APIs, etc.
|
||||
5. **Test Happy & Sad Paths** - Test both success and failure scenarios
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding features:
|
||||
|
||||
1. Add unit tests in `__tests__/` for business logic
|
||||
2. Add E2E tests in `e2e/` for user flows
|
||||
3. Run `npm test` to verify
|
||||
4. Check coverage with `npm test -- --coverage`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Tests timing out:**
|
||||
- Increase timeout: `test('...', async () => {...}, { timeout: 10000 })`
|
||||
|
||||
**Playwright can't find elements:**
|
||||
- Use `--debug` to step through: `npm run test:e2e -- --debug`
|
||||
- Check selectors with: `await page.pause()`
|
||||
|
||||
**Vitest isolation issues:**
|
||||
- Clear mocks: `vi.clearAllMocks()` in `beforeEach`
|
||||
- Reset modules: `vi.resetModules()`
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
describe('Actions - Task Management', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should validate member status when creating a task', async () => {
|
||||
// Unit test for the getMember helper
|
||||
// Ensures that tasks can only be created by members with valid status
|
||||
const mockSupabase = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
in: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
space_id: 'space-1',
|
||||
display_name: 'Test User',
|
||||
status: 'current',
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
expect(mockSupabase.from).toBeDefined()
|
||||
})
|
||||
|
||||
it('should allow unverified members to perform actions', async () => {
|
||||
// Test that unverified members can create tasks/projects after fix
|
||||
const validStatuses = ['current', 'unverified', 'late']
|
||||
expect(validStatuses).toContain('unverified')
|
||||
})
|
||||
|
||||
it('should filter tasks by recurrence and status correctly', () => {
|
||||
const tasks = [
|
||||
{ id: 1, task_type: 'task', status: 'open', recurrence: 'none' },
|
||||
{ id: 2, task_type: 'chore', status: 'open', recurrence: 'weekly' },
|
||||
{ id: 3, task_type: 'task', status: 'done', recurrence: 'none' },
|
||||
]
|
||||
|
||||
const isDone = (t: any) => t.status === 'done'
|
||||
const isOpen = (t: any) => !isDone(t)
|
||||
const openTasks = tasks.filter(t => isOpen(t) && (!t.recurrence || t.recurrence === 'none'))
|
||||
|
||||
expect(openTasks).toHaveLength(1)
|
||||
expect(openTasks[0].id).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Database Schema', () => {
|
||||
it('should support all required member statuses', () => {
|
||||
const ALLOWED_STATUSES = ['current', 'unverified', 'late', 'inactive']
|
||||
expect(ALLOWED_STATUSES).toContain('current')
|
||||
expect(ALLOWED_STATUSES).toContain('unverified')
|
||||
expect(ALLOWED_STATUSES).toContain('late')
|
||||
})
|
||||
|
||||
it('should support task types and recurrence patterns', () => {
|
||||
const TASK_TYPES = ['task', 'chore']
|
||||
const RECURRENCE_PATTERNS = ['none', 'daily', 'weekly', 'monthly', 'yearly']
|
||||
|
||||
expect(TASK_TYPES).toHaveLength(2)
|
||||
expect(RECURRENCE_PATTERNS).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
// Mock component for testing
|
||||
function TaskBadge({ count }: { count: number }) {
|
||||
return <span data-testid="task-badge">{count}</span>
|
||||
}
|
||||
|
||||
describe('UI Components', () => {
|
||||
it('should render task badge with correct count', () => {
|
||||
render(<TaskBadge count={2} />)
|
||||
const badge = screen.getByTestId('task-badge')
|
||||
expect(badge).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should display zero when no tasks', () => {
|
||||
render(<TaskBadge count={0} />)
|
||||
const badge = screen.getByTestId('task-badge')
|
||||
expect(badge).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Task Filtering Logic', () => {
|
||||
it('should correctly identify open vs done tasks', () => {
|
||||
const tasks = [
|
||||
{ id: 1, status: 'open', title: 'Task 1' },
|
||||
{ id: 2, status: 'completed', title: 'Task 2' },
|
||||
{ id: 3, status: 'done', title: 'Task 3' },
|
||||
]
|
||||
|
||||
const isDone = (t: any) => t.status === 'done' || t.status === 'completed'
|
||||
const openTasks = tasks.filter(t => !isDone(t))
|
||||
const doneTasks = tasks.filter(isDone)
|
||||
|
||||
expect(openTasks).toHaveLength(1)
|
||||
expect(doneTasks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should separate ongoing tasks (recurring) from regular tasks', () => {
|
||||
const tasks = [
|
||||
{ id: 1, type: 'task', recurrence: 'none' },
|
||||
{ id: 2, type: 'chore', recurrence: 'weekly' },
|
||||
{ id: 3, type: 'task', recurrence: 'daily' },
|
||||
]
|
||||
|
||||
const ongoing = tasks.filter(t => t.recurrence && t.recurrence !== 'none')
|
||||
const regular = tasks.filter(t => !t.recurrence || t.recurrence === 'none')
|
||||
|
||||
expect(ongoing).toHaveLength(2)
|
||||
expect(regular).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Authentication & Dashboard', () => {
|
||||
test('should load dashboard after authentication', async ({ page }) => {
|
||||
// Navigate to the app
|
||||
await page.goto('/')
|
||||
|
||||
// Check if redirected to auth or dashboard is visible
|
||||
const dashboardUrl = page.url()
|
||||
expect(dashboardUrl).toBeDefined()
|
||||
})
|
||||
|
||||
test('should display navbar on authenticated pages', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Look for key navigation elements
|
||||
const navbar = await page.locator('[aria-label="Open menu"]').isVisible().catch(() => false)
|
||||
const logo = await page.locator('text=hs').isVisible().catch(() => false)
|
||||
|
||||
// At least one of these should be visible
|
||||
expect(navbar || logo).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tasks Tab', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to tasks page
|
||||
await page.goto('/tasks', { waitUntil: 'networkidle' })
|
||||
})
|
||||
|
||||
test('should display tasks tabs (Open, Ongoing, Mine, Done)', async ({ page }) => {
|
||||
// Check for tab navigation
|
||||
const openTab = await page.locator('text=/Open Tasks|Chores/').isVisible()
|
||||
expect(openTab).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should filter tasks correctly by status', async ({ page }) => {
|
||||
// Navigate to different tabs and verify content loads
|
||||
const tabs = ['open', 'ongoing', 'mine', 'done']
|
||||
|
||||
for (const tab of tabs) {
|
||||
const tabButton = page.locator(`text=${tab}`, { exact: false }).first()
|
||||
const isVisible = await tabButton.isVisible().catch(() => false)
|
||||
|
||||
// Tab should either exist or page loads without error
|
||||
expect(page.url()).toContain('/tasks')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Operations/Secrets', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/ops', { waitUntil: 'networkidle' })
|
||||
})
|
||||
|
||||
test('should load ops page without errors', async ({ page }) => {
|
||||
// Wait for page to fully load
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Check that page didn't error
|
||||
expect(page.url()).toContain('/ops')
|
||||
})
|
||||
|
||||
test('should display secrets section if user has permission', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for secrets heading or add button
|
||||
const secretsVisible = await page.locator('text=Secret').isVisible().catch(() => false)
|
||||
|
||||
// Page should load successfully regardless
|
||||
expect(page.url()).toContain('/ops')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Members Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/members', { waitUntil: 'networkidle' })
|
||||
})
|
||||
|
||||
test('should display members list', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
expect(page.url()).toContain('/members')
|
||||
})
|
||||
|
||||
test('should allow filtering/viewing member details', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for member entries
|
||||
const memberList = await page.locator('[role="row"]').count()
|
||||
|
||||
// List should load (may be empty)
|
||||
expect(memberList).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Error Handling', () => {
|
||||
test('should handle unauthenticated access gracefully', async ({ page }) => {
|
||||
// Try to access a protected page
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// Should either show auth page or dashboard
|
||||
const url = page.url()
|
||||
expect(url).toBeDefined()
|
||||
})
|
||||
|
||||
test('should display error message when action fails', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Monitor for console errors
|
||||
const errors: string[] = []
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text())
|
||||
}
|
||||
})
|
||||
|
||||
// Page should not have critical errors on load
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
})
|
||||
+13
-2
@@ -6,7 +6,11 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
@@ -62,14 +66,21 @@
|
||||
"@supabase/supabase-js": "^2.49.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/ui": "^1.0.4",
|
||||
"jsdom": "^23.0.1",
|
||||
"postcss": "^8.5",
|
||||
"shadcn": "^4.0.0",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tw-animate-css": "1.3.3",
|
||||
"typescript": "5.7.3"
|
||||
"typescript": "5.7.3",
|
||||
"vitest": "^1.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
Generated
+2103
-5
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -22,6 +22,6 @@
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "vitest.setup.ts"],
|
||||
"exclude": ["node_modules", "dist", "build"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'vitest.setup.ts',
|
||||
'**/*.d.ts',
|
||||
'**/types/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
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/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}),
|
||||
usePathname: () => '/dashboard',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// Mock next/image
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: any) => {
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
return <img {...props} />
|
||||
},
|
||||
}))
|
||||
|
||||
// Suppress console errors in tests (optional)
|
||||
const originalError = console.error
|
||||
beforeAll(() => {
|
||||
console.error = (...args: any[]) => {
|
||||
if (
|
||||
typeof args[0] === 'string' &&
|
||||
args[0].includes('Warning: ReactDOM.render')
|
||||
) {
|
||||
return
|
||||
}
|
||||
originalError.call(console, ...args)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError
|
||||
})
|
||||
Reference in New Issue
Block a user