0b76a4b119
- Delete Mode1Test, Mode2Test, Mode3Test, Mode5Test (keep only Mode6Test) - Delete old documentation, Docker files, and k8s configs - Add comprehensive Svelte 5 reference library (svelte-reference/) - Update ModeSwitch orchestrator: - Fix port to 3005 (never use 3000/3001) - Add API endpoints for mode switching - Add bun-types and proper tsconfig - Clean up mode folder mappings
696 lines
16 KiB
Markdown
696 lines
16 KiB
Markdown
# Svelte Testing Guide
|
|
|
|
## Overview
|
|
|
|
Testing Svelte applications involves unit testing components, integration testing with stores/context, and end-to-end testing with real browsers.
|
|
|
|
---
|
|
|
|
## 🔧 Testing Setup
|
|
|
|
### Install Dependencies
|
|
|
|
```bash
|
|
# Vitest for unit/integration tests
|
|
npm install -D vitest @testing-library/svelte @testing-library/jest-dom jsdom
|
|
|
|
# Playwright for E2E tests
|
|
npm install -D @playwright/test
|
|
npx playwright install
|
|
```
|
|
|
|
### vitest.config.ts
|
|
|
|
```typescript
|
|
import { defineConfig } from 'vitest/config';
|
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|
|
|
export default defineConfig({
|
|
plugins: [svelte({ hot: !process.env.VITEST })],
|
|
test: {
|
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
|
globals: true,
|
|
environment: 'jsdom',
|
|
setupFiles: ['./src/tests/setup.ts'],
|
|
coverage: {
|
|
reporter: ['text', 'json', 'html'],
|
|
exclude: ['node_modules/', 'src/tests/']
|
|
}
|
|
}
|
|
});
|
|
```
|
|
|
|
### Test Setup File
|
|
|
|
```typescript
|
|
// src/tests/setup.ts
|
|
import '@testing-library/jest-dom/vitest';
|
|
import { vi } from 'vitest';
|
|
|
|
// Mock SvelteKit modules
|
|
vi.mock('$app/navigation', () => ({
|
|
goto: vi.fn(),
|
|
invalidate: vi.fn(),
|
|
invalidateAll: vi.fn(),
|
|
preloadData: vi.fn(),
|
|
preloadCode: vi.fn()
|
|
}));
|
|
|
|
vi.mock('$app/stores', () => ({
|
|
page: {
|
|
subscribe: vi.fn()
|
|
},
|
|
navigating: {
|
|
subscribe: vi.fn()
|
|
}
|
|
}));
|
|
|
|
vi.mock('$app/environment', () => ({
|
|
browser: true,
|
|
dev: true,
|
|
building: false
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
## 🧪 Unit Testing Components
|
|
|
|
### Basic Component Test
|
|
|
|
```typescript
|
|
// src/lib/components/Button.test.ts
|
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import Button from './Button.svelte';
|
|
|
|
describe('Button', () => {
|
|
it('renders with text', () => {
|
|
render(Button, { props: { children: 'Click me' } });
|
|
|
|
expect(screen.getByRole('button')).toHaveTextContent('Click me');
|
|
});
|
|
|
|
it('applies variant class', () => {
|
|
render(Button, {
|
|
props: {
|
|
variant: 'primary',
|
|
children: 'Primary'
|
|
}
|
|
});
|
|
|
|
expect(screen.getByRole('button')).toHaveClass('btn-primary');
|
|
});
|
|
|
|
it('handles click events', async () => {
|
|
const onclick = vi.fn();
|
|
|
|
render(Button, {
|
|
props: {
|
|
onclick,
|
|
children: 'Click'
|
|
}
|
|
});
|
|
|
|
await fireEvent.click(screen.getByRole('button'));
|
|
|
|
expect(onclick).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('is disabled when loading', () => {
|
|
render(Button, {
|
|
props: {
|
|
loading: true,
|
|
children: 'Submit'
|
|
}
|
|
});
|
|
|
|
expect(screen.getByRole('button')).toBeDisabled();
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing with Snippets (Svelte 5)
|
|
|
|
```typescript
|
|
// src/lib/components/Card.test.ts
|
|
import { render, screen } from '@testing-library/svelte';
|
|
import { describe, it, expect } from 'vitest';
|
|
import Card from './Card.svelte';
|
|
|
|
// Helper to create snippet-like content
|
|
const createCardWithContent = (title: string, content: string) => {
|
|
// For Svelte 5, you may need a wrapper component
|
|
return {
|
|
component: Card,
|
|
props: {
|
|
title
|
|
},
|
|
// Slot content for testing
|
|
slots: {
|
|
default: content
|
|
}
|
|
};
|
|
};
|
|
|
|
describe('Card', () => {
|
|
it('renders title and content', () => {
|
|
render(Card, {
|
|
props: { title: 'Card Title' }
|
|
// Note: Testing snippets may require wrapper components
|
|
});
|
|
|
|
expect(screen.getByText('Card Title')).toBeInTheDocument();
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Reactive State
|
|
|
|
```typescript
|
|
// src/lib/components/Counter.test.ts
|
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
import { describe, it, expect } from 'vitest';
|
|
import Counter from './Counter.svelte';
|
|
|
|
describe('Counter', () => {
|
|
it('increments count on click', async () => {
|
|
render(Counter);
|
|
|
|
const button = screen.getByRole('button', { name: /increment/i });
|
|
const display = screen.getByTestId('count');
|
|
|
|
expect(display).toHaveTextContent('0');
|
|
|
|
await fireEvent.click(button);
|
|
|
|
expect(display).toHaveTextContent('1');
|
|
|
|
await fireEvent.click(button);
|
|
await fireEvent.click(button);
|
|
|
|
expect(display).toHaveTextContent('3');
|
|
});
|
|
|
|
it('starts with initial value', () => {
|
|
render(Counter, { props: { initialValue: 10 } });
|
|
|
|
expect(screen.getByTestId('count')).toHaveTextContent('10');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Async Components
|
|
|
|
```typescript
|
|
// src/lib/components/UserProfile.test.ts
|
|
import { render, screen, waitFor } from '@testing-library/svelte';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import UserProfile from './UserProfile.svelte';
|
|
|
|
// Mock fetch
|
|
const mockFetch = vi.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
describe('UserProfile', () => {
|
|
beforeEach(() => {
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
it('shows loading state', () => {
|
|
mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves
|
|
|
|
render(UserProfile, { props: { userId: '1' } });
|
|
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
});
|
|
|
|
it('displays user data after loading', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ name: 'John Doe', email: 'john@example.com' })
|
|
});
|
|
|
|
render(UserProfile, { props: { userId: '1' } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText('john@example.com')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error on fetch failure', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
|
|
render(UserProfile, { props: { userId: '1' } });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load user');
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 🏪 Testing Stores
|
|
|
|
### Testing .svelte.ts Stores
|
|
|
|
```typescript
|
|
// src/lib/stores/counter.svelte.ts
|
|
export class Counter {
|
|
count = $state(0);
|
|
doubled = $derived(this.count * 2);
|
|
|
|
increment() {
|
|
this.count++;
|
|
}
|
|
|
|
decrement() {
|
|
this.count--;
|
|
}
|
|
|
|
reset() {
|
|
this.count = 0;
|
|
}
|
|
}
|
|
|
|
// src/lib/stores/counter.test.ts
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { Counter } from './counter.svelte';
|
|
|
|
describe('Counter Store', () => {
|
|
let counter: Counter;
|
|
|
|
beforeEach(() => {
|
|
counter = new Counter();
|
|
});
|
|
|
|
it('starts at 0', () => {
|
|
expect(counter.count).toBe(0);
|
|
});
|
|
|
|
it('increments', () => {
|
|
counter.increment();
|
|
expect(counter.count).toBe(1);
|
|
|
|
counter.increment();
|
|
counter.increment();
|
|
expect(counter.count).toBe(3);
|
|
});
|
|
|
|
it('decrements', () => {
|
|
counter.count = 5;
|
|
counter.decrement();
|
|
expect(counter.count).toBe(4);
|
|
});
|
|
|
|
it('computes doubled value', () => {
|
|
counter.count = 5;
|
|
expect(counter.doubled).toBe(10);
|
|
|
|
counter.increment();
|
|
expect(counter.doubled).toBe(12);
|
|
});
|
|
|
|
it('resets to 0', () => {
|
|
counter.count = 100;
|
|
counter.reset();
|
|
expect(counter.count).toBe(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Async Stores
|
|
|
|
```typescript
|
|
// src/lib/stores/users.svelte.ts
|
|
export class UsersStore {
|
|
users = $state<User[]>([]);
|
|
loading = $state(false);
|
|
error = $state<string | null>(null);
|
|
|
|
async fetchUsers() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
|
|
try {
|
|
const response = await fetch('/api/users');
|
|
if (!response.ok) throw new Error('Failed to fetch');
|
|
this.users = await response.json();
|
|
} catch (e) {
|
|
this.error = e instanceof Error ? e.message : 'Unknown error';
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// src/lib/stores/users.test.ts
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { UsersStore } from './users.svelte';
|
|
|
|
const mockFetch = vi.fn();
|
|
global.fetch = mockFetch;
|
|
|
|
describe('UsersStore', () => {
|
|
let store: UsersStore;
|
|
|
|
beforeEach(() => {
|
|
store = new UsersStore();
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
it('fetches users successfully', async () => {
|
|
const mockUsers = [
|
|
{ id: '1', name: 'John' },
|
|
{ id: '2', name: 'Jane' }
|
|
];
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockUsers)
|
|
});
|
|
|
|
await store.fetchUsers();
|
|
|
|
expect(store.users).toEqual(mockUsers);
|
|
expect(store.loading).toBe(false);
|
|
expect(store.error).toBeNull();
|
|
});
|
|
|
|
it('handles fetch error', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500
|
|
});
|
|
|
|
await store.fetchUsers();
|
|
|
|
expect(store.users).toEqual([]);
|
|
expect(store.error).toBe('Failed to fetch');
|
|
});
|
|
|
|
it('sets loading state during fetch', async () => {
|
|
mockFetch.mockImplementation(() => new Promise(resolve => {
|
|
setTimeout(() => resolve({
|
|
ok: true,
|
|
json: () => Promise.resolve([])
|
|
}), 100);
|
|
}));
|
|
|
|
const promise = store.fetchUsers();
|
|
|
|
expect(store.loading).toBe(true);
|
|
|
|
await promise;
|
|
|
|
expect(store.loading).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 🔗 Integration Testing
|
|
|
|
### Testing Components with Context
|
|
|
|
```typescript
|
|
// src/lib/components/ThemeConsumer.test.ts
|
|
import { render, screen } from '@testing-library/svelte';
|
|
import { describe, it, expect } from 'vitest';
|
|
import ThemeProvider from './ThemeProvider.svelte';
|
|
import ThemeConsumer from './ThemeConsumer.svelte';
|
|
|
|
// Wrapper component for testing
|
|
import TestWrapper from './TestWrapper.svelte';
|
|
|
|
describe('ThemeConsumer', () => {
|
|
it('uses theme from provider', () => {
|
|
// TestWrapper.svelte wraps children in ThemeProvider
|
|
render(TestWrapper, {
|
|
props: {
|
|
component: ThemeConsumer,
|
|
theme: 'dark'
|
|
}
|
|
});
|
|
|
|
expect(screen.getByTestId('theme-display')).toHaveTextContent('dark');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Form Submissions
|
|
|
|
```typescript
|
|
// src/lib/components/LoginForm.test.ts
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import LoginForm from './LoginForm.svelte';
|
|
|
|
describe('LoginForm', () => {
|
|
it('submits form with credentials', async () => {
|
|
const onsubmit = vi.fn();
|
|
|
|
render(LoginForm, { props: { onsubmit } });
|
|
|
|
await fireEvent.input(
|
|
screen.getByLabelText('Email'),
|
|
{ target: { value: 'test@example.com' } }
|
|
);
|
|
|
|
await fireEvent.input(
|
|
screen.getByLabelText('Password'),
|
|
{ target: { value: 'password123' } }
|
|
);
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: /login/i }));
|
|
|
|
expect(onsubmit).toHaveBeenCalledWith({
|
|
email: 'test@example.com',
|
|
password: 'password123'
|
|
});
|
|
});
|
|
|
|
it('shows validation errors', async () => {
|
|
render(LoginForm);
|
|
|
|
await fireEvent.click(screen.getByRole('button', { name: /login/i }));
|
|
|
|
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
|
expect(screen.getByText('Password is required')).toBeInTheDocument();
|
|
});
|
|
|
|
it('disables button while loading', async () => {
|
|
render(LoginForm, { props: { loading: true } });
|
|
|
|
expect(screen.getByRole('button')).toBeDisabled();
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 🌐 E2E Testing with Playwright
|
|
|
|
### playwright.config.ts
|
|
|
|
```typescript
|
|
import type { PlaywrightTestConfig } from '@playwright/test';
|
|
|
|
const config: PlaywrightTestConfig = {
|
|
webServer: {
|
|
command: 'npm run build && npm run preview',
|
|
port: 4173
|
|
},
|
|
testDir: 'tests',
|
|
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
|
|
use: {
|
|
baseURL: 'http://localhost:4173',
|
|
trace: 'on-first-retry',
|
|
screenshot: 'only-on-failure'
|
|
},
|
|
projects: [
|
|
{
|
|
name: 'chromium',
|
|
use: { browserName: 'chromium' }
|
|
},
|
|
{
|
|
name: 'firefox',
|
|
use: { browserName: 'firefox' }
|
|
},
|
|
{
|
|
name: 'webkit',
|
|
use: { browserName: 'webkit' }
|
|
}
|
|
]
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
### E2E Test Examples
|
|
|
|
```typescript
|
|
// tests/auth.test.ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test.describe('Authentication', () => {
|
|
test('user can log in', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
await page.fill('input[name="email"]', 'test@example.com');
|
|
await page.fill('input[name="password"]', 'password123');
|
|
await page.click('button[type="submit"]');
|
|
|
|
// Should redirect to dashboard
|
|
await expect(page).toHaveURL('/dashboard');
|
|
await expect(page.locator('h1')).toContainText('Dashboard');
|
|
});
|
|
|
|
test('shows error for invalid credentials', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
await page.fill('input[name="email"]', 'wrong@example.com');
|
|
await page.fill('input[name="password"]', 'wrongpassword');
|
|
await page.click('button[type="submit"]');
|
|
|
|
await expect(page.locator('[role="alert"]')).toContainText('Invalid credentials');
|
|
await expect(page).toHaveURL('/login');
|
|
});
|
|
|
|
test('protected routes redirect to login', async ({ page }) => {
|
|
await page.goto('/dashboard');
|
|
|
|
await expect(page).toHaveURL('/login');
|
|
});
|
|
});
|
|
|
|
// tests/navigation.test.ts
|
|
test.describe('Navigation', () => {
|
|
test('main navigation works', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
await page.click('a[href="/about"]');
|
|
await expect(page).toHaveURL('/about');
|
|
await expect(page.locator('h1')).toContainText('About');
|
|
|
|
await page.click('a[href="/contact"]');
|
|
await expect(page).toHaveURL('/contact');
|
|
await expect(page.locator('h1')).toContainText('Contact');
|
|
});
|
|
|
|
test('back button works', async ({ page }) => {
|
|
await page.goto('/');
|
|
await page.click('a[href="/about"]');
|
|
await page.goBack();
|
|
|
|
await expect(page).toHaveURL('/');
|
|
});
|
|
});
|
|
|
|
// tests/forms.test.ts
|
|
test.describe('Contact Form', () => {
|
|
test('submits form successfully', async ({ page }) => {
|
|
await page.goto('/contact');
|
|
|
|
await page.fill('input[name="name"]', 'John Doe');
|
|
await page.fill('input[name="email"]', 'john@example.com');
|
|
await page.fill('textarea[name="message"]', 'Hello, this is a test message');
|
|
|
|
await page.click('button[type="submit"]');
|
|
|
|
await expect(page.locator('.success-message')).toBeVisible();
|
|
await expect(page.locator('.success-message')).toContainText('Message sent');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Testing Accessibility with Playwright
|
|
|
|
```typescript
|
|
// tests/accessibility.test.ts
|
|
import { test, expect } from '@playwright/test';
|
|
import AxeBuilder from '@axe-core/playwright';
|
|
|
|
test.describe('Accessibility', () => {
|
|
test('home page passes axe', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
const results = await new AxeBuilder({ page }).analyze();
|
|
|
|
expect(results.violations).toEqual([]);
|
|
});
|
|
|
|
test('login page passes axe', async ({ page }) => {
|
|
await page.goto('/login');
|
|
|
|
const results = await new AxeBuilder({ page })
|
|
.exclude('.third-party-widget') // Exclude elements you can't control
|
|
.analyze();
|
|
|
|
expect(results.violations).toEqual([]);
|
|
});
|
|
|
|
test('keyboard navigation works', async ({ page }) => {
|
|
await page.goto('/');
|
|
|
|
// Tab through focusable elements
|
|
await page.keyboard.press('Tab');
|
|
await expect(page.locator(':focus')).toHaveAttribute('href', '/');
|
|
|
|
await page.keyboard.press('Tab');
|
|
await expect(page.locator(':focus')).toHaveText('About');
|
|
|
|
// Can activate with Enter
|
|
await page.keyboard.press('Enter');
|
|
await expect(page).toHaveURL('/about');
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Package.json Scripts
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"test": "vitest",
|
|
"test:unit": "vitest run",
|
|
"test:watch": "vitest",
|
|
"test:coverage": "vitest run --coverage",
|
|
"test:e2e": "playwright test",
|
|
"test:e2e:ui": "playwright test --ui",
|
|
"test:all": "npm run test:unit && npm run test:e2e"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Testing Checklist
|
|
|
|
### Unit Tests
|
|
- [ ] All components have tests
|
|
- [ ] Props and variants tested
|
|
- [ ] Event handlers tested
|
|
- [ ] Edge cases covered
|
|
- [ ] Async behavior tested
|
|
|
|
### Integration Tests
|
|
- [ ] Store interactions tested
|
|
- [ ] Context providers tested
|
|
- [ ] Form submissions tested
|
|
- [ ] API calls mocked
|
|
|
|
### E2E Tests
|
|
- [ ] Critical user flows covered
|
|
- [ ] Authentication tested
|
|
- [ ] Form submissions tested
|
|
- [ ] Error states tested
|
|
- [ ] Accessibility automated
|