component-testing · git:20260203.0ca173c · 2026-02-03 · sha256 7750183a0e7a4967

component-testing git:20260203.0ca173cA

Immutable. This exact content is served forever at /api/v1/blob/7750183a0e7a4967.

---
description: Writing unit tests and snapshot tests for UI component rendering
alwaysApply: false
---
# Unit Testing Guidelines

This document outlines the essential rules and patterns for creating unit tests and snapshot tests for UI components in the Franky codebase.

## File Naming

1. **Create a new `.test.tsx` for new UI components**
   - Each UI component should have a corresponding test file
   - Example: `Button.tsx` → `Button.test.tsx`
   - Place test files in the same directory as the component

## Unit Test Structure

### Sanity Tests

2. **Add at least one sanity test to confirm UI renders with some expected element(s) and attributes**
   - Verify the component renders without errors
   - Check for essential elements and attributes that confirm correct rendering
   - Example:
     ```typescript
     it('renders with default props', () => {
       render(<Button>Default Button</Button>);
       const button = screen.getByRole('button');
       expect(button).toBeInTheDocument();
       expect(button).toHaveAttribute('data-slot', 'button');
     });
     ```

### Functional Tests

3. **Functional tests for handling hover and click events**
   - Test interactive behaviour like click handlers
   - Test hover states by checking for appropriate CSS classes
   - Example:
     ```typescript
     it('handles click events', () => {
       const handleClick = vi.fn();
       render(<Button onClick={handleClick}>Click me</Button>);
       fireEvent.click(screen.getByRole('button'));
       expect(handleClick).toHaveBeenCalledTimes(1);
     });

     it('handles hover states correctly', () => {
       render(<Button variant="default">Hover Button</Button>);
       const button = screen.getByRole('button');
       expect(button).toHaveClass('hover:!bg-brand/30');
     });
     ```

## Snapshot Test Structure

### Organisation

4. **Snapshot tests grouped in a single describe block, like `ComponentName - Snapshots`**
   - All snapshot tests should be in a separate describe block
   - Use the naming pattern: `ComponentName - Snapshots`
   - Place snapshot tests after unit tests in the test file
   - Example:
     ```typescript
     describe('Button - Snapshots', () => {
       // All snapshot tests here
     });
     ```

### Coverage

5. **Snapshot tests for key states such as each Button size**
   - Create snapshot tests for all meaningful prop combinations
   - Test different sizes, variants, and states
   - Each unique state/prop combination should have its own snapshot test
   - Example:
     ```typescript
     it('matches snapshot for small size', () => {
       const { container } = render(<Button size="sm">Small</Button>);
       expect(container.firstChild).toMatchSnapshot();
     });

     it('matches snapshot for large size', () => {
       const { container } = render(<Button size="lg">Large</Button>);
       expect(container.firstChild).toMatchSnapshot();
     });
     ```

### Snapshot Test Rules

6. **Max one expect per snapshot test**
   - Each snapshot test should contain exactly one `expect().toMatchSnapshot()` call
   - Do not combine multiple snapshots in a single test
   - Example (correct):
     ```typescript
     it('matches snapshot for secondary variant', () => {
       const { container } = render(<Button variant="secondary">Secondary</Button>);
       expect(container.firstChild).toMatchSnapshot();
     });
     ```
   - Example (incorrect):
     ```typescript
     it('matches snapshots for all variants', () => {
       const { container: defaultContainer } = render(<Button>Default</Button>);
       expect(defaultContainer.firstChild).toMatchSnapshot();
       const { container: secondaryContainer } = render(<Button variant="secondary">Secondary</Button>);
       expect(secondaryContainer.firstChild).toMatchSnapshot(); // ❌ Multiple expects
     });
     ```

7. **Never render the exact same element for multiple snapshot tests**
   - Each snapshot test must render a different element configuration
   - Avoid duplicate snapshots that would produce identical output
   - Vary props, children, or state to ensure each snapshot is unique
   - Example (incorrect):
     ```typescript
     it('matches snapshot with default props', () => {
       const { container } = render(<Button>Default Button</Button>);
       expect(container.firstChild).toMatchSnapshot();
     });

     it('matches snapshot for button element', () => {
       const { container } = render(<Button>Default Button</Button>);
       // ❌ Testing a subelement already covered by the snapshot above
       const buttonText = container.querySelector('#button-text');
       expect(buttonText).toMatchSnapshot();
     });
     ```

