git:20260123.e778e32 to git:20260203.0ca173c

17 added, 62 removed. Audit A to A.

---
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`)
- ### CRITICAL: Always Use `normaliseRadixIds` for Snapshot Tests with Radix UI
-
- **Why**: 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
- - Which subset of tests are run
- - Other Radix components rendered before
-
- **This causes flaky snapshot tests that fail with differences like:**
- ```diff
- - aria-controls="radix-_r_0_"
- + aria-controls="radix-_r_f_"
- ```
-
- **Solution**: ALWAYS use `normaliseRadixIds` from `@/libs/utils/utils` before calling `toMatchSnapshot()`:
-
- ✅ **Good: Normalise Radix IDs before snapshot**
- ```typescript
- import { normaliseRadixIds } from '@/libs/utils/utils';
-
- // Note: Radix UI generates incremental IDs (radix-«r0», radix-«r1», etc.) for aria-controls attributes.
- // These IDs are deterministic within an identical test suite run but may change when a subset of tests are run or are run in a different order.
- // Use normaliseRadixIds to ensure the snapshots are consistent.
- describe('MyComponent - Snapshots', () => {
- it('matches snapshot for default state', () => {
- const { container } = render(<MyRadixComponent />);
- const normalizedContainer = normaliseRadixIds(container);
- expect(normalizedContainer).toMatchSnapshot();
- });
- });
- ```
-
- ❌ **Bad: Snapshot without normalisation (WILL cause flaky tests)**
+ ### 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 />);
- // ❌ This WILL fail randomly due to dynamic Radix IDs
- expect(container).toMatchSnapshot();
+ expect(container.firstChild).toMatchSnapshot();
});
});
```
-
- ### When to Use `normaliseRadixIds`
-
- Use `normaliseRadixIds` for snapshot tests involving ANY of these components:
- - `Dialog`, `DialogTrigger`, `DialogContent`
- - `Sheet`, `SheetTrigger`, `SheetContent`
- - `Popover`, `PopoverTrigger`, `PopoverContent`
- - `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`
- - `Tooltip`, `TooltipTrigger`, `TooltipContent`
- - `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`
- - `Select`, `SelectTrigger`, `SelectContent`
- - Any component from `@/atoms` that wraps Radix UI primitives
- - Any custom component that uses the above internally
-
+
### Complete Example with Radix UI
-
+
```typescript
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
- import { normaliseRadixIds } from '@/libs/utils/utils';
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();
});
});
-
- // Note: Radix UI generates incremental IDs (radix-«r0», radix-«r1», etc.) for aria-controls attributes.
- // Use normaliseRadixIds to ensure the snapshots are consistent.
+
describe('DialogExample - Snapshots', () => {
- it('matches snapshot with normalised Radix IDs', () => {
+ it('matches snapshot', () => {
const { container } = render(
<Dialog open={true}>
<DialogContent>Content</DialogContent>
</Dialog>
);
- const dialog = document.querySelector('[role="dialog"]');
- const normalizedContainer = normaliseRadixIds(dialog?.parentElement as HTMLElement);
- expect(normalizedContainer).toMatchSnapshot();
+ 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
- - **ALWAYS** use `normaliseRadixIds` utility from `@/libs/utils/utils` for snapshot tests - this is NOT optional
- Components that use `@/atoms` wrappers (which themselves use Radix UI) should test with real atom implementations
- - Add the comment block explaining why `normaliseRadixIds` is used for documentation purposes
## 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();
});
});
```