component-testing · diff
git:20260203.0ca173c to git:20260309.80c3cb2
3 added, 554 removed. Audit A to B.
---
description: Writing unit tests and snapshot tests for UI component rendering
+ globs: src/components/**/*.test.*
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();
- });
+ # Component Testing Rules
- 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();
- });
- });
- ```
+ @docs/component-testing.md