docs: map existing codebase
This commit is contained in:
751
.planning/codebase/TESTING.md
Normal file
751
.planning/codebase/TESTING.md
Normal 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*
|
||||
Reference in New Issue
Block a user