## Test Optimisation

8. **Minimise duplication of checks across unit and snapshot tests**
   - Unit tests should focus on functional behaviour and logic
   - Snapshot tests should focus on visual/structure differences
   - Avoid checking the same attributes/classes in both unit and snapshot tests
   - Exception: Overlapping checks are acceptable when they align with the single sanity test (from step 2), as this serves as the baseline validation
   - Use unit tests for dynamic behaviour, snapshot tests for static structure

## Mocking Rules

9. **Mock external dependencies, use real `@/libs` implementations by default**
   
   ### Default Rule: Use Real Implementations
   - Always prefer real implementations from `@/libs` for pure functions and business logic
   - This ensures tests validate actual behavior and catch integration issues
   - Example (preferred):
     ```typescript
     import { formatDate, validateEmail } from '@/libs/utils';
     // Use real implementations - no mocking needed
     it('formats date correctly', () => {
       expect(formatDate(new Date('2024-01-01'))).toBe('Jan 1, 2024');
     });
     ```

   ### When to Mock `@/libs`
   Mock `@/libs` functions only for:
   
   #### 1. External API Calls
   ```typescript
   // Mock network requests to prevent actual API calls
   vi.mock('@/libs/api', () => ({
     fetchUserData: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
   }));
   ```

   #### 2. File System Operations
   ```typescript
   // Mock file operations to avoid touching the file system
   vi.mock('@/libs/storage', () => ({
     readFile: vi.fn().mockResolvedValue('mock content'),
     writeFile: vi.fn().mockResolvedValue(true),
   }));
   ```

   #### 3. Database Connections
   ```typescript
   // Mock database to avoid actual DB connections in component tests
   vi.mock('@/libs/database', () => ({
     query: vi.fn().mockResolvedValue([{ id: 1 }]),
   }));
   ```

   #### 4. Time/Randomness (use dependency injection pattern)
   ```typescript
   // Mock time for deterministic tests
   beforeEach(() => {
     vi.useFakeTimers();
     vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
   });

   afterEach(() => {
     vi.useRealTimers();
   });

   // Or mock random functions
   vi.spyOn(Math, 'random').mockReturnValue(0.5);
   ```

   #### 5. Testing Error Conditions
   ```typescript
   // Mock to simulate error scenarios
   vi.mock('@/libs/api', () => ({
     fetchData: vi.fn().mockRejectedValue(new Error('Network error')),
   }));
   ```

   ### How to Mock Selectively
   When you need to mock only specific functions:
   ```typescript
   import * as Libs from '@/libs';
   
   vi.mock('@/libs', async () => {
     const actual = await vi.importActual('@/libs');
     return {
       ...actual,  // Keep all real implementations
       Logger: {
         ...actual.Logger,
         error: vi.fn(),  // Only mock what you need
       },
     };
   });
   ```

   ### Spying vs Mocking
   Use `vi.spyOn()` when you want to observe calls without changing behavior:
   ```typescript
   import { Logger } from '@/libs';
   
   it('logs errors correctly', () => {
     const spy = vi.spyOn(Logger, 'error');
     // Test code that should log errors
     expect(spy).toHaveBeenCalledWith('Expected error message');
     spy.mockRestore();
   });
   ```

   ### Anti-Pattern Examples
   
   ❌ **Bad: Mocking pure utility functions**
   ```typescript
   // Don't do this - test the real implementation
   vi.mock('@/libs/utils', () => ({
     formatCurrency: vi.fn().mockReturnValue('$10.00'),
   }));
   ```

   ✅ **Good: Use real pure functions**
   ```typescript
   // Test actual behavior
   import { formatCurrency } from '@/libs/utils';
   expect(formatCurrency(10)).toBe('$10.00');
   ```

   ❌ **Bad: Mocking everything by default**
   ```typescript
   // Too broad - mocks things that should be real
   vi.mock('@/libs', () => ({
     // ... mocking entire module
   }));
   ```

   ✅ **Good: Mock only what needs to be mocked**
   ```typescript
   // Import real utils, mock only API calls
   import { formatDate } from '@/libs/utils';
   vi.mock('@/libs/api', () => ({
     fetchData: vi.fn(),
   }));
   ```

   ### Icon Components: Always Use Real Implementations
   Icon components from `@/libs/icons` should **always** be used as real implementations in component tests. This ensures:
   - Snapshots capture actual SVG rendering
   - Visual regression tests detect icon changes
   - Tests validate real icon behaviour and attributes
   
   ✅ **Good: Use real icon components**
   ```typescript
   import * as Icons from '@/libs/icons';
   import { NotificationIcon } from './NotificationIcon';
   
   it('renders the correct icon', () => {
     const { container } = render(<NotificationIcon type={NotificationType.Follow} />);
     const svg = container.querySelector('svg');
     expect(svg).toBeInTheDocument();
     expect(svg).toHaveAttribute('width', '24');
     expect(svg).toHaveAttribute('height', '24');
   });
   
   it('matches snapshot with real icon', () => {
     const { container } = render(<NotificationIcon type={NotificationType.Follow} />);
     expect(container.firstChild).toMatchSnapshot();
   });
   ```
   
   ❌ **Bad: Mocking icon components or utility functions**
   ```typescript
   // Don't do this - use real icons and icon utilities
   vi.mock('@/libs/icons', () => ({
     Follow: () => <span>Mock Icon</span>,
     getIconFromUrl: vi.fn().mockReturnValue(() => <span>Mock</span>),
   }));
   ```
   
   **Note**: Icon utility functions like `getIconFromUrl` return actual icon components. Always use real implementations to ensure snapshots capture the correct icons.

   ### Radix UI Components: Always Use Real Implementations
   Radix UI components (e.g., `Dialog`, `Sheet`, `DropdownMenu`, `Popover`, `Tooltip`, `Accordion`) should **never** be mocked in component tests. Always use real Radix UI implementations to ensure:
   - Tests validate actual component behaviour and context requirements
   - Portal rendering works correctly
   - Accessibility attributes are properly applied
   - Context enforcement is validated (e.g., `DialogTrigger` must be within `Dialog`)
   
   ### Radix UI ID Normalization (Automatic)

   **Background**: Radix UI generates dynamic IDs (e.g., `radix-_r_0_`, `radix-_r_f_`) for `aria-controls`, `aria-labelledby`, and other attributes. These IDs change between test runs depending on test execution order and which subset of tests are run.

   **Solution**: A global snapshot serializer in `src/config/test.ts` automatically normalizes these IDs to `radix-normalized` in all snapshot tests. No manual intervention is required.

   This means you can write snapshot tests normally without worrying about Radix ID instability:

   ```typescript
   describe('MyComponent - Snapshots', () => {
     it('matches snapshot for default state', () => {
       const { container } = render(<MyRadixComponent />);
       expect(container.firstChild).toMatchSnapshot();
     });
   });
   ```

   ### Complete Example with Radix UI

   ```typescript
   import { describe, it, expect } from 'vitest';
   import { render, screen } from '@testing-library/react';
   import { Dialog, DialogTrigger, DialogContent } from '@/atoms';

   describe('DialogExample', () => {
     it('renders dialog with proper context', () => {
       render(
         <Dialog open={true}>
           <DialogTrigger>Open</DialogTrigger>
           <DialogContent>Content</DialogContent>
         </Dialog>
       );
       expect(screen.getByRole('dialog')).toBeInTheDocument();
     });
   });

   describe('DialogExample - Snapshots', () => {
     it('matches snapshot', () => {
       const { container } = render(
         <Dialog open={true}>
           <DialogContent>Content</DialogContent>
         </Dialog>
       );
       expect(container.firstChild).toMatchSnapshot();
     });
   });
   ```

   ❌ **Bad: Mocking Radix UI components**
   ```typescript
   // Don't do this - use real Radix UI components
   vi.mock('@radix-ui/react-dialog', () => ({
     Root: ({ children }) => <div>{children}</div>,
     Trigger: ({ children }) => <button>{children}</button>,
     Content: ({ children }) => <div>{children}</div>,
   }));
   ```

   **Important Notes**:
   - When testing Radix UI components, ensure child components are wrapped within their required parent context (e.g., `DialogTrigger` within `Dialog`)
   - For Portal-rendered content (like `DialogContent`), set `open={true}` to ensure content is rendered in the DOM
   - Components that use `@/atoms` wrappers (which themselves use Radix UI) should test with real atom implementations

