Spezielle Komponententests
Dieses Dokument beschreibt spezifische Teststrategien für komplexere Komponententypen in der smolitux UI-Bibliothek.
1. Form-Komponenten
Form-Komponenten stellen besondere Herausforderungen dar, da sie Benutzereingaben verarbeiten, Validierungen durchführen und komplexe Zustände verwalten müssen.
1.1 Zu testende Aspekte bei Form-Komponenten
-
Validierung und Fehlerbehandlung
- Tests mit gültigen und ungültigen Eingaben
- Anzeige und Format von Fehlermeldungen
- Form-übergreifende Validierungsregeln
-
Zustände und Interaktionen
- Disabled/Enabled-Zustand
- Fokus- und Blur-Events
- Placeholder-Text und Hinweistexte
-
Barrierefreiheit
- ARIA-Attribute und Rollen
- Label-Verknüpfung
- Unterstützung von Bildschirmlesern
1.2 Beispiel: FormControl mit verschachtelten Elementen
// FormControl.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormControl } from '../FormControl';
import { Input } from '../../Input/Input';
import { Select } from '../../Select/Select';
import { Button } from '../../Button/Button';
const NestedForm = () => {
const [values, setValues] = React.useState({
name: '',
email: '',
role: ''
});
## 4. Chart-Komponenten
Chart-Komponenten erfordern spezielle Testansätze, da sie visuell komplexe Daten darstellen und mit DOM-Elementen wie SVG interagieren.
### 4.1 Zu testende Aspekte
1. **Korrekte Datenvisualisierung**
- Prüfung der generierten SVG-Elemente
- Darstellung verschiedener Datensätze
2. **Interaktivität**
- Hover-Effekte und Tooltips
- Klickbare Bereiche und Legenden
3. **Anpassbarkeit**
- Themes und Farbschemata
- Responsive Anpassung an Containergrößen
### 4.2 Beispiel: AreaChart-Komponente
```tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { AreaChart } from '../AreaChart';
// Mock für useTheme hook
jest.mock('@smolitux/theme', () => ({
useTheme: () => ({ themeMode: 'light' })
}));
describe('AreaChart', () => {
const mockData = {
id: 'testSeries',
name: 'Test Data',
data: [
{ x: 'Jan', y: 100 },
{ x: 'Feb', y: 150 },
{ x: 'Mar', y: 200 },
{ x: 'Apr', y: 120 },
{ x: 'May', y: 180 }
]
};
test('renders chart with correct SVG elements', () => {
render(<AreaChart data={mockData} />);
// SVG sollte gerendert werden
expect(screen.getByRole('img')).toBeInTheDocument();
// Prüfen auf spezifische SVG-Elemente
const svg = document.querySelector('svg');
expect(svg).toBeInTheDocument();
// Hintergrund-Rechteck sollte existieren
expect(svg?.querySelector('rect')).toBeInTheDocument();
// Prüfen der Datenpunkte (Path-Elemente für Linien)
expect(svg?.querySelectorAll('path').length).toBeGreaterThan(0);
});
test('renders chart with title', () => {
render(<AreaChart data={mockData} title="Test Chart" />);
expect(screen.getByText('Test Chart')).toBeInTheDocument();
});
test('passes height and width properties correctly', () => {
render(<AreaChart data={mockData} height={400} width={800} />);
const svg = document.querySelector('svg');
expect(svg).toHaveAttribute('height', '400');
expect(svg).toHaveAttribute('width', '800');
});
test('shows points when showPoints is true', () => {
render(<AreaChart data={mockData} showPoints />);
const svg = document.querySelector('svg');
expect(svg?.querySelectorAll('circle').length).toBeGreaterThan(0);
});
test('hides points when showPoints is false', () => {
render(<AreaChart data={mockData} showPoints={false} />);
const svg = document.querySelector('svg');
expect(svg?.querySelectorAll('circle').length).toBe(0);
});
test('renders legend when showLegend is true', () => {
render(<AreaChart data={[mockData, {...mockData, id: 'second', name: 'Second Series'}]} showLegend />);
expect(screen.getByText('Test Data')).toBeInTheDocument();
expect(screen.getByText('Second Series')).toBeInTheDocument();
});
});
5. Teststrategien für Context-abhängige Komponenten
Viele Komponenten hängen von Context ab, wie z.B. ThemeContext, FormContext oder AuthContext. Hier sind Strategien für das Testen solcher Komponenten:
5.1 Context-Provider verwenden
// ThemeAwareComponent.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@smolitux/theme';
import { ThemeAwareComponent } from '../ThemeAwareComponent';
describe('ThemeAwareComponent', () => {
test('renders with light theme by default', () => {
render(
<ThemeProvider>
<ThemeAwareComponent />
</ThemeProvider>
);
expect(screen.getByTestId('theme-component')).toHaveClass('bg-white');
});
test('renders with dark theme when provided', () => {
render(
<ThemeProvider initialTheme="dark">
<ThemeAwareComponent />
</ThemeProvider>
);
expect(screen.getByTestId('theme-component')).toHaveClass('bg-gray-800');
});
});
5.2 Context-Consumer testen
// FormControl.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { FormControl, useFormControl } from '../FormControl';
import { Input } from '../../Input/Input';
// Testkomponente, die den FormControl-Context verwendet
const FormControlConsumer = () => {
const context = useFormControl();
return (
<div data-testid="consumer">
{JSON.stringify({
disabled: context.disabled,
required: context.required,
hasError: context.hasError,
})}
</div>
);
};
describe('FormControl Context', () => {
test('provides correct context values to children', () => {
render(
<FormControl disabled required error="Error message">
<FormControlConsumer />
</FormControl>
);
const consumer = screen.getByTestId('consumer');
expect(consumer).toHaveTextContent(/"disabled":true/);
expect(consumer).toHaveTextContent(/"required":true/);
expect(consumer).toHaveTextContent(/"hasError":true/);
});
});
6. Tipps für benutzerdefinierte Testhelfer
6.1 Benutzerdefinierte render-Funktion mit Providern
// test-utils.tsx
import React from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { ThemeProvider } from '@smolitux/theme';
import { ToastProvider } from '@smolitux/toast';
// Benutzerdefinierte render-Funktion, die alle benötigten Provider enthält
const customRender = (
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'> & { theme?: 'light' | 'dark' }
) => {
const { theme = 'light', ...rest } = options || {};
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme={theme}>
<ToastProvider>
{children}
</ToastProvider>
</ThemeProvider>
),
...rest,
});
};
// Export aller Funktionen aus testing-library/react
export * from '@testing-library/react';
// Überschreiben der render-Methode
export { customRender as render };
6.2 Häufig benötigte Testhelfer
// test-helpers.tsx
import { fireEvent, waitFor } from '@testing-library/react';
// Helfer für Formular-Interaktionen
export const fillForm = async (getByLabelText, formData) => {
for (const [label, value] of Object.entries(formData)) {
const input = getByLabelText(label);
await fireEvent.change(input, { target: { value } });
}
};
// Helfer für Modaltests
export const openAndVerifyModal = async (getByText, getByRole, buttonText) => {
fireEvent.click(getByText(buttonText));
await waitFor(() => {
expect(getByRole('dialog')).toBeInTheDocument();
});
};
// Helfer für Datumsmanipulation in Tests
export const setTestDate = (year, month, day) => {
jest.useFakeTimers();
jest.setSystemTime(new Date(year, month, day));
return () => {
jest.useRealTimers();
};
};
const [errors, setErrors] = React.useState({
name: '',
email: ''
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors = {
name: values.name ? '' : 'Name is required',
email: values.email ? '' : 'Email is required'
};
setErrors(newErrors);
if (Object.values(newErrors).every(error => !error)) {
console.log('Form submitted:', values);
}
};
test('combines filtering and sorting correctly', async () => {
render(
<Table
data={testData}
columns={columns}
showSearch={true}
showPagination={true}
itemsPerPage={50}
/>
);
// Nach Kategorie A filtern
const filterButtons = screen.getAllByRole('button');
const categoryFilterButton = Array.from(filterButtons).find(
button => button.closest('th')?.textContent?.includes('Category')
);
if (!categoryFilterButton) throw new Error('Filter button not found');
fireEvent.click(categoryFilterButton);
// Filterwert eingeben
const filterInput = await screen.findByPlaceholderText('Filterwert...');
await userEvent.type(filterInput, 'Category A');
// Filter anwenden
fireEvent.click(screen.getByText('Anwenden'));
// Prüfen, ob nur Kategorie A angezeigt wird
await waitFor(() => {
const categoryACells = screen.getAllByText('Category A');
expect(categoryACells.length).toBeGreaterThan(1); // Header + Zeilen
expect(screen.queryByText('Category B')).not.toBeInTheDocument();
expect(screen.queryByText('Category C')).not.toBeInTheDocument();
});
// Nach Wert sortieren (aufsteigend)
const valueHeader = Array.from(screen.getAllByRole('columnheader')).find(
header => header.textContent?.includes('Value')
);
if (!valueHeader) throw new Error('Value header not found');
fireEvent.click(valueHeader);
// Ersten und letzten Wert merken, um Sortierung zu prüfen
const valueRows = screen.getAllByRole('row').slice(1); // Header überspringen
const firstValue = parseFloat(valueRows[0].cells[4].textContent?.replace('€', '') || '0');
const lastValue = parseFloat(valueRows[valueRows.length - 1].cells[4].textContent?.replace('€', '') || '0');
// Erster Wert sollte kleiner sein als der letzte
expect(firstValue).toBeLessThan(lastValue);
});
test('row selection works correctly with select all', async () => {
const onRowSelect = jest.fn();
render(
<Table
data={testData.slice(0, 20)}
columns={columns}
selectable={true}
itemsPerPage={10}
onRowSelect={onRowSelect}
/>
);
// Alle Zeilen auf der ersten Seite auswählen
const selectAllCheckbox = screen.getByRole('checkbox', { name: /select all/i });
fireEvent.click(selectAllCheckbox);
// Alle Checkboxen auf der ersten Seite sollten ausgewählt sein
const checkboxes = screen.getAllByRole('checkbox');
checkboxes.forEach(checkbox => {
expect(checkbox).toBeChecked();
});
// Callback sollte mit 10 ausgewählten Zeilen aufgerufen worden sein
expect(onRowSelect).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 1 }),
expect.objectContaining({ id: 10 })
]));
expect(onRowSelect.mock.calls[0][0].length).toBe(10);
// Zur zweiten Seite navigieren
fireEvent.click(screen.getByLabelText('Next slide') || screen.getByText('Weiter'));
// Auf Seite 2 sollten Checkboxen nicht ausgewählt sein
await waitFor(() => {
const page2Checkboxes = screen.getAllByRole('checkbox');
expect(page2Checkboxes[1]).not.toBeChecked(); // Erstes Datencheckbox
});
// Eine Zeile auf Seite 2 auswählen
const page2FirstRowCheckbox = screen.getAllByRole('checkbox')[1];
fireEvent.click(page2FirstRowCheckbox);
// Callback sollte mit 11 ausgewählten Zeilen aufgerufen worden sein
expect(onRowSelect).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 1 }),
expect.objectContaining({ id: 10 }),
expect.objectContaining({ id: 11 })
]));
expect(onRowSelect.mock.calls[1][0].length).toBe(11);
});
});
};
return (
<form onSubmit={handleSubmit} data-testid="form">
<FormControl
label="Name"
error={errors.name}
required
>
<Input
value={values.name}
onChange={(e) => setValues({...values, name: e.target.value})}
placeholder="Enter your name"
/>
</FormControl>
<FormControl
label="Email"
error={errors.email}
required
>
<Input
type="email"
value={values.email}
onChange={(e) => setValues({...values, email: e.target.value})}
placeholder="Enter your email"
/>
</FormControl>
<FormControl label="Role">
<Select
options={[
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' }
]}
value={values.role}
onChange={(e) => setValues({...values, role: e.target.value})}
/>
</FormControl>
<Button type="submit">Submit</Button>
</form>
);
};
describe('Form Integration', () => {
test('validates form fields correctly', async () => {
render(<NestedForm />);
// Submit empty form
fireEvent.submit(screen.getByTestId('form'));
// Check for error messages
expect(screen.getByText('Name is required')).toBeInTheDocument();
expect(screen.getByText('Email is required')).toBeInTheDocument();
// Fill the form
await userEvent.type(screen.getByPlaceholderText('Enter your name'), 'Test User');
await userEvent.type(screen.getByPlaceholderText('Enter your email'), 'test@example.com');
// Submit again
fireEvent.submit(screen.getByTestId('form'));
// Error messages should be gone
expect(screen.queryByText('Name is required')).not.toBeInTheDocument();
expect(screen.queryByText('Email is required')).not.toBeInTheDocument();
});
test('select component changes value correctly', async () => {
render(<NestedForm />);
// Open select dropdown
const select = screen.getByRole('combobox');
await userEvent.click(select);
// Select an option
await userEvent.click(screen.getByText('Admin'));
// Value should be updated
expect(select).toHaveValue('admin');
});
});
2. Datums- und Zeit-Komponenten
Datums- und Zeit-Komponenten stellen spezielle Herausforderungen für Tests dar, da sie mit dem aktuellen Datum/der aktuellen Zeit interagieren und komplexe Benutzerinteraktionen unterstützen müssen.
2.1 Zu testende Aspekte
-
Datumsformatierung und -validierung
- Verschiedene Datumsformate testen
- Valide und invalide Eingaben
- Lokalisierung und Internationalisierung
-
Navigation und Auswahl
- Monats- und Jahresnavigation
- Datumsauswahl via Klick
- Tastaturnavigation
-
Einschränkungen und Anpassungen
- Min/Max-Datum
- Deaktivierte Termine
- Benutzerdefinierte Datumsformatierung
2.2 Beispiel: DatePicker-Komponente
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DatePicker } from '../DatePicker';
describe('DatePicker Complex Tests', () => {
beforeEach(() => {
// Konsistentes Datum für Tests
jest.useFakeTimers();
jest.setSystemTime(new Date(2023, 0, 15)); // 15. Januar 2023
});
afterEach(() => {
jest.useRealTimers();
});
test('respects min and max date constraints', async () => {
const minDate = new Date(2023, 0, 10); // 10. Januar 2023
const maxDate = new Date(2023, 0, 20); // 20. Januar 2023
render(
<DatePicker
minDate={minDate}
maxDate={maxDate}
/>
);
// Öffne den Datepicker
fireEvent.click(screen.getByRole('textbox'));
// Vor dem minDate sollten Tage deaktiviert sein
await waitFor(() => {
const day5 = screen.getByText('5'); // 5. Januar
expect(day5.parentElement).toHaveClass('text-gray-400'); // Deaktivierte Klasse
expect(day5.parentElement).toHaveAttribute('aria-disabled', 'true');
});
// Nach dem maxDate sollten Tage deaktiviert sein
const day25 = screen.getByText('25'); // 25. Januar
expect(day25.parentElement).toHaveClass('text-gray-400'); // Deaktivierte Klasse
expect(day25.parentElement).toHaveAttribute('aria-disabled', 'true');
// Tag zwischen min und max sollte wählbar sein
const day15 = screen.getByText('15'); // 15. Januar
expect(day15.parentElement).not.toHaveClass('text-gray-400');
expect(day15.parentElement).not.toHaveAttribute('aria-disabled', 'true');
});
test('handles different date formats correctly', async () => {
const onChange = jest.fn();
render(
<DatePicker
format="dd.MM.yyyy"
onChange={onChange}
/>
);
// Öffne den Datepicker
fireEvent.click(screen.getByRole('textbox'));
// Klicke auf den heutigen Tag
await waitFor(() => {
fireEvent.click(screen.getByText('15')); // 15. Januar
});
// Input sollte im richtigen Format sein
expect(screen.getByRole('textbox')).toHaveValue('15.01.2023');
// onChange sollte mit korrektem Datum aufgerufen werden
expect(onChange).toHaveBeenCalledWith(expect.any(Date));
const calledDate = onChange.mock.calls[0][0];
expect(calledDate.getDate()).toBe(15);
expect(calledDate.getMonth()).toBe(0); // Januar
expect(calledDate.getFullYear()).toBe(2023);
});
test('allows manual date input when allowKeyboardInput is true', async () => {
const onChange = jest.fn();
render(
<DatePicker
allowKeyboardInput={true}
format="yyyy-MM-dd"
onChange={onChange}
/>
);
// Manuell Datum eingeben
const input = screen.getByRole('textbox');
await userEvent.clear(input);
await userEvent.type(input, '2023-02-20');
// Enter drücken oder blur auslösen
fireEvent.blur(input);
// onChange sollte mit korrektem Datum aufgerufen werden
expect(onChange).toHaveBeenCalledWith(expect.any(Date));
const calledDate = onChange.mock.calls[0][0];
expect(calledDate.getDate()).toBe(20);
expect(calledDate.getMonth()).toBe(1); // Februar
expect(calledDate.getFullYear()).toBe(2023);
});
});
3. Tabellenkomponenten
Tabellen sind komplexe Komponenten, die Datenfilterung, Sortierung, Paginierung und andere Funktionen unterstützen können.
3.1 Zu testende Aspekte
-
Datenoperationen
- Sortierung (aufsteigend/absteigend)
- Filterung nach verschiedenen Kriterien
- Paginierung
-
Zeilenfunktionen
- Zeilenauswahl (einzeln/mehrfach)
- Aktionen pro Zeile
- Expansion/Collapse von Details
-
Darstellungsoptionen
- Anzahl von Elementen pro Seite
- Spaltenbreite und -anpassungen
- Layoutvarianten (gestreift, mit Rahmen, etc.)
3.2 Beispiel: Erweiterte Tabellentests
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Table } from '../Table';
describe('Table Advanced Tests', () => {
const testData = Array.from({ length: 50 }).map((_, index) => ({
id: index + 1,
name: `Item ${index + 1}`,
category: index % 3 === 0 ? 'Category A' : index % 3 === 1 ? 'Category B' : 'Category C',
status: index % 4 === 0 ? 'active' : index % 4 === 1 ? 'inactive' : index % 4 === 2 ? 'pending' : 'archived',
value: Math.round(Math.random() * 1000) / 10
}));
const columns = [
{
id: 'id',
header: 'ID',
accessor: (row: any) => row.id
},
{
id: 'name',
header: 'Name',
accessor: (row: any) => row.name,
sortable: true
},
{
id: 'category',
header: 'Category',
accessor: (row: any) => row.category,
filterable: true,
sortable: true
},
{
id: 'status',
header: 'Status',
accessor: (row: any) => row.status,
filterable: true,
sortable: true
},
{
id: 'value',
header: 'Value',
accessor: (row: any) => row.value,
sortable: true,
cell: (value: number) => `${value.toFixed(1)}€`
}
];
test('pagination controls change page correctly', async () => {
render(
<Table
data={testData}
columns={columns}
showPagination={true}
itemsPerPage={10}
/>
);
// Erste Seite sollte die ersten 10 Items zeigen
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 10')).toBeInTheDocument();
expect(screen.queryByText('Item 11')).not.toBeInTheDocument();
// Zur zweiten Seite navigieren
fireEvent.click(screen.getByLabelText('Next slide') || screen.getByText('Weiter'));
// Zweite Seite sollte Items 11-20 zeigen
await waitFor(() => {
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
expect(screen.getByText('Item 11')).toBeInTheDocument();
expect(screen.getByText('Item 20')).toBeInTheDocument();
});
// Zur letzten Seite springen
const lastPageButton = Array.from(screen.getAllByRole('button')).find(
button => button.textContent === '5'
);
if (lastPageButton) {
fireEvent.click(lastPageButton);
// Letzte Seite sollte Items 41-50 zeigen
await waitFor(() => {
expect(screen.queryByText('Item 20')).not.toBeInTheDocument();
expect(screen.getByText('Item 41')).toBeInTheDocument();
expect(screen.getByText('Item 50')).toBeInTheDocument();
});
}
});
});