AI Prompt: Forma3D.Connect — Phase 5d: Frontend Test Coverage¶
Purpose: This prompt instructs an AI to add comprehensive unit test coverage to the React frontend
Estimated Effort: 8-12 days (~40-60 hours)
Prerequisites: Phase 5c completed (Webhook Idempotency)
Output: Vitest configured, 60%+ test coverage on frontend, all tests passing
Status: 🟡 PENDING
🎯 Mission¶
You are continuing development of Forma3D.Connect, building on the Phase 5c foundation. Your task is to implement Phase 5d: Frontend Test Coverage — specifically addressing TD-002 (Missing Frontend Test Coverage) from the technical debt register.
Why This Matters:
The React frontend application currently has zero unit tests. All testing relies on end-to-end acceptance tests, which creates several problems:
- Slow Feedback Loop: E2E tests take minutes; unit tests take seconds
- Fragile Test Suite: E2E tests are inherently flaky and maintenance-intensive
- Component Regression Risk: UI logic changes may introduce subtle bugs
- Refactoring Fear: Developers avoid refactoring without test safety net
Phase 5d delivers:
- Vitest configured for React component testing
- Tests for all custom hooks (6 hook files, ~15 hooks)
- Tests for api-client.ts error handling
- Tests for auth-context.tsx
- Tests for key UI components
- 60%+ test coverage target
- All tests passing in CI
📋 Context: Technical Debt Item¶
TD-002: Missing Frontend Test Coverage¶
| Attribute | Value |
|---|---|
| Type | Test Debt |
| Priority | Critical |
| Location | apps/web/src/** |
| Interest Rate | High (bugs reach production) |
| Principal (Effort) | 8-12 days |
Current State¶
| Category | Files | Test Files | Coverage |
|---|---|---|---|
| Hooks | 6 | 0 | 0% |
| Contexts | 2 | 0 | 0% |
| API Client | 1 | 0 | 0% |
| UI Components | 10 | 0 | 0% |
| Pages | 9 | 0 | 0% |
| Total | 28 | 0 | 0% |
Testing Priority¶
| Priority | Category | Files | Rationale |
|---|---|---|---|
| P1 | Custom Hooks | 6 | Core business logic, reused everywhere |
| P1 | API Client | 1 | Central data fetching, error handling |
| P1 | Auth Context | 1 | Authentication state management |
| P2 | Socket Context | 1 | Real-time connection management |
| P2 | UI Components | 10 | Reusable, visual regression risk |
| P3 | Pages | 9 | Integration of hooks/components |
🛠️ Implementation Phases¶
Phase 1: Vitest Configuration (2 hours)¶
Priority: Critical | Impact: Critical | Dependencies: None
1. Install Testing Dependencies¶
pnpm add -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitejs/plugin-react msw
2. Create Vitest Configuration¶
Create apps/web/vitest.config.ts:
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.{test,spec}.{ts,tsx}',
'src/test/**',
'src/main.tsx',
'src/router.tsx',
'src/vite-env.d.ts',
],
thresholds: {
statements: 60,
branches: 60,
functions: 60,
lines: 60,
},
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@forma3d/domain': resolve(__dirname, '../../libs/domain/src'),
},
},
});
3. Create Test Setup File¶
Create apps/web/src/test/setup.ts:
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Mock import.meta.env
vi.stubGlobal('import', {
meta: {
env: {
VITE_API_URL: 'http://localhost:3000',
},
},
});
4. Create Test Utilities¶
Create apps/web/src/test/test-utils.tsx:
import { ReactElement, ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from '../contexts/auth-context';
// Create a fresh QueryClient for each test
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
mutations: {
retry: false,
},
},
});
}
interface WrapperProps {
children: ReactNode;
}
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
withRouter?: boolean;
withAuth?: boolean;
}
function createWrapper(options: CustomRenderOptions = {}) {
const {
queryClient = createTestQueryClient(),
withRouter = true,
withAuth = true,
} = options;
return function Wrapper({ children }: WrapperProps) {
let wrapped = children;
if (withAuth) {
wrapped = <AuthProvider>{wrapped}</AuthProvider>;
}
wrapped = (
<QueryClientProvider client={queryClient}>{wrapped}</QueryClientProvider>
);
if (withRouter) {
wrapped = <BrowserRouter>{wrapped}</BrowserRouter>;
}
return <>{wrapped}</>;
};
}
function customRender(ui: ReactElement, options: CustomRenderOptions = {}) {
const { queryClient, withRouter, withAuth, ...renderOptions } = options;
return render(ui, {
wrapper: createWrapper({ queryClient, withRouter, withAuth }),
...renderOptions,
});
}
// Re-export everything from testing-library
export * from '@testing-library/react';
export { userEvent } from '@testing-library/user-event';
export { customRender as render, createTestQueryClient };
5. Create MSW Handlers¶
Create apps/web/src/test/mocks/handlers.ts:
import { http, HttpResponse } from 'msw';
const API_BASE = 'http://localhost:3000';
// Mock data factories
export const mockOrder = (overrides = {}) => ({
id: 'order-1',
shopifyOrderId: '123456789',
shopifyOrderNumber: '#1001',
status: 'PENDING',
customerName: 'John Doe',
customerEmail: 'john@example.com',
shippingAddress: {
address1: '123 Main St',
city: 'Amsterdam',
country: 'Netherlands',
zip: '1012',
},
totalPrice: '99.99',
currency: 'EUR',
totalParts: 3,
completedParts: 1,
createdAt: '2026-01-17T10:00:00Z',
updatedAt: '2026-01-17T10:00:00Z',
completedAt: null,
lineItems: [],
...overrides,
});
export const mockPrintJob = (overrides = {}) => ({
id: 'job-1',
lineItemId: 'line-1',
assemblyPartId: 'part-1',
simplyPrintJobId: 'sp-123',
status: 'PRINTING',
copyNumber: 1,
printerId: 'printer-1',
printerName: 'Prusa MK4',
fileId: 'file-1',
fileName: 'model.gcode',
queuedAt: '2026-01-17T10:00:00Z',
startedAt: '2026-01-17T10:05:00Z',
completedAt: null,
estimatedDuration: 3600,
actualDuration: null,
errorMessage: null,
retryCount: 0,
maxRetries: 3,
createdAt: '2026-01-17T10:00:00Z',
updatedAt: '2026-01-17T10:05:00Z',
orderId: 'order-1',
shopifyOrderNumber: '#1001',
productSku: 'TEST-SKU',
productName: 'Test Product',
...overrides,
});
export const mockDashboardStats = (overrides = {}) => ({
pendingOrders: 5,
processingOrders: 3,
completedToday: 10,
failedOrders: 1,
activePrintJobs: 8,
completedPrintJobsToday: 15,
...overrides,
});
export const mockProductMapping = (overrides = {}) => ({
id: 'mapping-1',
shopifyProductId: 'sp-123',
shopifyVariantId: null,
sku: 'TEST-SKU',
productName: 'Test Product',
description: 'A test product',
isAssembly: false,
isActive: true,
createdAt: '2026-01-17T10:00:00Z',
updatedAt: '2026-01-17T10:00:00Z',
assemblyParts: [],
...overrides,
});
// Request handlers
export const handlers = [
// Health endpoints
http.get(`${API_BASE}/health`, () => {
return HttpResponse.json({ status: 'ok', database: 'connected' });
}),
http.get(`${API_BASE}/health/live`, () => {
return HttpResponse.json({ status: 'ok' });
}),
http.get(`${API_BASE}/health/ready`, () => {
return HttpResponse.json({ status: 'ok', database: 'connected' });
}),
// Orders endpoints
http.get(`${API_BASE}/api/v1/orders`, ({ request }) => {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1');
const pageSize = parseInt(url.searchParams.get('pageSize') || '10');
return HttpResponse.json({
orders: [mockOrder(), mockOrder({ id: 'order-2', shopifyOrderNumber: '#1002' })],
total: 2,
page,
pageSize,
});
}),
http.get(`${API_BASE}/api/v1/orders/:id`, ({ params }) => {
const { id } = params;
if (id === 'not-found') {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json(mockOrder({ id: id as string }));
}),
http.put(`${API_BASE}/api/v1/orders/:id/status`, async ({ params, request }) => {
const { id } = params;
const body = await request.json() as { status: string };
return HttpResponse.json(mockOrder({ id: id as string, status: body.status }));
}),
http.put(`${API_BASE}/api/v1/orders/:id/cancel`, ({ params }) => {
const { id } = params;
return HttpResponse.json(mockOrder({ id: id as string, status: 'CANCELLED' }));
}),
// Print jobs endpoints
http.get(`${API_BASE}/api/v1/print-jobs`, () => {
return HttpResponse.json({
data: [mockPrintJob()],
total: 1,
page: 1,
pageSize: 10,
});
}),
http.get(`${API_BASE}/api/v1/print-jobs/order/:orderId`, () => {
return HttpResponse.json({
data: [mockPrintJob()],
total: 1,
page: 1,
pageSize: 10,
});
}),
http.get(`${API_BASE}/api/v1/print-jobs/active`, () => {
return HttpResponse.json([mockPrintJob()]);
}),
http.post(`${API_BASE}/api/v1/print-jobs/:id/retry`, ({ params }) => {
const { id } = params;
return HttpResponse.json(mockPrintJob({ id: id as string, status: 'QUEUED' }));
}),
http.post(`${API_BASE}/api/v1/print-jobs/:id/cancel`, ({ params }) => {
const { id } = params;
return HttpResponse.json(mockPrintJob({ id: id as string, status: 'CANCELLED' }));
}),
// Dashboard endpoints
http.get(`${API_BASE}/api/v1/dashboard/stats`, () => {
return HttpResponse.json(mockDashboardStats());
}),
// Product mappings endpoints
http.get(`${API_BASE}/api/v1/product-mappings`, () => {
return HttpResponse.json({
mappings: [mockProductMapping()],
total: 1,
page: 1,
pageSize: 10,
});
}),
http.get(`${API_BASE}/api/v1/product-mappings/:id`, ({ params }) => {
const { id } = params;
return HttpResponse.json(mockProductMapping({ id: id as string }));
}),
// Logs endpoints
http.get(`${API_BASE}/api/v1/logs`, () => {
return HttpResponse.json({
logs: [
{
id: 'log-1',
orderId: 'order-1',
printJobId: null,
eventType: 'order.created',
severity: 'INFO',
message: 'Order created',
metadata: {},
createdAt: '2026-01-17T10:00:00Z',
},
],
total: 1,
page: 1,
pageSize: 10,
});
}),
// Shipping endpoints
http.get(`${API_BASE}/api/v1/shipments/order/:orderId`, () => {
return HttpResponse.json({
id: 'shipment-1',
orderId: 'order-1',
status: 'PENDING',
trackingNumber: null,
trackingUrl: null,
labelUrl: null,
});
}),
http.get(`${API_BASE}/api/v1/shipping/status`, () => {
return HttpResponse.json({ enabled: true });
}),
];
Create apps/web/src/test/mocks/server.ts:
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
6. Update Test Setup for MSW¶
Update apps/web/src/test/setup.ts to include MSW:
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server } from './mocks/server';
// Start MSW server before tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers after each test
afterEach(() => {
cleanup();
server.resetHandlers();
});
// Close server after all tests
afterAll(() => server.close());
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
7. Update Project Configuration¶
Update apps/web/project.json:
{
"name": "web",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/web/src",
"projectType": "application",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"options": {
"configFile": "apps/web/vitest.config.ts"
}
},
"test:coverage": {
"executor": "@nx/vite:test",
"options": {
"configFile": "apps/web/vitest.config.ts",
"coverage": true
}
}
}
}
Phase 2: Hook Tests (2-3 days)¶
Priority: P1 | Impact: High | Dependencies: Phase 1
1. Test use-orders.ts¶
Create apps/web/src/hooks/__tests__/use-orders.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import {
useOrders,
useOrder,
useOrderPrintJobs,
useRetryPrintJob,
useCancelPrintJob,
useCancelOrder,
useUpdateOrderStatus,
} from '../use-orders';
import { AuthProvider } from '../../contexts/auth-context';
import { mockOrder, mockPrintJob } from '../../test/mocks/handlers';
// Test wrapper
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
}
describe('useOrders', () => {
it('should fetch orders list', async () => {
const { result } = renderHook(() => useOrders(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.orders).toHaveLength(2);
expect(result.current.data?.orders[0]).toMatchObject({
id: 'order-1',
shopifyOrderNumber: '#1001',
});
});
it('should pass query parameters', async () => {
const { result } = renderHook(
() => useOrders({ page: 2, pageSize: 20, status: 'PENDING' }),
{ wrapper: createWrapper() },
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.page).toBe(2);
expect(result.current.data?.pageSize).toBe(20);
});
});
describe('useOrder', () => {
it('should fetch single order by id', async () => {
const { result } = renderHook(() => useOrder('order-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
id: 'order-1',
shopifyOrderNumber: '#1001',
});
});
it('should not fetch when id is empty', () => {
const { result } = renderHook(() => useOrder(''), {
wrapper: createWrapper(),
});
expect(result.current.fetchStatus).toBe('idle');
});
});
describe('useOrderPrintJobs', () => {
it('should fetch print jobs for order', async () => {
const { result } = renderHook(() => useOrderPrintJobs('order-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBeInstanceOf(Array);
});
it('should not fetch when orderId is empty', () => {
const { result } = renderHook(() => useOrderPrintJobs(''), {
wrapper: createWrapper(),
});
expect(result.current.fetchStatus).toBe('idle');
});
});
describe('useRetryPrintJob', () => {
it('should retry print job', async () => {
const { result } = renderHook(() => useRetryPrintJob(), {
wrapper: createWrapper(),
});
result.current.mutate('job-1');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
id: 'job-1',
status: 'QUEUED',
});
});
});
describe('useCancelPrintJob', () => {
it('should cancel print job', async () => {
const { result } = renderHook(() => useCancelPrintJob(), {
wrapper: createWrapper(),
});
result.current.mutate({ jobId: 'job-1', reason: 'Test cancellation' });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
id: 'job-1',
status: 'CANCELLED',
});
});
});
describe('useCancelOrder', () => {
it('should cancel order', async () => {
const { result } = renderHook(() => useCancelOrder(), {
wrapper: createWrapper(),
});
result.current.mutate('order-1');
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
id: 'order-1',
status: 'CANCELLED',
});
});
});
describe('useUpdateOrderStatus', () => {
it('should update order status', async () => {
const { result } = renderHook(() => useUpdateOrderStatus(), {
wrapper: createWrapper(),
});
result.current.mutate({ orderId: 'order-1', status: 'PROCESSING' });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
id: 'order-1',
status: 'PROCESSING',
});
});
});
2. Test use-dashboard.ts¶
Create apps/web/src/hooks/__tests__/use-dashboard.test.tsx:
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useDashboardStats, useActivePrintJobs } from '../use-dashboard';
import { mockDashboardStats } from '../../test/mocks/handlers';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe('useDashboardStats', () => {
it('should fetch dashboard statistics', async () => {
const { result } = renderHook(() => useDashboardStats(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
pendingOrders: expect.any(Number),
processingOrders: expect.any(Number),
completedToday: expect.any(Number),
failedOrders: expect.any(Number),
activePrintJobs: expect.any(Number),
completedPrintJobsToday: expect.any(Number),
});
});
it('should have refetch interval of 30 seconds', async () => {
const { result } = renderHook(() => useDashboardStats(), {
wrapper: createWrapper(),
});
// The refetchInterval is set in the hook, we just verify the query works
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
});
describe('useActivePrintJobs', () => {
it('should fetch active print jobs', async () => {
const { result } = renderHook(() => useActivePrintJobs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBeInstanceOf(Array);
});
});
3. Test use-mappings.ts¶
Create apps/web/src/hooks/__tests__/use-mappings.test.tsx:
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useMappings, useMapping, useCreateMapping, useUpdateMapping, useDeleteMapping } from '../use-mappings';
import { AuthProvider } from '../../contexts/auth-context';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
}
describe('useMappings', () => {
it('should fetch mappings list', async () => {
const { result } = renderHook(() => useMappings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.mappings).toBeDefined();
expect(result.current.data?.total).toBeDefined();
});
it('should pass query parameters', async () => {
const { result } = renderHook(
() => useMappings({ page: 1, pageSize: 20, isActive: true }),
{ wrapper: createWrapper() },
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
});
describe('useMapping', () => {
it('should fetch single mapping by id', async () => {
const { result } = renderHook(() => useMapping('mapping-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
id: 'mapping-1',
});
});
});
4. Test use-logs.ts¶
Create apps/web/src/hooks/__tests__/use-logs.test.tsx:
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useLogs } from '../use-logs';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe('useLogs', () => {
it('should fetch logs list', async () => {
const { result } = renderHook(() => useLogs(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data?.logs).toBeDefined();
expect(result.current.data?.total).toBeDefined();
});
it('should pass filter parameters', async () => {
const { result } = renderHook(
() => useLogs({ severity: 'ERROR', eventType: 'order.created' }),
{ wrapper: createWrapper() },
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
});
});
5. Test use-health.ts¶
Create apps/web/src/hooks/__tests__/use-health.test.tsx:
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useHealth, useLiveHealth, useReadyHealth } from '../use-health';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe('useHealth', () => {
it('should fetch health status', async () => {
const { result } = renderHook(() => useHealth(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
status: 'ok',
});
});
});
describe('useLiveHealth', () => {
it('should fetch liveness probe', async () => {
const { result } = renderHook(() => useLiveHealth(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
status: 'ok',
});
});
});
describe('useReadyHealth', () => {
it('should fetch readiness probe', async () => {
const { result } = renderHook(() => useReadyHealth(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
status: 'ok',
database: 'connected',
});
});
});
6. Test use-shipments.ts¶
Create apps/web/src/hooks/__tests__/use-shipments.test.tsx:
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';
import { useOrderShipment, useShippingStatus, useCreateShippingLabel } from '../use-shipments';
import { AuthProvider } from '../../contexts/auth-context';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
}
describe('useOrderShipment', () => {
it('should fetch shipment for order', async () => {
const { result } = renderHook(() => useOrderShipment('order-1'), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
orderId: 'order-1',
});
});
it('should not fetch when orderId is empty', () => {
const { result } = renderHook(() => useOrderShipment(''), {
wrapper: createWrapper(),
});
expect(result.current.fetchStatus).toBe('idle');
});
});
describe('useShippingStatus', () => {
it('should fetch shipping enabled status', async () => {
const { result } = renderHook(() => useShippingStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toMatchObject({
enabled: true,
});
});
});
Phase 3: Context Tests (1 day)¶
Priority: P1 | Impact: High | Dependencies: Phase 1
1. Test auth-context.tsx¶
Create apps/web/src/contexts/__tests__/auth-context.test.tsx:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from '../auth-context';
describe('AuthContext', () => {
beforeEach(() => {
// Clear localStorage before each test
window.localStorage.clear();
vi.clearAllMocks();
});
describe('useAuth hook', () => {
it('should throw error when used outside AuthProvider', () => {
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within an AuthProvider');
});
it('should return initial unauthenticated state', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.apiKey).toBeNull();
});
it('should load apiKey from localStorage on mount', () => {
window.localStorage.setItem('forma3d_api_key', 'stored-key');
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.apiKey).toBe('stored-key');
});
});
describe('login', () => {
it('should set apiKey and update isAuthenticated', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
act(() => {
result.current.login('test-api-key');
});
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.apiKey).toBe('test-api-key');
});
it('should store apiKey in localStorage', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
act(() => {
result.current.login('test-api-key');
});
expect(window.localStorage.setItem).toHaveBeenCalledWith(
'forma3d_api_key',
'test-api-key',
);
});
});
describe('logout', () => {
it('should clear apiKey and update isAuthenticated', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
// First login
act(() => {
result.current.login('test-api-key');
});
// Then logout
act(() => {
result.current.logout();
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.apiKey).toBeNull();
});
it('should remove apiKey from localStorage', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
act(() => {
result.current.login('test-api-key');
});
act(() => {
result.current.logout();
});
expect(window.localStorage.removeItem).toHaveBeenCalledWith(
'forma3d_api_key',
);
});
});
describe('error handling', () => {
it('should handle localStorage errors gracefully on getItem', () => {
const originalGetItem = window.localStorage.getItem;
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('Storage error');
});
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
expect(result.current.apiKey).toBeNull();
// Restore
window.localStorage.getItem = originalGetItem;
});
it('should handle localStorage errors gracefully on setItem', () => {
(window.localStorage.setItem as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('Storage error');
});
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
// Should not throw
act(() => {
result.current.login('test-key');
});
// State should still update
expect(result.current.apiKey).toBe('test-key');
});
});
});
Phase 4: API Client Tests (1 day)¶
Priority: P1 | Impact: High | Dependencies: Phase 1
1. Test api-client.ts¶
Create apps/web/src/lib/__tests__/api-client.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../test/mocks/server';
import { apiClient } from '../api-client';
const API_BASE = 'http://localhost:3000';
describe('apiClient', () => {
describe('request function', () => {
it('should handle successful responses', async () => {
const result = await apiClient.health.check();
expect(result).toMatchObject({ status: 'ok' });
});
it('should handle 404 errors', async () => {
server.use(
http.get(`${API_BASE}/api/v1/orders/:id`, () => {
return new HttpResponse(
JSON.stringify({ message: 'Order not found' }),
{ status: 404 },
);
}),
);
await expect(apiClient.orders.get('not-found')).rejects.toThrow(
'Order not found',
);
});
it('should handle 500 errors', async () => {
server.use(
http.get(`${API_BASE}/health`, () => {
return new HttpResponse(
JSON.stringify({ message: 'Internal server error' }),
{ status: 500 },
);
}),
);
await expect(apiClient.health.check()).rejects.toThrow(
'Internal server error',
);
});
it('should handle network errors', async () => {
server.use(
http.get(`${API_BASE}/health`, () => {
return HttpResponse.error();
}),
);
await expect(apiClient.health.check()).rejects.toThrow();
});
it('should include API key header when provided', async () => {
let capturedHeaders: Headers | undefined;
server.use(
http.put(`${API_BASE}/api/v1/orders/:id/status`, ({ request }) => {
capturedHeaders = new Headers(request.headers);
return HttpResponse.json({ id: 'order-1', status: 'PROCESSING' });
}),
);
await apiClient.orders.updateStatus('order-1', 'PROCESSING' as any, 'test-api-key');
expect(capturedHeaders?.get('X-API-Key')).toBe('test-api-key');
});
it('should handle 204 No Content responses', async () => {
server.use(
http.delete(`${API_BASE}/api/v1/product-mappings/:id`, () => {
return new HttpResponse(null, { status: 204 });
}),
);
const result = await apiClient.mappings.delete('mapping-1', 'api-key');
expect(result).toBeUndefined();
});
});
describe('orders', () => {
it('should list orders with pagination', async () => {
const result = await apiClient.orders.list({ page: 1, pageSize: 10 });
expect(result).toMatchObject({
orders: expect.any(Array),
total: expect.any(Number),
page: 1,
pageSize: 10,
});
});
it('should get order by id', async () => {
const result = await apiClient.orders.get('order-1');
expect(result).toMatchObject({
id: 'order-1',
shopifyOrderNumber: '#1001',
});
});
it('should update order status', async () => {
const result = await apiClient.orders.updateStatus(
'order-1',
'PROCESSING' as any,
'api-key',
);
expect(result).toMatchObject({
id: 'order-1',
status: 'PROCESSING',
});
});
it('should cancel order', async () => {
const result = await apiClient.orders.cancel('order-1', 'api-key');
expect(result).toMatchObject({
id: 'order-1',
status: 'CANCELLED',
});
});
});
describe('printJobs', () => {
it('should list print jobs', async () => {
const result = await apiClient.printJobs.list();
expect(result).toMatchObject({
data: expect.any(Array),
total: expect.any(Number),
});
});
it('should get print jobs by order id', async () => {
const result = await apiClient.printJobs.getByOrderId('order-1');
expect(result).toBeInstanceOf(Array);
});
it('should get active print jobs', async () => {
const result = await apiClient.printJobs.getActive();
expect(result).toBeInstanceOf(Array);
});
it('should retry print job', async () => {
const result = await apiClient.printJobs.retry('job-1', 'api-key');
expect(result).toMatchObject({
id: 'job-1',
status: 'QUEUED',
});
});
it('should cancel print job', async () => {
const result = await apiClient.printJobs.cancel('job-1', 'reason', 'api-key');
expect(result).toMatchObject({
id: 'job-1',
status: 'CANCELLED',
});
});
});
describe('dashboard', () => {
it('should get dashboard stats', async () => {
const result = await apiClient.dashboard.getStats();
expect(result).toMatchObject({
pendingOrders: expect.any(Number),
processingOrders: expect.any(Number),
completedToday: expect.any(Number),
failedOrders: expect.any(Number),
activePrintJobs: expect.any(Number),
completedPrintJobsToday: expect.any(Number),
});
});
});
describe('mappings', () => {
it('should list mappings', async () => {
const result = await apiClient.mappings.list();
expect(result).toMatchObject({
mappings: expect.any(Array),
total: expect.any(Number),
});
});
it('should get mapping by id', async () => {
const result = await apiClient.mappings.get('mapping-1');
expect(result).toMatchObject({
id: 'mapping-1',
});
});
});
describe('logs', () => {
it('should list logs with filters', async () => {
const result = await apiClient.logs.list({
severity: 'ERROR',
page: 1,
pageSize: 20,
});
expect(result).toMatchObject({
logs: expect.any(Array),
total: expect.any(Number),
});
});
});
describe('shipping', () => {
it('should get shipment by order id', async () => {
const result = await apiClient.shipping.getByOrderId('order-1');
expect(result).toMatchObject({
orderId: 'order-1',
});
});
it('should return null for 404 on shipment', async () => {
server.use(
http.get(`${API_BASE}/api/v1/shipments/order/:orderId`, () => {
return new HttpResponse(
JSON.stringify({ message: 'Not found' }),
{ status: 404 },
);
}),
);
const result = await apiClient.shipping.getByOrderId('not-found');
expect(result).toBeNull();
});
it('should get shipping status', async () => {
const result = await apiClient.shipping.getStatus();
expect(result).toMatchObject({
enabled: true,
});
});
});
});
Phase 5: UI Component Tests (2-3 days)¶
Priority: P2 | Impact: Medium | Dependencies: Phase 1
1. Test Badge Component¶
Create apps/web/src/components/ui/__tests__/badge.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import { Badge, StatusBadge } from '../badge';
describe('Badge', () => {
it('should render children', () => {
render(<Badge>Test Badge</Badge>);
expect(screen.getByText('Test Badge')).toBeInTheDocument();
});
it('should apply default variant styles', () => {
render(<Badge>Default</Badge>);
const badge = screen.getByText('Default');
expect(badge).toHaveClass('bg-gray-100');
});
it('should apply success variant styles', () => {
render(<Badge variant="success">Success</Badge>);
const badge = screen.getByText('Success');
expect(badge).toHaveClass('bg-green-100');
});
it('should apply warning variant styles', () => {
render(<Badge variant="warning">Warning</Badge>);
const badge = screen.getByText('Warning');
expect(badge).toHaveClass('bg-yellow-100');
});
it('should apply error variant styles', () => {
render(<Badge variant="error">Error</Badge>);
const badge = screen.getByText('Error');
expect(badge).toHaveClass('bg-red-100');
});
it('should apply additional className', () => {
render(<Badge className="custom-class">Custom</Badge>);
const badge = screen.getByText('Custom');
expect(badge).toHaveClass('custom-class');
});
});
describe('StatusBadge', () => {
it.each([
['PENDING', 'warning'],
['PROCESSING', 'info'],
['COMPLETED', 'success'],
['FAILED', 'error'],
['CANCELLED', 'default'],
])('should render %s status with correct variant', (status, expectedVariant) => {
render(<StatusBadge status={status} />);
expect(screen.getByText(status)).toBeInTheDocument();
});
});
2. Test Button Component¶
Create apps/web/src/components/ui/__tests__/button.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import userEvent from '@testing-library/user-event';
import { Button } from '../button';
describe('Button', () => {
it('should render children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('should handle click events', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('should be disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('should not call onClick when disabled', async () => {
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}>Disabled</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
it('should show loading state', () => {
render(<Button loading>Loading</Button>);
expect(screen.getByRole('button')).toBeDisabled();
// Check for loading spinner or text
});
it('should apply variant styles', () => {
const { rerender } = render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-blue-600');
rerender(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-gray-200');
rerender(<Button variant="danger">Danger</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-red-600');
});
it('should apply size styles', () => {
const { rerender } = render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('px-3', 'py-1.5');
rerender(<Button size="md">Medium</Button>);
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');
rerender(<Button size="lg">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
});
});
3. Test Card Component¶
Create apps/web/src/components/ui/__tests__/card.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import { Card, CardHeader, CardTitle, CardContent } from '../card';
describe('Card', () => {
it('should render children', () => {
render(<Card>Card content</Card>);
expect(screen.getByText('Card content')).toBeInTheDocument();
});
it('should apply additional className', () => {
render(<Card className="custom-class">Content</Card>);
expect(screen.getByText('Content').closest('div')).toHaveClass('custom-class');
});
});
describe('CardHeader', () => {
it('should render header content', () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
</CardHeader>
</Card>,
);
expect(screen.getByText('Title')).toBeInTheDocument();
});
});
describe('CardContent', () => {
it('should render content', () => {
render(
<Card>
<CardContent>Body content</CardContent>
</Card>,
);
expect(screen.getByText('Body content')).toBeInTheDocument();
});
});
4. Test Loading Component¶
Create apps/web/src/components/ui/__tests__/loading.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import { Loading, LoadingSpinner, LoadingPage } from '../loading';
describe('Loading', () => {
it('should render loading text', () => {
render(<Loading />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('should render custom text', () => {
render(<Loading text="Please wait..." />);
expect(screen.getByText('Please wait...')).toBeInTheDocument();
});
});
describe('LoadingSpinner', () => {
it('should render spinner', () => {
render(<LoadingSpinner />);
// Check for spinner element (usually an SVG or animated div)
expect(document.querySelector('[class*="animate-spin"]')).toBeInTheDocument();
});
it('should apply size classes', () => {
const { container } = render(<LoadingSpinner size="lg" />);
expect(container.querySelector('[class*="w-8"]')).toBeInTheDocument();
});
});
describe('LoadingPage', () => {
it('should render full page loading', () => {
render(<LoadingPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
});
5. Test Pagination Component¶
Create apps/web/src/components/ui/__tests__/pagination.test.tsx:
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '../../../test/test-utils';
import userEvent from '@testing-library/user-event';
import { Pagination } from '../pagination';
describe('Pagination', () => {
it('should render current page and total', () => {
render(
<Pagination
currentPage={1}
totalPages={10}
onPageChange={() => {}}
/>,
);
expect(screen.getByText(/1/)).toBeInTheDocument();
expect(screen.getByText(/10/)).toBeInTheDocument();
});
it('should call onPageChange when clicking next', async () => {
const handlePageChange = vi.fn();
render(
<Pagination
currentPage={1}
totalPages={10}
onPageChange={handlePageChange}
/>,
);
await userEvent.click(screen.getByRole('button', { name: /next/i }));
expect(handlePageChange).toHaveBeenCalledWith(2);
});
it('should call onPageChange when clicking previous', async () => {
const handlePageChange = vi.fn();
render(
<Pagination
currentPage={5}
totalPages={10}
onPageChange={handlePageChange}
/>,
);
await userEvent.click(screen.getByRole('button', { name: /previous/i }));
expect(handlePageChange).toHaveBeenCalledWith(4);
});
it('should disable previous button on first page', () => {
render(
<Pagination
currentPage={1}
totalPages={10}
onPageChange={() => {}}
/>,
);
expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
});
it('should disable next button on last page', () => {
render(
<Pagination
currentPage={10}
totalPages={10}
onPageChange={() => {}}
/>,
);
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
});
});
Phase 6: Documentation Updates (30 minutes)¶
Priority: Medium | Impact: Medium | Dependencies: Phase 5
1. Update Technical Debt Register¶
Update docs/04-development/techdebt/technical-debt-register.md:
In the Summary Dashboard section:
## Summary Dashboard
| Category | Critical | High | Medium | Low | Total |
|----------|----------|------|--------|-----|-------|
| Architecture Debt | 0 | 3 | 2 | 1 | 6 |
| Code Debt | 0 | 4 | 5 | 3 | 12 |
| Test Debt | 0 | 2 | 1 | 0 | 3 |
| Documentation Debt | 0 | 1 | 2 | 1 | 4 |
| Infrastructure Debt | 0 | 1 | 2 | 1 | 4 |
| **Total** | **0** | **11** | **12** | **6** | **29** |
Update the TD-002 section:
### ~~TD-002: Missing Frontend Test Coverage~~ ✅ RESOLVED
**Type:** Test Debt
**Status:** ✅ **Resolved in Phase 5d**
**Resolution Date:** 2026-XX-XX
#### Resolution
Implemented comprehensive frontend test coverage using Vitest:
- **Test Framework:** Vitest with React Testing Library
- **Mocking:** MSW (Mock Service Worker) for API mocking
- **Coverage:** 60%+ across hooks, contexts, and components
**Files Created:**
- `apps/web/vitest.config.ts` - Vitest configuration
- `apps/web/src/test/setup.ts` - Test setup with MSW
- `apps/web/src/test/test-utils.tsx` - Custom render utilities
- `apps/web/src/test/mocks/handlers.ts` - MSW request handlers
- `apps/web/src/test/mocks/server.ts` - MSW server setup
- `apps/web/src/hooks/__tests__/*.test.tsx` - Hook tests
- `apps/web/src/contexts/__tests__/*.test.tsx` - Context tests
- `apps/web/src/lib/__tests__/*.test.ts` - API client tests
- `apps/web/src/components/ui/__tests__/*.test.tsx` - Component tests
---
Phase 7: CI/CD Pipeline Integration (1 hour)¶
Priority: High | Impact: High | Dependencies: Phase 1
1. Update Vitest Configuration for CI¶
Update apps/web/vitest.config.ts to output JUnit reports:
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
// CI-specific reporters
reporters: process.env.CI
? ['default', 'junit']
: ['default'],
outputFile: {
junit: '../../test-results/web/junit.xml',
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'cobertura'],
reportsDirectory: '../../coverage/web',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.{test,spec}.{ts,tsx}',
'src/test/**',
'src/main.tsx',
'src/router.tsx',
'src/vite-env.d.ts',
],
thresholds: {
statements: 60,
branches: 60,
functions: 60,
lines: 60,
},
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@forma3d/domain': resolve(__dirname, '../../libs/domain/src'),
},
},
});
2. Update Azure DevOps Pipeline¶
Update .azuredevops/pipelines/ci.yml to add test reporting:
Replace the existing Test job with:
# -----------------------------------------------------------------------
# Test Job (Updated with Test Reports)
# -----------------------------------------------------------------------
- job: Test
displayName: 'Test'
services:
postgres:
image: postgres:16
ports:
- 5432:5432
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: forma3d_connect_test
steps:
- task: NodeTool@0
inputs:
versionSpec: '$(nodeVersion)'
displayName: 'Install Node.js'
- script: |
corepack enable
corepack prepare pnpm@$(pnpmVersion) --activate
displayName: 'Install pnpm'
- script: pnpm install --frozen-lockfile
displayName: 'Install Dependencies'
- script: pnpm prisma generate
displayName: 'Generate Prisma Client'
- script: |
for i in {1..30}; do
pg_isready -h localhost -p 5432 -U postgres && break
sleep 1
done
displayName: 'Wait for PostgreSQL'
- script: pnpm prisma migrate deploy
displayName: 'Run Migrations'
env:
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/forma3d_connect_test?schema=public'
# Create test results directories
- script: mkdir -p test-results/api test-results/web coverage/api coverage/web
displayName: 'Create Results Directories'
# Run API tests with JUnit output
- script: pnpm nx test api --coverage
displayName: 'Run API Tests'
env:
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/forma3d_connect_test?schema=public'
CI: 'true'
continueOnError: true
# Run Frontend tests with JUnit output
- script: pnpm nx test web --coverage
displayName: 'Run Frontend Tests'
env:
CI: 'true'
continueOnError: true
# Publish Test Results (JUnit format)
- task: PublishTestResults@2
displayName: 'Publish Test Results'
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '**/test-results/**/junit.xml'
mergeTestResults: true
failTaskOnFailedTests: true
testRunTitle: 'Unit Tests - $(Build.BuildNumber)'
condition: succeededOrFailed()
# Publish Code Coverage (Cobertura format)
- task: PublishCodeCoverageResults@2
displayName: 'Publish Code Coverage'
inputs:
summaryFileLocation: '$(System.DefaultWorkingDirectory)/coverage/**/cobertura-coverage.xml'
pathToSources: '$(System.DefaultWorkingDirectory)'
condition: succeededOrFailed()
3. Update Jest Configuration for API (JUnit output)¶
Install jest-junit:
pnpm add -D jest-junit
Update jest.config.ts in the root or apps/api/jest.config.ts:
import { getJestProjectsAsync } from '@nx/jest';
export default async () => ({
projects: await getJestProjectsAsync(),
// Global reporters for all projects
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: './test-results/api',
outputName: 'junit.xml',
classNameTemplate: '{classname}',
titleTemplate: '{title}',
},
],
],
});
4. Update Project Configuration for CI¶
Update apps/web/project.json:
{
"name": "web",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/web/src",
"projectType": "application",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"options": {
"configFile": "apps/web/vitest.config.ts"
}
},
"test:coverage": {
"executor": "@nx/vite:test",
"options": {
"configFile": "apps/web/vitest.config.ts",
"coverage": true
}
}
}
}
5. Update .gitignore¶
Ensure these directories are ignored:
# Test results and coverage
test-results/
coverage/
📁 Files to Create/Modify¶
New Files¶
apps/web/
vitest.config.ts
src/
test/
setup.ts
test-utils.tsx
mocks/
handlers.ts
server.ts
hooks/__tests__/
use-orders.test.tsx
use-dashboard.test.tsx
use-mappings.test.tsx
use-logs.test.tsx
use-health.test.tsx
use-shipments.test.tsx
contexts/__tests__/
auth-context.test.tsx
lib/__tests__/
api-client.test.ts
components/ui/__tests__/
badge.test.tsx
button.test.tsx
card.test.tsx
loading.test.tsx
pagination.test.tsx
Modified Files¶
apps/web/project.json # Add test targets
apps/web/vitest.config.ts # Add CI reporters (JUnit, Cobertura)
.azuredevops/pipelines/ci.yml # Add test reporting tasks
jest.config.ts # Add jest-junit reporter
.gitignore # Add test-results/ and coverage/
package.json # Add testing dependencies
docs/04-development/techdebt/technical-debt-register.md # Mark TD-002 resolved
🧪 Testing Requirements¶
Coverage Targets¶
| Category | Files | Target Coverage |
|---|---|---|
| Hooks | 6 | 80% |
| Contexts | 2 | 90% |
| API Client | 1 | 80% |
| UI Components | 10 | 70% |
| Overall | 19 | 60% |
Test Verification¶
# Run all frontend tests
pnpm nx test web
# Run with coverage
pnpm nx test:coverage web
# Watch mode for development
pnpm nx test web --watch
✅ Validation Checklist¶
Phase 1: Configuration¶
- Testing dependencies installed
- Vitest configured with jsdom environment
- Test setup file created with MSW
- Test utilities created with wrapper
- MSW handlers for all API endpoints
- Project.json updated with test targets
Phase 2: Hook Tests¶
-
use-orders.test.tsx- All 7 hooks tested -
use-dashboard.test.tsx- Both hooks tested -
use-mappings.test.tsx- All hooks tested -
use-logs.test.tsx- Hook tested -
use-health.test.tsx- All 3 hooks tested -
use-shipments.test.tsx- All hooks tested
Phase 3: Context Tests¶
-
auth-context.test.tsx- Login/logout/persistence tested
Phase 4: API Client Tests¶
-
api-client.test.ts- All endpoints and error handling tested
Phase 5: Component Tests¶
- Badge component tested
- Button component tested
- Card component tested
- Loading component tested
- Pagination component tested
Phase 6: Documentation¶
- Technical debt register updated (TD-002 marked resolved)
- Summary dashboard counts updated
Phase 7: CI/CD Integration¶
- Vitest configured with JUnit reporter for CI
- Vitest configured with Cobertura coverage reporter
- jest-junit installed for API tests
- Azure DevOps pipeline updated with PublishTestResults task
- Azure DevOps pipeline updated with PublishCodeCoverageResults task
- test-results/ and coverage/ directories in .gitignore
- Test results visible in Azure DevOps pipeline runs
Final Verification¶
# All tests pass
pnpm nx test web
# Coverage meets threshold (60%)
pnpm nx test:coverage web
# Build still works
pnpm nx build web
# Lint passes
pnpm nx lint web
# CI reporter generates JUnit (verify file exists after test run)
CI=true pnpm nx test web
ls -la test-results/web/junit.xml
# Verify coverage report generated
ls -la coverage/web/cobertura-coverage.xml
CI/CD Verification¶
After pushing changes, verify in Azure DevOps:
- Test Results Tab: Pipeline run should show "Tests" tab with pass/fail counts
- Code Coverage Tab: Pipeline run should show "Code Coverage" tab with percentage
- Test History: Individual test names should be visible and searchable
- Failure Details: Failed tests should show stack traces and error messages
🚫 Constraints and Rules¶
MUST DO¶
- Use Vitest (not Jest) for frontend tests
- Use React Testing Library for component testing
- Use MSW for API mocking
- Create test utilities with proper providers
- Test error states and edge cases
- Achieve 60% minimum coverage
MUST NOT¶
- Use snapshot testing for components
- Mock implementation details
- Test private functions directly
- Skip async/await error handling tests
- Leave flaky tests in the suite
📊 Success Metrics¶
Before Phase 5d¶
| Metric | Value |
|---|---|
| Test files | 0 |
| Test coverage | 0% |
| Critical tech debt items | 1 |
After Phase 5d¶
| Metric | Value |
|---|---|
| Test files | 15+ |
| Test coverage | 60%+ |
| Critical tech debt items | 0 |
END OF PROMPT
This prompt resolves TD-002 from the technical debt register by implementing comprehensive frontend test coverage. The testing setup uses modern tools (Vitest, MSW, React Testing Library) following React testing best practices.