Component Testing

Testing strategies, patterns, and best practices for React components in Smart Shelf.

Component Testing

Comprehensive testing strategies and patterns for ensuring React component reliability, functionality, and user experience in Smart Shelf.

Testing Philosophy

Testing Pyramid for Components

        ┌─────────────────────┐
        │  End-to-End Tests   │ ← Few, expensive, slow
        │   (Playwright)      │
        └─────────────────────┘
              ┌─────────────────────┐
              │ Integration Tests   │ ← Some, moderate cost
              │  (Component +       │
              │   Dependencies)     │
              └─────────────────────┘
                    ┌─────────────────────┐
                    │    Unit Tests       │ ← Many, cheap, fast
                    │  (React Testing     │
                    │     Library)        │
                    └─────────────────────┘

Testing Principles

  1. Test Behavior, Not Implementation

    • Focus on what users see and do
    • Avoid testing internal component state
    • Test component interactions and outcomes
  2. Accessibility First

    • Use semantic queries (getByRole, getByLabelText)
    • Ensure components work with assistive technologies
    • Test keyboard navigation and focus management
  3. Realistic Testing Environment

    • Mock external dependencies appropriately
    • Use realistic data and scenarios
    • Test error states and edge cases

Unit Testing with React Testing Library

Basic Component Test Structure

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductForm } from './ProductForm';

describe('ProductForm', () => {
  const defaultProps = {
    onSubmit: jest.fn(),
    onCancel: jest.fn(),
  };

  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should render form fields correctly', () => {
    render(<ProductForm {...defaultProps} />);
    
    expect(screen.getByLabelText(/product name/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/sku/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /create product/i })).toBeInTheDocument();
  });

  it('should display validation errors for required fields', async () => {
    const user = userEvent.setup();
    render(<ProductForm {...defaultProps} />);
    
    const submitButton = screen.getByRole('button', { name: /create product/i });
    await user.click(submitButton);
    
    await waitFor(() => {
      expect(screen.getByText(/product name is required/i)).toBeInTheDocument();
      expect(screen.getByText(/sku is required/i)).toBeInTheDocument();
    });
    
    expect(defaultProps.onSubmit).not.toHaveBeenCalled();
  });

  it('should submit form with valid data', async () => {
    const user = userEvent.setup();
    const mockProduct = {
      name: 'Test Product',
      sku: 'TEST-001',
      description: 'Test description',
      costPrice: 10.00,
      sellingPrice: 15.00,
    };

    render(<ProductForm {...defaultProps} />);
    
    await user.type(screen.getByLabelText(/product name/i), mockProduct.name);
    await user.type(screen.getByLabelText(/sku/i), mockProduct.sku);
    await user.type(screen.getByLabelText(/description/i), mockProduct.description);
    await user.type(screen.getByLabelText(/cost price/i), mockProduct.costPrice.toString());
    await user.type(screen.getByLabelText(/selling price/i), mockProduct.sellingPrice.toString());
    
    await user.click(screen.getByRole('button', { name: /create product/i }));
    
    await waitFor(() => {
      expect(defaultProps.onSubmit).toHaveBeenCalledWith(
        expect.objectContaining(mockProduct)
      );
    });
  });

  it('should handle form submission errors', async () => {
    const user = userEvent.setup();
    const errorMessage = 'Failed to create product';
    const onSubmit = jest.fn().mockRejectedValue(new Error(errorMessage));
    
    render(<ProductForm {...defaultProps} onSubmit={onSubmit} />);
    
    // Fill in required fields
    await user.type(screen.getByLabelText(/product name/i), 'Test Product');
    await user.type(screen.getByLabelText(/sku/i), 'TEST-001');
    
    await user.click(screen.getByRole('button', { name: /create product/i }));
    
    await waitFor(() => {
      expect(screen.getByText(errorMessage)).toBeInTheDocument();
    });
  });
});

Testing Custom Hooks

import { renderHook, act } from '@testing-library/react';
import { useInventory } from './useInventory';
import { inventoryService } from '../services/inventoryService';

// Mock the service
jest.mock('../services/inventoryService');
const mockInventoryService = inventoryService as jest.Mocked<typeof inventoryService>;