## Testing Workflow

10. **Always run new or edited tests in sandbox to generate snapshot files and check they pass, and fix if failing**
   - Run tests after creating new test files: `npm test -- ComponentName.test.tsx`
   - When adding new snapshot tests, update snapshots: `npm test -- ComponentName.test.tsx -u`
   - Verify all tests pass before committing
   - Review generated snapshot files to ensure they capture expected output
   - Fix any failing tests before marking work as complete

## Deterministic Time Testing

11. **Always use deterministic time for components with time-based logic**
    - Components displaying relative time (e.g., "2 hours ago") must use fake timers
    - This prevents flaky tests and ensures consistent snapshots
    - Use `vi.useFakeTimers()` and `vi.setSystemTime()` from Vitest

    ### Pattern for Time-Based Components
    ```typescript
    import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
    import { render } from '@testing-library/react';
    import { PostTimestamp } from './PostTimestamp';

    describe('PostTimestamp', () => {
      beforeEach(() => {
        // Set a fixed system time before each test
        vi.useFakeTimers();
        vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
      });

      afterEach(() => {
        // Restore real timers after each test
        vi.useRealTimers();
      });

      it('displays relative time correctly', () => {
        // Create a post from 2 hours ago relative to our fixed time
        const twoHoursAgo = new Date('2024-01-01T10:00:00Z');
        render(<PostTimestamp createdAt={twoHoursAgo} />);
        expect(screen.getByText('2 hours ago')).toBeInTheDocument();
      });

      it('matches snapshot with deterministic time', () => {
        const twoHoursAgo = new Date('2024-01-01T10:00:00Z');
        const { container } = render(<PostTimestamp createdAt={twoHoursAgo} />);
        expect(container.firstChild).toMatchSnapshot();
      });
    });
    ```

    ### Anti-Pattern: Flaky Time Tests
    ❌ **Bad: Using current time in tests**
    ```typescript
    it('displays relative time', () => {
      const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
      render(<PostTimestamp createdAt={twoHoursAgo} />);
      // This will fail when seconds change during test execution
      expect(screen.getByText('2 hours ago')).toBeInTheDocument();
    });
    ```

    ✅ **Good: Fixed time for deterministic tests**
    ```typescript
    it('displays relative time', () => {
      vi.useFakeTimers();
      vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
      const twoHoursAgo = new Date('2024-01-01T10:00:00Z');
      render(<PostTimestamp createdAt={twoHoursAgo} />);
      expect(screen.getByText('2 hours ago')).toBeInTheDocument();
      vi.useRealTimers();
    });
    ```

