Testing¶
Kamerplanter has a comprehensive test suite for both backend and frontend. All tests must be green before a pull request is merged.
Current status: 821 backend tests (pytest), 198 frontend tests (vitest) — all passing.
Backend Tests (pytest)¶
Prerequisites¶
This installs all production and development dependencies, including pytest, pytest-asyncio, and pytest-cov.
Running Tests¶
# All tests
pytest
# With verbose output
pytest -v
# Single test file
pytest tests/test_onboarding_engine.py -v
# Single test
pytest tests/test_onboarding_engine.py::TestValidateKitApplication::test_valid_application -v
# Filter tests by name pattern
pytest -k "substrate" -v
Test Structure¶
src/backend/tests/
├── conftest.py # Shared fixtures (Species, Site, Substrate, ...)
├── unit/ # Unit tests without external dependencies
│ ├── domain/
│ │ └── test_calculations.py # VPD, GDD, EC calculations
│ └── adapters/
│ └── test_enrichment.py # GBIF/Perenual adapter logic
├── api/ # API layer tests
│ └── test_error_handling.py
├── integration/ # Integration tests (require ArangoDB)
│ └── test_arango_integration.py
├── test_care_reminder_engine.py # Engine tests (direct class instantiation)
├── test_onboarding_engine.py
├── test_substrate_lifecycle_manager.py
└── test_*.py # Further engine/service tests
pytest-asyncio Configuration¶
pytest-asyncio is configured with asyncio_mode = "auto". Async test functions do not need an explicit @pytest.mark.asyncio decorator:
# Works without explicit decorator
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)¶
The central conftest.py provides typical datasets as fixtures:
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
Available fixtures: sample_species_data, sample_site_data, sample_location_data, sample_substrate_data.
Integration Tests¶
Integration tests under tests/integration/ require a running ArangoDB instance. They are automatically skipped when no connection is available:
@pytest.mark.skipif(not ARANGO_AVAILABLE, reason="ArangoDB not available")
class TestArangoSetup:
...
To run them explicitly:
# Start ArangoDB (e.g. via Docker Compose)
docker-compose up -d arangodb
# Integration tests only
pytest tests/integration/ -v
Code Coverage¶
The HTML report is saved in htmlcov/ and can be opened in a browser.
Writing New Tests¶
Engine tests — direct class instantiation, no repository mocks needed:
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 are mocked:
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)¶
Prerequisites¶
Node.js 25.1.0 is required (via asdf, .tool-versions in the frontend directory).
Running Tests¶
# All tests (single run)
npm test
# Watch mode (re-run on file changes)
npm run test:watch
# With coverage report
npm run test:coverage
Test Structure¶
src/frontend/src/test/
├── setup.ts # Global test setup (MSW, jest-dom, cleanup)
├── helpers.tsx # renderWithProviders, createTestStore
├── mocks/
│ ├── handlers.ts # MSW request handlers (API mocks)
│ └── server.ts # MSW node server
├── components/ # Component tests
│ ├── ConfirmDialog.test.tsx
│ ├── DataTable.test.tsx
│ ├── FormTextField.test.tsx
│ └── ...
├── hooks/ # Hook tests (useApiError, useDebounce, ...)
├── pages/ # Page component tests
├── store/ # Redux slice tests
├── api/ # API client tests
└── a11y/ # Accessibility tests (vitest-axe)
Test Infrastructure (setup.ts)¶
The setup initializes the MSW server before all tests and sets a tenant slug in localStorage. After each test, the DOM is cleaned up and MSW handlers are reset:
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)¶
All component tests use renderWithProviders from src/test/helpers.tsx. This function wraps the component in all required providers:
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();
});
Included providers: Redux Store, React Router (createMemoryRouter), MUI Theme, SnackbarProvider.
userPreferences reducer required
Components using useExpertiseLevel require the userPreferences reducer in the test store. createTestStore() already includes it. When creating a test store manually, add it explicitly:
API Mocks with MSW¶
API calls are intercepted by Mock Service Worker (MSW). Handlers are defined in src/test/mocks/handlers.ts:
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 });
}),
];
For test-specific behavior, handlers can be temporarily overridden:
test('shows error on API failure', () => {
server.use(
http.get('/api/v1/botanical-families', () => {
return HttpResponse.error();
})
);
renderWithProviders(<BotanicalFamilyList />);
expect(screen.getByText(/Error loading/)).toBeInTheDocument();
});
Accessibility Tests (vitest-axe)¶
Critical forms and dialogs have automated 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();
});
Writing New Tests¶
Component test conventions:
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="Email" required />
);
await user.click(screen.getByLabelText('Email'));
await user.tab(); // Leave focus to trigger validation
expect(screen.getByText(/Required/)).toBeInTheDocument();
});
});
Redux slice tests — without 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 verify complete user workflows through a real browser. They use Selenium WebDriver with the Page Object pattern and generate Markdown test protocols with screenshots (NFR-008).
Prerequisites¶
Dependencies: selenium>=4.25.0, webdriver-manager>=4.0.0, pytest>=8.3.0.
Running Locally¶
Tests run against a local application instance with a local Chrome/Firefox browser:
# Default: Chrome headless against localhost:5173
pytest tests/e2e/ -v
# Firefox
pytest tests/e2e/ --browser firefox -v
# Custom base URL (e.g. Docker Compose dev stack on port 8080)
pytest tests/e2e/ --base-url http://localhost:8080 -v
# With test protocol generation (NFR-008 §4.4)
pytest tests/e2e/ --generate-protocol
Reports and screenshots are saved to test-reports/<timestamp>/.
Dedicated Docker Environment¶
A dedicated Docker Compose stack runs the complete application plus Selenium Grid in an isolated network — no host ports needed, runs in parallel with Kind/Skaffold without conflicts.
# Recommended: wrapper script (starts stack, collects logs, tears down)
./scripts/run-e2e.sh
# Or manually:
docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit
docker compose -f docker-compose.e2e.yml down -v
The stack includes:
| Service | Purpose |
|---|---|
arangodb | Isolated test database (kamerplanter_e2e) |
valkey | Redis-compatible cache/queue |
backend | FastAPI application |
celery-worker | Background task processing |
frontend | React app served via nginx |
selenium-hub | Selenium Grid hub |
chrome | Chrome node (up to 4 parallel sessions) |
e2e-tests | Test runner container |
Reports are written to ./test-reports/<timestamp>/ on the host:
protokoll.md— Markdown test protocol with results and inline screenshotsscreenshots/— all screenshots (explicit checkpoints + automatic failure captures)logs/— container logs for all services (backend, frontend, selenium, arangodb, ...)
How it works: The test runner connects to Chrome via Selenium Grid (SELENIUM_REMOTE_URL), and reaches the frontend via Docker's internal network (E2E_BASE_URL=http://frontend:80). The conftest.py browser fixture automatically switches between local and remote WebDriver based on the SELENIUM_REMOTE_URL environment variable.
Test Structure¶
tests/e2e/
├── conftest.py # Browser fixtures, CLI options, screenshot capture
├── protocol_plugin.py # Markdown protocol report generator (NFR-008 §4.4)
├── requirements.txt # E2E Python dependencies
├── Dockerfile # Test runner container for Docker Compose
├── pages/
│ ├── base_page.py # BasePage with shared Selenium utilities
│ ├── login_page.py # Page objects (one per screen)
│ └── ...
├── test_req001_*.py # Tests organized by requirement
├── test_req006_*.py
└── ...
Page Object Pattern¶
All page objects inherit from BasePage and use data-testid locators:
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 are captured automatically on failure and can be taken explicitly during tests:
class TestDashboard:
def test_dashboard_loads(self, screenshot, browser, base_url):
browser.get(base_url)
screenshot("001_dashboard_loaded", "Dashboard after initial load")
Common Rules for Both Test Suites¶
- Tests run in CI on every push to
developand every pull request. - New features require at least one unit test for the business logic.
- Bug fixes require a regression test that reproduces the bug.
- No
.skipor.onlyin merged tests without a comment and issue reference.