Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
604 lines
14 KiB
Markdown
604 lines
14 KiB
Markdown
# Vitest: Next-Gen Testing mit Browser Mode
|
||
|
||
**Meta-Description:** Vitest als Vite-native Testing Framework. Browser Mode, Component Testing, TypeScript-Support und Migration von Jest.
|
||
|
||
**Keywords:** Vitest, Testing, Browser Mode, Component Testing, Playwright, React Testing, TypeScript, Jest Alternative
|
||
|
||
---
|
||
|
||
## Einführung
|
||
|
||
Vitest ist das **native Testing-Framework für Vite** und hat Jest in modernen Projekten weitgehend abgelöst. Mit **Browser Mode** testet Vitest Components in echten Browsern – ohne JSDOM-Limitierungen.
|
||
|
||
---
|
||
|
||
## Warum Vitest?
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ VITEST vs JEST │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ VITEST JEST │
|
||
│ ──────────────────── ──────────────────── │
|
||
│ Vite-Native Babel-basiert │
|
||
│ ESM First CommonJS Default │
|
||
│ Shared Config mit Vite Separate Config │
|
||
│ Hot Module Replacement Full Restart │
|
||
│ Browser Mode JSDOM only │
|
||
│ │
|
||
│ Performance: │
|
||
│ ├── Instant Watch Mode │
|
||
│ ├── Native TypeScript Support │
|
||
│ ├── Out-of-Box ESM Support │
|
||
│ └── Shared Vite Pipeline │
|
||
│ │
|
||
│ Bundle: ~5MB vs ~65MB (Jest + Babel) │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Quick Setup
|
||
|
||
```bash
|
||
# Installation
|
||
npm install -D vitest
|
||
|
||
# Browser Mode (optional)
|
||
npm install -D @vitest/browser playwright
|
||
|
||
# UI (optional)
|
||
npm install -D @vitest/ui
|
||
```
|
||
|
||
```typescript
|
||
// vite.config.ts
|
||
import { defineConfig } from 'vite';
|
||
import react from '@vitejs/plugin-react';
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
test: {
|
||
globals: true,
|
||
environment: 'jsdom',
|
||
setupFiles: './src/test/setup.ts',
|
||
include: ['**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||
coverage: {
|
||
provider: 'v8',
|
||
reporter: ['text', 'html', 'lcov']
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// src/test/setup.ts
|
||
import '@testing-library/jest-dom';
|
||
import { cleanup } from '@testing-library/react';
|
||
import { afterEach } from 'vitest';
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Basic Unit Testing
|
||
|
||
```typescript
|
||
// src/utils/math.ts
|
||
export function add(a: number, b: number): number {
|
||
return a + b;
|
||
}
|
||
|
||
export function multiply(a: number, b: number): number {
|
||
return a * b;
|
||
}
|
||
|
||
export async function fetchData(url: string): Promise<unknown> {
|
||
const response = await fetch(url);
|
||
return response.json();
|
||
}
|
||
|
||
// src/utils/math.test.ts
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
import { add, multiply, fetchData } from './math';
|
||
|
||
describe('Math Utils', () => {
|
||
it('should add two numbers', () => {
|
||
expect(add(2, 3)).toBe(5);
|
||
expect(add(-1, 1)).toBe(0);
|
||
});
|
||
|
||
it('should multiply two numbers', () => {
|
||
expect(multiply(2, 3)).toBe(6);
|
||
expect(multiply(0, 100)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('fetchData', () => {
|
||
it('should fetch and parse JSON', async () => {
|
||
const mockData = { name: 'Test' };
|
||
|
||
// Mock fetch
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
json: () => Promise.resolve(mockData)
|
||
}));
|
||
|
||
const result = await fetchData('/api/test');
|
||
expect(result).toEqual(mockData);
|
||
|
||
vi.unstubAllGlobals();
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Component Testing (JSDOM)
|
||
|
||
```typescript
|
||
// src/components/Counter.tsx
|
||
import { useState } from 'react';
|
||
|
||
interface CounterProps {
|
||
initialValue?: number;
|
||
onCountChange?: (count: number) => void;
|
||
}
|
||
|
||
export function Counter({ initialValue = 0, onCountChange }: CounterProps) {
|
||
const [count, setCount] = useState(initialValue);
|
||
|
||
const increment = () => {
|
||
const newCount = count + 1;
|
||
setCount(newCount);
|
||
onCountChange?.(newCount);
|
||
};
|
||
|
||
const decrement = () => {
|
||
const newCount = count - 1;
|
||
setCount(newCount);
|
||
onCountChange?.(newCount);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<span data-testid="count">{count}</span>
|
||
<button onClick={decrement}>-</button>
|
||
<button onClick={increment}>+</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// src/components/Counter.test.tsx
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
import { render, screen, fireEvent } from '@testing-library/react';
|
||
import { Counter } from './Counter';
|
||
|
||
describe('Counter', () => {
|
||
it('renders with initial value', () => {
|
||
render(<Counter initialValue={5} />);
|
||
expect(screen.getByTestId('count')).toHaveTextContent('5');
|
||
});
|
||
|
||
it('increments count when + clicked', () => {
|
||
render(<Counter />);
|
||
|
||
fireEvent.click(screen.getByText('+'));
|
||
|
||
expect(screen.getByTestId('count')).toHaveTextContent('1');
|
||
});
|
||
|
||
it('decrements count when - clicked', () => {
|
||
render(<Counter initialValue={5} />);
|
||
|
||
fireEvent.click(screen.getByText('-'));
|
||
|
||
expect(screen.getByTestId('count')).toHaveTextContent('4');
|
||
});
|
||
|
||
it('calls onCountChange callback', () => {
|
||
const handleChange = vi.fn();
|
||
render(<Counter onCountChange={handleChange} />);
|
||
|
||
fireEvent.click(screen.getByText('+'));
|
||
|
||
expect(handleChange).toHaveBeenCalledWith(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Browser Mode (Real Browser Testing)
|
||
|
||
```bash
|
||
# Browser Mode initialisieren
|
||
npx vitest init browser
|
||
```
|
||
|
||
```typescript
|
||
// vitest.config.ts
|
||
import { defineConfig } from 'vitest/config';
|
||
|
||
export default defineConfig({
|
||
test: {
|
||
browser: {
|
||
enabled: true,
|
||
provider: 'playwright', // oder 'webdriverio'
|
||
name: 'chromium',
|
||
headless: true
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// src/components/Dialog.browser.test.tsx
|
||
import { describe, it, expect } from 'vitest';
|
||
import { render } from 'vitest-browser-react';
|
||
import { Dialog } from './Dialog';
|
||
|
||
describe('Dialog (Browser Mode)', () => {
|
||
it('should open and close correctly', async () => {
|
||
const screen = render(
|
||
<Dialog trigger={<button>Open</button>}>
|
||
<p>Dialog Content</p>
|
||
</Dialog>
|
||
);
|
||
|
||
// Initial: Dialog geschlossen
|
||
await expect.element(screen.getByText('Dialog Content')).not.toBeVisible();
|
||
|
||
// Öffnen
|
||
await screen.getByText('Open').click();
|
||
await expect.element(screen.getByText('Dialog Content')).toBeVisible();
|
||
|
||
// Schließen mit Escape
|
||
await screen.getByRole('dialog').press('Escape');
|
||
await expect.element(screen.getByText('Dialog Content')).not.toBeVisible();
|
||
});
|
||
|
||
it('should focus trap correctly', async () => {
|
||
const screen = render(
|
||
<Dialog trigger={<button>Open</button>}>
|
||
<input data-testid="input1" />
|
||
<input data-testid="input2" />
|
||
<button>Close</button>
|
||
</Dialog>
|
||
);
|
||
|
||
await screen.getByText('Open').click();
|
||
|
||
// Fokus sollte im Dialog gefangen sein
|
||
const input1 = screen.getByTestId('input1');
|
||
const input2 = screen.getByTestId('input2');
|
||
|
||
await input1.focus();
|
||
await input1.press('Tab');
|
||
await expect.element(input2).toBeFocused();
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Mocking
|
||
|
||
```typescript
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
||
// 1. Function Mocking
|
||
const mockFn = vi.fn();
|
||
mockFn.mockReturnValue(42);
|
||
mockFn.mockResolvedValue({ data: 'test' });
|
||
mockFn.mockImplementation((x) => x * 2);
|
||
|
||
// 2. Module Mocking
|
||
vi.mock('./api', () => ({
|
||
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
|
||
updateUser: vi.fn().mockResolvedValue({ success: true })
|
||
}));
|
||
|
||
// 3. Partial Mocking
|
||
vi.mock('./utils', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('./utils')>();
|
||
return {
|
||
...actual,
|
||
complexFunction: vi.fn().mockReturnValue('mocked')
|
||
};
|
||
});
|
||
|
||
// 4. Timer Mocking
|
||
describe('Timer Tests', () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it('should handle setTimeout', async () => {
|
||
const callback = vi.fn();
|
||
|
||
setTimeout(callback, 1000);
|
||
|
||
expect(callback).not.toHaveBeenCalled();
|
||
|
||
vi.advanceTimersByTime(1000);
|
||
|
||
expect(callback).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('should handle intervals', () => {
|
||
const callback = vi.fn();
|
||
|
||
setInterval(callback, 100);
|
||
|
||
vi.advanceTimersByTime(350);
|
||
|
||
expect(callback).toHaveBeenCalledTimes(3);
|
||
});
|
||
});
|
||
|
||
// 5. Date Mocking
|
||
it('should mock current date', () => {
|
||
const mockDate = new Date('2026-01-15');
|
||
vi.setSystemTime(mockDate);
|
||
|
||
expect(new Date().toISOString()).toContain('2026-01-15');
|
||
|
||
vi.useRealTimers();
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Snapshot Testing
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest';
|
||
import { render } from '@testing-library/react';
|
||
import { UserCard } from './UserCard';
|
||
|
||
describe('UserCard Snapshots', () => {
|
||
it('matches snapshot for basic user', () => {
|
||
const { container } = render(
|
||
<UserCard
|
||
user={{
|
||
name: 'John Doe',
|
||
email: 'john@example.com',
|
||
avatar: '/avatar.jpg'
|
||
}}
|
||
/>
|
||
);
|
||
|
||
expect(container).toMatchSnapshot();
|
||
});
|
||
|
||
it('matches inline snapshot', () => {
|
||
const user = { name: 'Jane', role: 'admin' };
|
||
|
||
expect(user).toMatchInlineSnapshot(`
|
||
{
|
||
"name": "Jane",
|
||
"role": "admin",
|
||
}
|
||
`);
|
||
});
|
||
|
||
// File Snapshots
|
||
it('matches file snapshot for large data', () => {
|
||
const complexData = generateLargeDataset();
|
||
|
||
expect(complexData).toMatchFileSnapshot('./snapshots/large-data.json');
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Test Coverage
|
||
|
||
```typescript
|
||
// vitest.config.ts
|
||
import { defineConfig } from 'vitest/config';
|
||
|
||
export default defineConfig({
|
||
test: {
|
||
coverage: {
|
||
provider: 'v8', // oder 'istanbul'
|
||
reporter: ['text', 'html', 'lcov', 'json'],
|
||
reportsDirectory: './coverage',
|
||
include: ['src/**/*.{ts,tsx}'],
|
||
exclude: [
|
||
'src/**/*.test.{ts,tsx}',
|
||
'src/**/*.d.ts',
|
||
'src/test/**/*'
|
||
],
|
||
thresholds: {
|
||
statements: 80,
|
||
branches: 80,
|
||
functions: 80,
|
||
lines: 80
|
||
}
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
```bash
|
||
# Coverage generieren
|
||
npx vitest --coverage
|
||
|
||
# Watch Mode mit Coverage
|
||
npx vitest --coverage --watch
|
||
```
|
||
|
||
---
|
||
|
||
## Parallel & Sequential Tests
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest';
|
||
|
||
// Parallel (Default)
|
||
describe('Parallel Tests', () => {
|
||
it.concurrent('test 1', async () => {
|
||
await sleep(1000);
|
||
expect(true).toBe(true);
|
||
});
|
||
|
||
it.concurrent('test 2', async () => {
|
||
await sleep(1000);
|
||
expect(true).toBe(true);
|
||
});
|
||
// Beide laufen parallel → ~1s total
|
||
});
|
||
|
||
// Sequential
|
||
describe('Sequential Tests', { sequential: true }, () => {
|
||
let sharedState = 0;
|
||
|
||
it('first', () => {
|
||
sharedState = 1;
|
||
expect(sharedState).toBe(1);
|
||
});
|
||
|
||
it('second', () => {
|
||
sharedState = 2;
|
||
expect(sharedState).toBe(2);
|
||
});
|
||
});
|
||
|
||
// Test Isolation
|
||
describe.concurrent('Isolated Concurrent', () => {
|
||
// Jeder Test bekommt eigene Isolation
|
||
it('isolated 1', async ({ expect }) => {
|
||
// ...
|
||
});
|
||
|
||
it('isolated 2', async ({ expect }) => {
|
||
// ...
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## API Testing
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||
import { setupServer } from 'msw/node';
|
||
import { http, HttpResponse } from 'msw';
|
||
|
||
const server = setupServer(
|
||
http.get('/api/users', () => {
|
||
return HttpResponse.json([
|
||
{ id: 1, name: 'Alice' },
|
||
{ id: 2, name: 'Bob' }
|
||
]);
|
||
}),
|
||
|
||
http.post('/api/users', async ({ request }) => {
|
||
const body = await request.json();
|
||
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
|
||
})
|
||
);
|
||
|
||
beforeAll(() => server.listen());
|
||
afterAll(() => server.close());
|
||
|
||
describe('API Client', () => {
|
||
it('fetches users', async () => {
|
||
const response = await fetch('/api/users');
|
||
const users = await response.json();
|
||
|
||
expect(users).toHaveLength(2);
|
||
expect(users[0].name).toBe('Alice');
|
||
});
|
||
|
||
it('creates a user', async () => {
|
||
const response = await fetch('/api/users', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name: 'Charlie' })
|
||
});
|
||
|
||
expect(response.status).toBe(201);
|
||
|
||
const user = await response.json();
|
||
expect(user.name).toBe('Charlie');
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Migration von Jest
|
||
|
||
```typescript
|
||
// jest.config.js → vitest.config.ts
|
||
|
||
// Jest
|
||
module.exports = {
|
||
testEnvironment: 'jsdom',
|
||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
||
moduleNameMapper: {
|
||
'^@/(.*)$': '<rootDir>/src/$1'
|
||
}
|
||
};
|
||
|
||
// Vitest
|
||
import { defineConfig } from 'vitest/config';
|
||
|
||
export default defineConfig({
|
||
test: {
|
||
environment: 'jsdom',
|
||
setupFiles: './src/setupTests.ts',
|
||
alias: {
|
||
'@': './src'
|
||
}
|
||
}
|
||
});
|
||
|
||
// API-Änderungen
|
||
// Jest: jest.fn() → Vitest: vi.fn()
|
||
// Jest: jest.mock() → Vitest: vi.mock()
|
||
// Jest: jest.spyOn() → Vitest: vi.spyOn()
|
||
```
|
||
|
||
---
|
||
|
||
## Fazit
|
||
|
||
Vitest bietet:
|
||
|
||
1. **Vite-Native**: Shared Config, HMR, sofortiger Start
|
||
2. **Browser Mode**: Echte Browser statt JSDOM
|
||
3. **TypeScript-First**: Out-of-Box Support ohne Config
|
||
4. **Jest-kompatibel**: Einfache Migration
|
||
|
||
Für jedes Vite-Projekt ist Vitest die natürliche Wahl.
|
||
|
||
---
|
||
|
||
## Bildprompts
|
||
|
||
1. "Test runner showing green checkmarks, development workflow visualization"
|
||
2. "Browser and code split screen, component testing in real browser concept"
|
||
3. "Fast forward icon with test results, instant feedback development loop"
|
||
|
||
---
|
||
|
||
## Quellen
|
||
|
||
- [Vitest Documentation](https://vitest.dev/)
|
||
- [Vitest Browser Mode](https://vitest.dev/guide/browser/)
|
||
- [vitest-browser-react](https://github.com/vitest-dev/vitest-browser-react)
|
||
- [Vitest vs JSDOM - InfoQ](https://www.infoq.com/news/2025/06/vitest-browser-mode-jsdom/)
|