## Example Complete Test File Structure

```typescript
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  // Sanity test
  it('renders with default props', () => {
    render(<Button>Default Button</Button>);
    const button = screen.getByRole('button');
    expect(button).toBeInTheDocument();
    expect(button).toHaveAttribute('data-slot', 'button');
  });

  // Functional tests
  it('handles click events', () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    fireEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('handles hover states correctly', () => {
    render(<Button variant="default">Hover Button</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('hover:!bg-brand/30');
  });
});

describe('Button - Snapshots', () => {
  it('matches snapshot for small size', () => {
    const { container } = render(<Button size="sm">Small</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });

  it('matches snapshot for large size', () => {
    const { container } = render(<Button size="lg">Large</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });

  it('matches snapshot for secondary variant', () => {
    const { container } = render(<Button variant="secondary">Secondary</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });

  it('matches snapshot for disabled state', () => {
    const { container } = render(<Button disabled>Disabled</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });
});
```

## Example with Time-Based Logic

```typescript
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SinglePostUserDetails } from './SinglePostUserDetails';

describe('SinglePostUserDetails', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('renders with user details and relative time', () => {
    const post = {
      author: 'test-user',
      created_at: new Date('2024-01-01T10:00:00Z').getTime(),
    };
    render(<SinglePostUserDetails post={post} />);
    expect(screen.getByText('test-user')).toBeInTheDocument();
    expect(screen.getByText('2 hours ago')).toBeInTheDocument();
  });
});

describe('SinglePostUserDetails - Snapshots', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('matches snapshot with recent post', () => {
    const post = {
      author: 'test-user',
      created_at: new Date('2024-01-01T11:30:00Z').getTime(),
    };
    const { container } = render(<SinglePostUserDetails post={post} />);
    expect(container.firstChild).toMatchSnapshot();
  });

  it('matches snapshot with old post', () => {
    const post = {
      author: 'test-user',
      created_at: new Date('2023-12-25T12:00:00Z').getTime(),
    };
    const { container } = render(<SinglePostUserDetails post={post} />);
    expect(container.firstChild).toMatchSnapshot();
  });
});
```