describe('useInventory', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should fetch inventory data on mount', async () => {
    const mockInventory = [
      { id: '1', productName: 'Product 1', quantity: 10 },
      { id: '2', productName: 'Product 2', quantity: 5 },
    ];
    
    mockInventoryService.getInventory.mockResolvedValue(mockInventory);
    
    const { result } = renderHook(() => useInventory());
    
    expect(result.current.loading).toBe(true);
    expect(result.current.items).toEqual([]);
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.items).toEqual(mockInventory);
    expect(result.current.error).toBeNull();
  });

  it('should handle stock adjustment', async () => {
    const mockInventory = [{ id: '1', productName: 'Product 1', quantity: 10 }];
    mockInventoryService.getInventory.mockResolvedValue(mockInventory);
    mockInventoryService.adjustStock.mockResolvedValue({ success: true });
    
    const { result } = renderHook(() => useInventory());
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    await act(async () => {
      await result.current.adjustStock('1', { quantity: 5, reason: 'test' });
    });
    
    expect(mockInventoryService.adjustStock).toHaveBeenCalledWith('1', {
      quantity: 5,
      reason: 'test',
    });
  });

  it('should handle errors gracefully', async () => {
    const errorMessage = 'Failed to fetch inventory';
    mockInventoryService.getInventory.mockRejectedValue(new Error(errorMessage));
    
    const { result } = renderHook(() => useInventory());
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.error).toEqual(expect.objectContaining({
      message: errorMessage,
    }));
    expect(result.current.items).toEqual([]);
  });
});

Integration Testing

Testing Components with Context

import { render, screen } from '@testing-library/react';
import { InventoryProvider } from '../contexts/InventoryContext';
import { InventoryTable } from './InventoryTable';

// Test wrapper with context providers
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
  <InventoryProvider>
    {children}
  </InventoryProvider>
);

describe('InventoryTable Integration', () => {
  it('should display inventory data from context', async () => {
    render(
      <InventoryTable warehouseId="warehouse-1" />,
      { wrapper: TestWrapper }
    );
    
    await waitFor(() => {
      expect(screen.getByText('Product 1')).toBeInTheDocument();
      expect(screen.getByText('Product 2')).toBeInTheDocument();
    });
  });

  it('should handle stock adjustments through context', async () => {
    const user = userEvent.setup();
    
    render(
      <InventoryTable warehouseId="warehouse-1" />,
      { wrapper: TestWrapper }
    );
    
    await waitFor(() => {
      expect(screen.getByText('Product 1')).toBeInTheDocument();
    });
    
    // Open adjustment dialog
    const adjustButton = screen.getByRole('button', { name: /adjust stock/i });
    await user.click(adjustButton);
    
    // Verify dialog opens and make adjustment
    expect(screen.getByText(/stock adjustment/i)).toBeInTheDocument();
  });
});

Mock Service Worker for API Testing

// test/mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/inventory', (req, res, ctx) => {
    return res(
      ctx.json([
        {
          id: '1',
          productId: 'product-1',
          productName: 'Test Product',
          quantity: 10,
          minLevel: 5,
        },
      ])
    );
  }),

  rest.post('/api/inventory/:id/adjust', (req, res, ctx) => {
    return res(
      ctx.json({ success: true, newQuantity: 15 })
    );
  }),

  rest.post('/api/products', (req, res, ctx) => {
    return res(
      ctx.status(201),
      ctx.json({
        id: 'new-product-id',
        name: 'New Product',
        sku: 'SKU-001',
      })
    );
  }),
];

// test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

export const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Visual Testing with Storybook

Component Stories

// ProductCard.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { ProductCard } from './ProductCard';

const meta: Meta<typeof ProductCard> = {
  title: 'Components/ProductCard',
  component: ProductCard,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
  argTypes: {
    product: {
      control: 'object',
    },
    showActions: {
      control: 'boolean',
    },
  },
};

export default meta;
type Story = StoryObj<typeof meta>;

const sampleProduct = {
  id: '1',
  name: 'Wireless Headphones',
  sku: 'WH-001',
  description: 'High-quality wireless headphones with noise cancellation',
  costPrice: 50.00,
  sellingPrice: 99.99,
  currentStock: 25,
  minStockLevel: 10,
  unitOfMeasure: 'piece',
  status: 'active' as const,
};

export const Default: Story = {
  args: {
    product: sampleProduct,
    onEdit: action('edit'),
    onDelete: action('delete'),
    onViewDetails: action('view-details'),
  },
};

export const LowStock: Story = {
  args: {
    product: {
      ...sampleProduct,
      currentStock: 3,
    },
    onEdit: action('edit'),
    onDelete: action('delete'),
  },
};

