Files
the-other-dude/.planning/codebase/TESTING.md
2026-03-12 19:33:26 -05:00

19 KiB

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:

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:

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:

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:

/**
 * 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:

"""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:

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:

// 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:

# 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:

// 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:

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:

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:

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:

// 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:

npm run test:coverage
# Generates coverage in frontend/coverage/ directory

Backend:

Requirements: Not enforced in config (but tracked)

View Coverage:

pytest --cov=app --cov-report=term-missing
pytest --cov=app --cov-report=html  # Generates htmlcov/index.html

Go:

Requirements: Not enforced

View Coverage:

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:

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:

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:

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:

@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:

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:

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:

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:

@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:

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