docs: map existing codebase

This commit is contained in:
Jason Staack
2026-03-12 19:33:26 -05:00
parent 5beede9502
commit 70126980a4
7 changed files with 2252 additions and 0 deletions

View File

@@ -0,0 +1,751 @@
# Testing Patterns
**Analysis Date:** 2026-03-12
## Test Framework
**Frontend:**
Runner:
- Vitest 4.0.18
- Config: `frontend/vitest.config.ts`
- Environment: jsdom (browser simulation)
- Globals enabled: true
Assertion Library:
- Testing Library (React) - `@testing-library/react`
- Testing Library User Events - `@testing-library/user-event`
- Testing Library Jest DOM matchers - `@testing-library/jest-dom`
- Vitest's built-in expect (compatible with Jest)
Run Commands:
```bash
npm run test # Run all tests once
npm run test:watch # Watch mode (re-runs on file change)
npm run test:coverage # Generate coverage report
npm run test:e2e # E2E tests with Playwright
npm run test:e2e:headed # E2E tests with visible browser
```
**Backend:**
Runner:
- pytest 8.0.0
- Config: `pyproject.toml` with `asyncio_mode = "auto"`
- Plugins: pytest-asyncio, pytest-mock, pytest-cov
- Markers: `integration` (marked tests requiring PostgreSQL)
Run Commands:
```bash
pytest # Run all tests
pytest -m "not integration" # Run unit tests only
pytest -m integration # Run integration tests only
pytest --cov=app # Generate coverage report
pytest -v # Verbose output
```
**Go (Poller):**
Runner:
- Go's built-in testing package
- Config: implicit (no config file)
- Assertions: testify/assert, testify/require
- Test containers for integration tests (PostgreSQL, Redis, NATS)
Run Commands:
```bash
go test ./... # Run all tests
go test -v ./... # Verbose output
go test -run TestName ... # Run specific test
go test -race ./... # Race condition detection
```
## Test File Organization
**Frontend:**
Location:
- Co-located with components in `__tests__` subdirectory
- Pattern: `src/components/__tests__/{component}.test.tsx`
- Shared test utilities in `src/test/test-utils.tsx`
- Test setup in `src/test/setup.ts`
Examples:
- `frontend/src/components/__tests__/LoginPage.test.tsx`
- `frontend/src/components/__tests__/DeviceList.test.tsx`
- `frontend/src/components/__tests__/TemplatePushWizard.test.tsx`
Naming:
- Test files: `{Component}.test.tsx` (matches component name)
- Vitest config includes: `'src/**/*.test.{ts,tsx}'`
**Backend:**
Location:
- Separate `tests/` directory at project root
- Organization: `tests/unit/` and `tests/integration/`
- Pattern: `tests/unit/test_{module}.py`
Examples:
- `backend/tests/unit/test_auth.py`
- `backend/tests/unit/test_security.py`
- `backend/tests/unit/test_crypto.py`
- `backend/tests/unit/test_audit_service.py`
- `backend/tests/conftest.py` (shared fixtures)
- `backend/tests/integration/conftest.py` (database fixtures)
**Go:**
Location:
- Co-located with implementation: `{file}.go` and `{file}_test.go`
- Pattern: `internal/poller/scheduler_test.go` alongside `scheduler.go`
Examples:
- `poller/internal/poller/scheduler_test.go`
- `poller/internal/sshrelay/server_test.go`
- `poller/internal/poller/integration_test.go`
## Test Structure
**Frontend (Vitest + React Testing Library):**
Suite Organization:
```typescript
/**
* Component tests -- description of what is tested
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@/test/test-utils'
import userEvent from '@testing-library/user-event'
// --------------------------------------------------------------------------
// Mocks
// --------------------------------------------------------------------------
const mockNavigate = vi.fn()
vi.mock('@tanstack/react-router', () => ({
// mock implementation
}))
// --------------------------------------------------------------------------
// Tests
// --------------------------------------------------------------------------
describe('LoginPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders login form with email and password fields', () => {
render(<LoginPage />)
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
})
it('submits form with entered credentials', async () => {
render(<LoginPage />)
const user = userEvent.setup()
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test@example.com', expect.any(String))
})
})
})
```
Patterns:
- Mocks defined before imports, then imported components
- Section comments: `// ---------- Mocks ----------`, `// ---------- Tests ----------`
- `describe()` blocks for test suites
- `beforeEach()` for test isolation and cleanup
- `userEvent.setup()` for simulating user interactions
- `waitFor()` for async assertions
- Accessibility-first selectors: `getByLabelText`, `getByRole` over `getByTestId`
**Backend (pytest):**
Suite Organization:
```python
"""Unit tests for the JWT authentication service.
Tests cover:
- Password hashing and verification (bcrypt)
- JWT access token creation and validation
"""
import pytest
from unittest.mock import patch
class TestPasswordHashing:
"""Tests for bcrypt password hashing."""
def test_hash_returns_different_string(self):
password = "test-password-123!"
hashed = hash_password(password)
assert hashed != password
def test_hash_verify_roundtrip(self):
password = "test-password-123!"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
```
Patterns:
- Module docstring describing test scope
- Test classes for grouping related tests: `class TestPasswordHashing:`
- Test methods: `def test_{behavior}(self):`
- Assertions: `assert condition` (pytest style)
- Fixtures defined in conftest.py for async/db setup
**Go:**
Suite Organization:
```go
package poller
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockDeviceFetcher implements DeviceFetcher for testing.
type mockDeviceFetcher struct {
devices []store.Device
err error
}
func (m *mockDeviceFetcher) FetchDevices(ctx context.Context) ([]store.Device, error) {
return m.devices, m.err
}
func newTestScheduler(fetcher DeviceFetcher) *Scheduler {
// Create test instance with mocked dependencies
return &Scheduler{...}
}
func TestReconcileDevices_StartsNewDevices(t *testing.T) {
devices := []store.Device{...}
fetcher := &mockDeviceFetcher{devices: devices}
sched := newTestScheduler(fetcher)
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := sched.reconcileDevices(ctx, &wg)
require.NoError(t, err)
sched.mu.Lock()
assert.Len(t, sched.activeDevices, 2)
sched.mu.Unlock()
}
```
Patterns:
- Mock types defined at package level (not inside test functions)
- Constructor helper: `newTest{Subject}(...)` for creating test instances
- Test function signature: `func Test{Subject}_{Scenario}(t *testing.T)`
- testify assertions: `assert.Len()`, `require.NoError()`
- Context management with defer for cleanup
- Concurrent access protected by locks (shown in assertions)
## Mocking
**Frontend:**
Framework: vitest `vi` object
Patterns:
```typescript
// Mock module imports
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => mockNavigate,
Link: ({ children, ...props }) => <a href={props.to}>{children}</a>,
}))
// Mock with partial real imports
vi.mock('@/lib/api', async () => {
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api')
return {
...actual,
devicesApi: {
...actual.devicesApi,
list: (...args: unknown[]) => mockDevicesList(...args),
},
}
})
// Create spy/mock functions
const mockLogin = vi.fn()
const mockNavigate = vi.fn()
// Configure mock behavior
mockLogin.mockResolvedValueOnce(undefined) // Resolve once
mockLogin.mockRejectedValueOnce(new Error('...')) // Reject once
mockLogin.mockReturnValueOnce(new Promise(...)) // Return pending promise
// Clear mocks between tests
beforeEach(() => {
vi.clearAllMocks()
})
// Assert mock was called
expect(mockLogin).toHaveBeenCalledWith('email', 'password')
expect(mockNavigate).toHaveBeenCalledWith({ to: '/' })
```
What to Mock:
- External API calls (via axios/fetch)
- Router navigation (TanStack Router)
- Zustand store state (create mock `authState`)
- External libraries with complex behavior
What NOT to Mock:
- DOM elements (use Testing Library queries instead)
- React hooks from react-testing-library
- Component rendering (test actual render unless circular dependency)
**Backend (Python):**
Framework: pytest-mock (monkeypatch) and unittest.mock
Patterns:
```python
# Fixture-based mocking
@pytest.fixture
def mock_db(monkeypatch):
# monkeypatch.setattr(module, 'function', mock_fn)
pass
# Patch in test
def test_something(monkeypatch):
mock_fn = monkeypatch.setattr('app.services.auth.hash_password', mock_hash)
# Mock with context manager
from unittest.mock import patch
def test_redis():
with patch('app.routers.auth.get_redis') as mock_redis:
mock_redis.return_value = MagicMock()
# test code
```
What to Mock:
- Database queries (return test data)
- External HTTP calls
- Redis operations
- Email sending
- File I/O
What NOT to Mock:
- Core business logic (hash_password, verify_token)
- Pydantic model validation
- SQLAlchemy relationship traversal (in integration tests)
**Go:**
Framework: testify/mock or simple interfaces
Patterns:
```go
// Interface-based mocking
type mockDeviceFetcher struct {
devices []store.Device
err error
}
func (m *mockDeviceFetcher) FetchDevices(ctx context.Context) ([]store.Device, error) {
return m.devices, m.err
}
// Use interface, not concrete type
func newTestScheduler(fetcher DeviceFetcher) *Scheduler {
return &Scheduler{store: fetcher, ...}
}
// Configure in test
sched := newTestScheduler(&mockDeviceFetcher{
devices: []store.Device{...},
err: nil,
})
```
What to Mock:
- Database/store interfaces
- External service calls (HTTP, SSH)
- Redis operations
What NOT to Mock:
- Standard library functions
- Core business logic
## Fixtures and Factories
**Frontend Test Data:**
Approach: Inline test data in test file
Example from `DeviceList.test.tsx`:
```typescript
const testDevices: DeviceListResponse = {
items: [
{
id: 'dev-1',
hostname: 'router-office-1',
ip_address: '192.168.1.1',
api_port: 8728,
api_ssl_port: 8729,
model: 'RB4011',
serial_number: 'ABC123',
firmware_version: '7.12',
routeros_version: '7.12.1',
uptime_seconds: 86400,
last_seen: '2026-03-01T12:00:00Z',
latitude: null,
longitude: null,
status: 'online',
},
],
total: 1,
}
```
**Test Utilities:**
Location: `frontend/src/test/test-utils.tsx`
Wrapper with providers:
```typescript
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
})
}
export function renderWithProviders(
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
const queryClient = createTestQueryClient()
function Wrapper({ children }: WrapperProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
return {
...render(ui, { wrapper: Wrapper, ...options }),
queryClient,
}
}
export { renderWithProviders as render }
```
Usage: Import `render` from test-utils, which automatically provides React Query
**Backend Fixtures:**
Location: `backend/tests/conftest.py` (unit), `backend/tests/integration/conftest.py` (integration)
Base conftest:
```python
def pytest_configure(config):
"""Register custom markers."""
config.addinivalue_line(
"markers", "integration: marks tests as integration tests requiring PostgreSQL"
)
```
Integration fixtures (in `tests/integration/conftest.py`):
- Database fixtures (SQLAlchemy AsyncSession)
- Redis test instance (testcontainers)
- NATS JetStream test server
**Go Test Helpers:**
Location: Helper functions defined in `_test.go` files
Example from `scheduler_test.go`:
```go
// mockDeviceFetcher implements DeviceFetcher for testing.
type mockDeviceFetcher struct {
devices []store.Device
err error
}
func (m *mockDeviceFetcher) FetchDevices(ctx context.Context) ([]store.Device, error) {
return m.devices, m.err
}
// newTestScheduler creates a Scheduler with a mock DeviceFetcher for testing.
func newTestScheduler(fetcher DeviceFetcher) *Scheduler {
testCache := vault.NewCredentialCache(64, 5*time.Minute, nil, make([]byte, 32), nil)
return &Scheduler{
store: fetcher,
locker: nil,
publisher: nil,
credentialCache: testCache,
pollInterval: 24 * time.Hour,
connTimeout: time.Second,
cmdTimeout: time.Second,
refreshPeriod: time.Second,
maxFailures: 5,
baseBackoff: 30 * time.Second,
maxBackoff: 15 * time.Minute,
activeDevices: make(map[string]*deviceState),
}
}
```
## Coverage
**Frontend:**
Requirements: Not enforced (no threshold in vitest config)
View Coverage:
```bash
npm run test:coverage
# Generates coverage in frontend/coverage/ directory
```
**Backend:**
Requirements: Not enforced in config (but tracked)
View Coverage:
```bash
pytest --cov=app --cov-report=term-missing
pytest --cov=app --cov-report=html # Generates htmlcov/index.html
```
**Go:**
Requirements: Not enforced
View Coverage:
```bash
go test -cover ./...
go tool cover -html=coverage.out # Visual report
```
## Test Types
**Frontend Unit Tests:**
Scope:
- Individual component rendering
- User interactions (click, type)
- Component state changes
- Props and variant rendering
Approach:
- Render component with test-utils
- Simulate user events with userEvent
- Assert on rendered DOM
Example from `LoginPage.test.tsx`:
```typescript
it('renders login form with email and password fields', () => {
render(<LoginPage />)
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
})
it('submits form with entered credentials', async () => {
mockLogin.mockResolvedValueOnce(undefined)
render(<LoginPage />)
const user = userEvent.setup()
await user.type(screen.getByLabelText(/email/i), 'admin@example.com')
await user.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('admin@example.com', 'secret123')
})
})
```
**Frontend E2E Tests:**
Framework: Playwright
Config: `frontend/playwright.config.ts`
Approach:
- Launch real browser
- Navigate through app
- Test full user journeys
- Sequential execution (no parallelization) for stability
Config highlights:
```typescript
fullyParallel: false, // Run sequentially for stability
workers: 1, // Single worker
timeout: 30000, // 30 second timeout per test
retries: process.env.CI ? 2 : 0, // Retry in CI
```
Location: `frontend/tests/e2e/` (referenced in playwright config)
**Backend Unit Tests:**
Scope:
- Pure function behavior (hash_password, verify_token)
- Service methods without database
- Validation logic
Approach:
- No async/await needed unless using mocking
- Direct function calls
- Assert on return values
Example from `test_auth.py`:
```python
class TestPasswordHashing:
def test_hash_returns_different_string(self):
password = "test-password-123!"
hashed = hash_password(password)
assert hashed != password
def test_hash_verify_roundtrip(self):
password = "test-password-123!"
hashed = hash_password(password)
assert verify_password(password, hashed) is True
```
**Backend Integration Tests:**
Scope:
- Full request/response cycle
- Database operations with fixtures
- External service interactions (Redis, NATS)
Approach:
- Marked with `@pytest.mark.integration`
- Use async fixtures for database
- Skip with `-m "not integration"` in CI (slow)
Location: `backend/tests/integration/`
Example:
```python
@pytest.mark.integration
async def test_login_creates_session(async_db, client):
# Creates user in test database
# Posts to /api/auth/login
# Asserts JWT tokens in response
pass
```
**Go Tests:**
Scope: Unit tests for individual functions, integration tests for subsystems
Unit test example:
```go
func TestReconcileDevices_StartsNewDevices(t *testing.T) {
devices := []store.Device{...}
fetcher := &mockDeviceFetcher{devices: devices}
sched := newTestScheduler(fetcher)
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := sched.reconcileDevices(ctx, &wg)
require.NoError(t, err)
sched.mu.Lock()
assert.Len(t, sched.activeDevices, 2)
sched.mu.Unlock()
cancel()
wg.Wait()
}
```
Integration test: Uses testcontainers for PostgreSQL, Redis, NATS (e.g., `integration_test.go`)
## Common Patterns
**Async Testing (Frontend):**
Pattern for testing async operations:
```typescript
it('navigates to home on successful login', async () => {
mockLogin.mockResolvedValueOnce(undefined)
render(<LoginPage />)
const user = userEvent.setup()
await user.type(screen.getByLabelText(/email/i), 'admin@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({ to: '/' })
})
})
```
- Use `userEvent.setup()` for user interactions
- Use `await waitFor()` for assertions on async results
- Mock promises with `mockFn.mockResolvedValueOnce()` or `mockRejectedValueOnce()`
**Error Testing (Frontend):**
Pattern for testing error states:
```typescript
it('shows error message on failed login', async () => {
mockLogin.mockRejectedValueOnce(new Error('Invalid credentials'))
authState.error = null
render(<LoginPage />)
const user = userEvent.setup()
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'wrongpassword')
await user.click(screen.getByRole('button', { name: /sign in/i }))
authState.error = 'Invalid credentials'
render(<LoginPage />)
expect(screen.getByText('Invalid credentials')).toBeInTheDocument()
})
```
**Async Testing (Backend):**
Pattern for async pytest:
```python
@pytest.mark.asyncio
async def test_get_redis():
redis = await get_redis()
assert redis is not None
```
Configure in `pyproject.toml`: `asyncio_mode = "auto"` (enabled globally)
**Error Testing (Backend):**
Pattern for testing exceptions:
```python
def test_verify_token_rejects_expired():
token = create_access_token(user_id=uuid4(), expires_delta=timedelta(seconds=-1))
with pytest.raises(HTTPException) as exc_info:
verify_token(token, expected_type="access")
assert exc_info.value.status_code == 401
```
---
*Testing analysis: 2026-03-12*