export const OutOfStock: Story = {
  args: {
    product: {
      ...sampleProduct,
      currentStock: 0,
    },
    onEdit: action('edit'),
    onDelete: action('delete'),
  },
};

export const WithoutActions: Story = {
  args: {
    product: sampleProduct,
    showActions: false,
  },
};

export const Loading: Story = {
  render: () => <ProductCardSkeleton />,
};

Interaction Testing in Storybook

// ProductForm.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { ProductForm } from './ProductForm';

const meta: Meta<typeof ProductForm> = {
  title: 'Forms/ProductForm',
  component: ProductForm,
  parameters: {
    layout: 'centered',
  },
};

export default meta;
type Story = StoryObj<typeof meta>;

export const FillAndSubmit: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // Fill out the form
    await userEvent.type(canvas.getByLabelText(/product name/i), 'Test Product');
    await userEvent.type(canvas.getByLabelText(/sku/i), 'TEST-001');
    await userEvent.type(canvas.getByLabelText(/cost price/i), '10.00');
    await userEvent.type(canvas.getByLabelText(/selling price/i), '15.00');
    
    // Submit the form
    await userEvent.click(canvas.getByRole('button', { name: /create product/i }));
    
    // Verify form submission behavior
    await expect(canvas.getByRole('button', { name: /create product/i })).toBeInTheDocument();
  },
};

export const ValidationErrors: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    
    // Try to submit empty form
    await userEvent.click(canvas.getByRole('button', { name: /create product/i }));
    
    // Verify validation errors appear
    await expect(canvas.getByText(/product name is required/i)).toBeInTheDocument();
    await expect(canvas.getByText(/sku is required/i)).toBeInTheDocument();
  },
};

Test Utilities and Helpers

Custom Render Function

// test/utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { InventoryProvider } from '../src/contexts/InventoryContext';

// Create a custom render function with providers
const AllProviders = ({ children }: { children: React.ReactNode }) => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
  });

  return (
    <QueryClientProvider client={queryClient}>
      <InventoryProvider>
        {children}
      </InventoryProvider>
    </QueryClientProvider>
  );
};

const customRender = (
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllProviders, ...options });

export * from '@testing-library/react';
export { customRender as render };

Test Data Factories

// test/factories/productFactory.ts
import { faker } from '@faker-js/faker';
import { Product } from '../types';

export const createMockProduct = (overrides?: Partial<Product>): Product => ({
  id: faker.string.uuid(),
  name: faker.commerce.productName(),
  sku: faker.string.alphanumeric(8).toUpperCase(),
  description: faker.commerce.productDescription(),
  costPrice: parseFloat(faker.commerce.price({ min: 10, max: 100 })),
  sellingPrice: parseFloat(faker.commerce.price({ min: 15, max: 150 })),
  currentStock: faker.number.int({ min: 0, max: 100 }),
  minStockLevel: faker.number.int({ min: 5, max: 20 }),
  unitOfMeasure: faker.helpers.arrayElement(['piece', 'kg', 'liter', 'box']),
  status: 'active',
  categoryId: faker.string.uuid(),
  brand: faker.company.name(),
  ...overrides,
});

export const createMockProducts = (count: number): Product[] => 
  Array.from({ length: count }, () => createMockProduct());

Assertion Helpers

// test/assertions.ts
import { expect } from '@testing-library/jest-dom';

export const expectFormToBeValid = (form: HTMLFormElement) => {
  expect(form).toHaveFormValues({});
  expect(form.checkValidity()).toBe(true);
};

export const expectFormToHaveErrors = (fields: string[]) => {
  fields.forEach(field => {
    expect(screen.getByText(new RegExp(`${field}.*required`, 'i'))).toBeInTheDocument();
  });
};

export const expectLoadingState = () => {
  expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
};

export const expectEmptyState = (message: string) => {
  expect(screen.getByText(message)).toBeInTheDocument();
};

Test Configuration

Jest Configuration

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.{ts,tsx}',
    '!src/**/index.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
};

Test Setup File

// test/setup.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';

// Setup MSW
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Mock IntersectionObserver
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
  observe: jest.fn(),
  unobserve: jest.fn(),
  disconnect: jest.fn(),
}));

// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

This comprehensive testing approach ensures Smart Shelf components are reliable, accessible, and maintainable while providing confidence in the user experience.