Zum Inhalt

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

cd src/backend
pip install -e ".[dev]"

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

pytest --cov=app --cov-report=term-missing --cov-report=html

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

cd src/frontend
npm install

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:

configureStore({
  reducer: {
    userPreferences: userPreferencesReducer,
    // ... weitere Reducer
  }
});

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

pip install -r tests/e2e/requirements.txt

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 Screenshots
  • screenshots/ — 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 develop und 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 .skip oder .only in gemergten Tests ohne Kommentar und Issue-Referenz.

Siehe auch