Testing Guide
The DTX Portal uses Vitest 3.2.4 with Testing Library React 16.3 and jsdom 27.0.0 for testing. The infrastructure is fully configured — this guide covers how to write and run tests.
Current State
Test coverage is at 0% — the infrastructure is ready but no test files exist yet. This guide documents the patterns to follow when adding tests.
Running Tests
pnpm test # Run all tests once
pnpm test:ui # Open Vitest visual dashboard (browser-based)
pnpm test -- --watch # Watch mode — re-runs on file changes
pnpm test -- src/components/pipelines/ # Run tests in a specific directory
pnpm test -- PipelineList.test.tsx # Run a single test file
Test File Convention
| Rule | Convention |
|---|---|
| Location | Same directory as the source file |
| Naming | {FileName}.test.ts or {FileName}.test.tsx |
| Globals | describe, it, expect, vi are globally available (no imports needed) |
components/
pipelines/
PipelineList.tsx
PipelineList.test.tsx ← test file next to source
Vitest Configuration
The test config lives in vitest.config.ts:
| Setting | Value |
|---|---|
| Environment | jsdom |
| Globals | Enabled (no explicit imports) |
| Setup file | src/test/setup.ts |
| CSS | Processing enabled |
| Path aliases | Same as tsconfig.json (@/, @dtx/ui, @dtx/utils) |
Testing Patterns
1. Component Tests
Render a component, interact with it, assert the output.
// components/pipelines/PipelineList.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PipelineList from './PipelineList';
describe('PipelineList', () => {
it('renders pipeline names', () => {
render(<PipelineList pipelines={mockPipelines} />);
expect(screen.getByText('My Pipeline')).toBeInTheDocument();
expect(screen.getByText('Test Pipeline')).toBeInTheDocument();
});
it('calls onDelete when delete button clicked', async () => {
const onDelete = vi.fn();
render(<PipelineList pipelines={mockPipelines} onDelete={onDelete} />);
await userEvent.click(screen.getByRole('button', { name: /delete/i }));
expect(onDelete).toHaveBeenCalledWith('pipeline-1');
});
});
2. React Query Hooks
Wrap components that use React Query in a test QueryClientProvider.
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function TestWrapper({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('PipelineListPage', () => {
it('shows loading state then data', async () => {
// Mock the service
vi.spyOn(pipelineService, 'getAll').mockResolvedValue(mockPipelines);
render(<PipelineListPage />, { wrapper: TestWrapper });
// Initially loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// After data loads
await waitFor(() => {
expect(screen.getByText('My Pipeline')).toBeInTheDocument();
});
});
});
3. Zustand Stores
Test stores directly without rendering components.
import { usePipelineStore } from '@/stores/usePipelineStore';
describe('usePipelineStore', () => {
beforeEach(() => {
// Reset store between tests
usePipelineStore.setState(usePipelineStore.getInitialState());
});
it('adds a node to the canvas', () => {
const { addNode } = usePipelineStore.getState();
addNode({ id: 'node-1', type: 'source', position: { x: 0, y: 0 }, data: {} });
const { nodes } = usePipelineStore.getState();
expect(nodes).toHaveLength(1);
expect(nodes[0].id).toBe('node-1');
});
it('removes a node and its connected edges', () => {
// Setup: add node + edge
usePipelineStore.setState({
nodes: [{ id: 'n1' }, { id: 'n2' }],
edges: [{ id: 'e1', source: 'n1', target: 'n2' }],
});
const { removeNode } = usePipelineStore.getState();
removeNode('n1');
const state = usePipelineStore.getState();
expect(state.nodes).toHaveLength(1);
expect(state.edges).toHaveLength(0);
});
});
4. Service Classes
Mock fetchWithAuth to test service methods without hitting the backend.
import { PipelineService } from '@/services/pipeline.service';
vi.mock('@/services/fetchWithAuth', () => ({
fetchWithAuth: vi.fn(),
}));
import { fetchWithAuth } from '@/services/fetchWithAuth';
describe('PipelineService', () => {
const service = PipelineService.getInstance();
it('creates a pipeline', async () => {
const mockResponse = {
ok: true,
json: () => Promise.resolve({
status: 'success',
data: { id: 'p1', name: 'Test' },
}),
};
vi.mocked(fetchWithAuth).mockResolvedValue(mockResponse);
const result = await service.create({ name: 'Test' });
expect(fetchWithAuth).toHaveBeenCalledWith(
expect.stringContaining('/pipelines'),
expect.objectContaining({ method: 'POST' })
);
expect(result.name).toBe('Test');
});
});
5. Form Validation (React Hook Form + Zod)
Test Zod schemas directly for validation logic.
import { pipelineSchema } from '@/types/pipeline.types';
describe('pipelineSchema', () => {
it('validates a valid pipeline', () => {
const result = pipelineSchema.safeParse({
name: 'My Pipeline',
description: 'Test pipeline',
type: 'PROCESSING',
});
expect(result.success).toBe(true);
});
it('rejects empty name', () => {
const result = pipelineSchema.safeParse({
name: '',
description: 'Test',
type: 'PROCESSING',
});
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toContain('name');
});
});
Mocking Patterns
Mock Keycloak Auth
vi.mock('@/providers/AuthProvider', () => ({
useAuth: () => ({
token: 'mock-jwt-token',
user: { name: 'Test User', tenantId: 'tenant-1' },
isAuthenticated: true,
}),
}));
Mock Zustand Store
vi.mock('@/stores/usePipelineStore', () => ({
usePipelineStore: vi.fn(() => ({
nodes: mockNodes,
edges: mockEdges,
addNode: vi.fn(),
removeNode: vi.fn(),
})),
}));
Mock API Response
vi.spyOn(operatorService, 'getAll').mockResolvedValue([
{ id: 'op1', name: 'Kafka Source', category: 'SOURCE' },
{ id: 'op2', name: 'Transformer', category: 'PROCESSOR' },
]);
What to Test vs. What to Skip
Test These
| What | Why |
|---|---|
| Business logic in hooks | Core app behavior — mutations, transformations, validation |
| Form validation schemas | Zod schemas define data contracts |
| Store actions | Zustand store logic (add, remove, update operations) |
| Service methods | Verify correct API calls and response handling |
| Complex component interactions | Multi-step wizards, conditional rendering |
| Error states | Verify error messages display correctly |
Skip These
| What | Why |
|---|---|
| @dtx/ui base components | They wrap React Aria — already tested by the library |
| Static pages | No logic to test — visual review is sufficient |
| Styling / layout | Tailwind classes don't need unit tests |
| Third-party libraries | XYFlow, Monaco, Recharts — trust the library |
| Trivial components | Components that just pass props down |
Test Utilities
Create a shared test setup in src/test/:
| File | Purpose |
|---|---|
setup.ts | Global test setup (jsdom, cleanup, mock globals) |
test-utils.tsx | Custom render() with providers (QueryClient, Auth, Router) |
mocks/ | Shared mock data (mockPipeline, mockOperator, mockUser) |
Custom Render with Providers
// src/test/test-utils.tsx
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>
{children}
</MemoryRouter>
</QueryClientProvider>
);
}
export function renderWithProviders(ui: React.ReactElement) {
return render(ui, { wrapper: AllProviders });
}
Usage:
import { renderWithProviders } from '@/test/test-utils';
it('renders pipeline page', () => {
renderWithProviders(<PipelineListPage />);
// ...
});