Testen¶
Kamerplanter verfügt über eine umfangreiche Testsuite für Backend und Frontend. Alle Tests müssen grün sein, bevor ein Pull Request gemergt wird.
Aktueller Stand: 821 Backend-Tests (pytest), 198 Frontend-Tests (vitest) — alle grün.
Backend-Tests (pytest)¶
Voraussetzungen¶
Das installiert alle Produktions- und Entwicklungsabhängigkeiten, einschließlich pytest, pytest-asyncio und pytest-cov.
Tests ausführen¶
# Alle Tests
pytest
# Mit ausführlicher Ausgabe
pytest -v
# Einzelne Testdatei
pytest tests/test_onboarding_engine.py -v
# Einzelnen Test
pytest tests/test_onboarding_engine.py::TestValidateKitApplication::test_valid_application -v
# Tests nach Namenspattern filtern
pytest -k "substrate" -v
Teststruktur¶
src/backend/tests/
├── conftest.py # Gemeinsame Fixtures (Species, Site, Substrate, ...)
├── unit/ # Einheitentests ohne externe Abhängigkeiten
│ ├── domain/
│ │ └── test_calculations.py # VPD, GDD, EC-Berechnungen
│ └── adapters/
│ └── test_enrichment.py # GBIF/Perenual Adapter-Logik
├── api/ # API-Schicht-Tests
│ └── test_error_handling.py
├── integration/ # Integrationstests (erfordern ArangoDB)
│ └── test_arango_integration.py
├── test_care_reminder_engine.py # Engine-Tests (direkte Klassen-Instantiierung)
├── test_onboarding_engine.py
├── test_substrate_lifecycle_manager.py
└── test_*.py # Weitere Engine-/Service-Tests
pytest-asyncio Konfiguration¶
pytest-asyncio ist mit asyncio_mode = "auto" konfiguriert. Asynchrone Testfunktionen benötigen kein explizites @pytest.mark.asyncio-Dekorator:
# Funktioniert ohne explizites Dekorator
async def test_service_creates_species():
service = SpeciesService(mock_repo)
result = await service.create(sample_data)
assert result.scientific_name == "Solanum lycopersicum"
Fixtures (conftest.py)¶
Die zentrale conftest.py stellt typische Datensätze als Fixtures bereit:
def test_substrate_lifecycle(sample_substrate_data):
substrate = Substrate(**sample_substrate_data)
manager = SubstrateLifecycleManager()
result = manager.prepare_for_reuse(substrate)
assert result.reuse_cycle == 1
Verfügbare Fixtures: sample_species_data, sample_site_data, sample_location_data, sample_substrate_data.
Integrationstests¶
Integrationstests unter tests/integration/ erfordern eine laufende ArangoDB-Instanz. Sie werden automatisch übersprungen, wenn keine Verbindung besteht:
@pytest.mark.skipif(not ARANGO_AVAILABLE, reason="ArangoDB not available")
class TestArangoSetup:
...
Um sie gezielt auszuführen:
# ArangoDB starten (z. B. via Docker Compose)
docker-compose up -d arangodb
# Nur Integrationstests
pytest tests/integration/ -v
Code Coverage¶
Der HTML-Report wird in htmlcov/ gespeichert und kann im Browser geöffnet werden.
Neue Tests schreiben¶
Engine-Tests — direkte Klassen-Instantiierung, keine Mocks für Repositories nötig:
class TestNutrientSolutionCalculator:
calc = NutrientSolutionCalculator()
def test_ec_net_calculation(self):
result = self.calc.calculate_ec_net(
base_water_ec=0.3,
target_ec=1.8,
)
assert result == pytest.approx(1.5, abs=0.01)
def test_mixing_order_calmag_first(self):
plan = NutrientPlan(...)
errors = self.calc.validate_mixing_order(plan)
assert errors == []
Service-Tests — Repositories werden gemockt:
class TestSpeciesService:
async def test_create_species(self):
mock_repo = AsyncMock(spec=SpeciesRepository)
mock_repo.create.return_value = Species(key="sp-1", ...)
service = SpeciesService(mock_repo)
result = await service.create(CreateSpeciesRequest(...))
mock_repo.create.assert_called_once()
assert result.key == "sp-1"
Frontend-Tests (vitest)¶
Voraussetzungen¶
Node.js 25.1.0 ist erforderlich (via asdf, .tool-versions im Frontend-Verzeichnis).
Tests ausführen¶
# Alle Tests (einmaliger Durchlauf)
npm test
# Watch-Modus (Tests bei Dateiänderungen neu ausführen)
npm run test:watch
# Mit Coverage-Report
npm run test:coverage
Teststruktur¶
src/frontend/src/test/
├── setup.ts # Globales Test-Setup (MSW, jest-dom, Cleanup)
├── helpers.tsx # renderWithProviders, createTestStore
├── mocks/
│ ├── handlers.ts # MSW Request-Handler (API-Mocks)
│ └── server.ts # MSW Node-Server
├── components/ # Komponenten-Tests
│ ├── ConfirmDialog.test.tsx
│ ├── DataTable.test.tsx
│ ├── FormTextField.test.tsx
│ └── ...
├── hooks/ # Hook-Tests (useApiError, useDebounce, ...)
├── pages/ # Seiten-Komponenten-Tests
├── store/ # Redux Slice-Tests
├── api/ # API-Client-Tests
└── a11y/ # Barrierefreiheits-Tests (vitest-axe)
Test-Infrastruktur (setup.ts)¶
Das Setup initialisiert vor allen Tests den MSW-Server und setzt einen Tenant-Slug in localStorage. Nach jedem Test wird der DOM bereinigt und die MSW-Handler werden zurückgesetzt:
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
beforeEach(() => {
window.localStorage.setItem('kp_active_tenant_slug', 'test-tenant');
});
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());
renderWithProviders (helpers.tsx)¶
Alle Komponenten-Tests verwenden renderWithProviders aus src/test/helpers.tsx. Diese Funktion wickelt die Komponente in alle erforderlichen Provider ein:
import { renderWithProviders } from '@/test/helpers';
import { screen } from '@testing-library/react';
import { SpeciesDetailPage } from '@/pages/stammdaten/SpeciesDetailPage';
test('renders species name', () => {
renderWithProviders(<SpeciesDetailPage />, { route: '/stammdaten/species/sp-1' });
expect(screen.getByText('Solanum lycopersicum')).toBeInTheDocument();
});
Enthaltene Provider: Redux Store, React Router (createMemoryRouter), MUI Theme, SnackbarProvider.
userPreferences-Reducer erforderlich
Komponenten, die useExpertiseLevel verwenden, benötigen den userPreferences-Reducer im Test-Store. createTestStore() schließt ihn bereits ein. Beim manuellen Erstellen eines Test-Stores muss er explizit hinzugefügt werden:
API-Mocks mit MSW¶
API-Aufrufe werden durch Mock Service Worker (MSW) abgefangen. Handler sind in src/test/mocks/handlers.ts definiert:
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/v1/botanical-families', () => {
return HttpResponse.json(mockFamilies);
}),
http.post('/api/v1/t/:tenantSlug/species', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ key: 'sp-new', ...body }, { status: 201 });
}),
];
Für testspezifisches Verhalten können Handler temporär überschrieben werden:
test('shows error on API failure', () => {
server.use(
http.get('/api/v1/botanical-families', () => {
return HttpResponse.error();
})
);
renderWithProviders(<BotanicalFamilyList />);
expect(screen.getByText(/Fehler beim Laden/)).toBeInTheDocument();
});
Barrierefreiheits-Tests (vitest-axe)¶
Kritische Formulare und Dialogfelder haben automatische Accessibility-Tests:
import { axe } from 'vitest-axe';
test('form has no accessibility violations', async () => {
const { container } = renderWithProviders(<SpeciesCreateDialog open />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Neue Tests schreiben¶
Konventionen für Komponenten-Tests:
import { describe, test, expect } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers';
describe('FormTextField', () => {
test('displays validation error', async () => {
const user = userEvent.setup();
renderWithProviders(
<FormTextField name="email" label="E-Mail" required />
);
await user.click(screen.getByLabelText('E-Mail'));
await user.tab(); // Fokus verlassen, um Validierung auszulösen
expect(screen.getByText(/Pflichtfeld/)).toBeInTheDocument();
});
});
Redux Slice-Tests — ohne React:
import { describe, test, expect } from 'vitest';
import onboardingReducer, { setStep } from '@/store/slices/onboardingSlice';
describe('onboardingSlice', () => {
test('advances step', () => {
const initial = { currentStep: 0, completed: false };
const next = onboardingReducer(initial, setStep(1));
expect(next.currentStep).toBe(1);
});
});
E2E-Tests (Selenium)¶
End-to-End-Tests prüfen komplette Benutzer-Workflows in einem echten Browser. Sie verwenden Selenium WebDriver mit dem Page-Object-Pattern und erzeugen Markdown-Testprotokolle mit Screenshots (NFR-008).
Voraussetzungen¶
Abhängigkeiten: selenium>=4.25.0, webdriver-manager>=4.0.0, pytest>=8.3.0.
Lokal ausführen¶
Tests laufen gegen eine lokale Applikations-Instanz mit einem lokalen Chrome/Firefox-Browser:
# Standard: Chrome headless gegen localhost:5173
pytest tests/e2e/ -v
# Firefox
pytest tests/e2e/ --browser firefox -v
# Eigene Base-URL (z. B. Docker-Compose-Dev-Stack auf Port 8080)
pytest tests/e2e/ --base-url http://localhost:8080 -v
# Mit Testprotokoll-Generierung (NFR-008 §4.4)
pytest tests/e2e/ --generate-protocol
Reports und Screenshots werden in test-reports/<timestamp>/ gespeichert.
Dedizierte Docker-Umgebung¶
Ein eigener Docker-Compose-Stack startet die komplette Applikation plus Selenium Grid in einem isolierten Netzwerk — keine Host-Ports nötig, läuft parallel zum Kind/Skaffold-Cluster ohne Konflikte.
# Empfohlen: Wrapper-Skript (startet Stack, sammelt Logs, räumt auf)
./scripts/run-e2e.sh
# Oder manuell:
docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit
docker compose -f docker-compose.e2e.yml down -v
Der Stack umfasst:
| Service | Zweck |
|---|---|
arangodb | Isolierte Testdatenbank (kamerplanter_e2e) |
valkey | Redis-kompatibler Cache/Queue |
backend | FastAPI-Applikation |
celery-worker | Hintergrund-Taskverarbeitung |
frontend | React-App via nginx |
selenium-hub | Selenium-Grid-Hub |
chrome | Chrome-Node (bis zu 4 parallele Sessions) |
e2e-tests | Test-Runner-Container |
Reports werden auf dem Host nach ./test-reports/<timestamp>/ geschrieben:
protokoll.md— Markdown-Testprotokoll mit Ergebnissen und eingebetteten Screenshotsscreenshots/— alle Screenshots (explizite Checkpoints + automatische Failure-Captures)logs/— Container-Logs aller Services (Backend, Frontend, Selenium, ArangoDB, ...)
Funktionsweise: Der Test-Runner verbindet sich über Selenium Grid mit Chrome (SELENIUM_REMOTE_URL) und erreicht das Frontend über Dockers internes Netzwerk (E2E_BASE_URL=http://frontend:80). Die conftest.py-Browser-Fixture schaltet automatisch zwischen lokalem und Remote-WebDriver um, basierend auf der Umgebungsvariable SELENIUM_REMOTE_URL.
Teststruktur¶
tests/e2e/
├── conftest.py # Browser-Fixtures, CLI-Optionen, Screenshot-Erfassung
├── protocol_plugin.py # Markdown-Protokoll-Generator (NFR-008 §4.4)
├── requirements.txt # E2E Python-Abhängigkeiten
├── Dockerfile # Test-Runner-Container für Docker Compose
├── pages/
│ ├── base_page.py # BasePage mit gemeinsamen Selenium-Hilfsmethoden
│ ├── login_page.py # Page Objects (eins pro Bildschirm)
│ └── ...
├── test_req001_*.py # Tests nach Anforderung organisiert
├── test_req006_*.py
└── ...
Page-Object-Pattern¶
Alle Page Objects erben von BasePage und verwenden data-testid-Locator:
from .base_page import BasePage
from selenium.webdriver.common.by import By
class LoginPage(BasePage):
PATH = "/login"
USERNAME_INPUT = (By.CSS_SELECTOR, "[data-testid='email-input'] input")
PASSWORD_INPUT = (By.CSS_SELECTOR, "[data-testid='password-input'] input")
SUBMIT_BUTTON = (By.CSS_SELECTOR, "[data-testid='login-submit']")
def login(self, email: str, password: str):
self.navigate(self.PATH)
self.wait_for_element(self.USERNAME_INPUT).send_keys(email)
self.wait_for_element(self.PASSWORD_INPUT).send_keys(password)
self.wait_for_element_clickable(self.SUBMIT_BUTTON).click()
Screenshots¶
Screenshots werden bei Fehlern automatisch erstellt und können während der Tests explizit aufgenommen werden:
class TestDashboard:
def test_dashboard_loads(self, screenshot, browser, base_url):
browser.get(base_url)
screenshot("001_dashboard_loaded", "Dashboard nach initialem Laden")
Gemeinsame Regeln für beide Test-Suites¶
- Tests laufen in CI bei jedem Push auf
developund bei jedem Pull Request. - Neue Features erfordern mindestens einen Unit-Test für die Business-Logik.
- Bug-Fixes erfordern einen Regressionstest, der den Bug reproduziert.
- Kein
.skipoder.onlyin gemergten Tests ohne Kommentar und Issue-Referenz.