forked from cardosofelipe/fast-next-template
Add unit tests for core components and layouts
- **ThemeToggle:** Introduce comprehensive tests to validate button functionality, dropdown options, and active theme indicators. - **ThemeProvider:** Add tests for theme management, localStorage persistence, system preferences, and DOM updates. - **Header & Footer:** Verify header rendering, user menu functionality, and footer content consistency. - **AuthInitializer:** Ensure authentication state is correctly loaded from storage on mount.
This commit is contained in:
349
frontend/tests/components/theme/ThemeProvider.test.tsx
Normal file
349
frontend/tests/components/theme/ThemeProvider.test.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Tests for ThemeProvider
|
||||
* Verifies theme state management, localStorage persistence, and system preference detection
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { ThemeProvider, useTheme } from '@/components/theme/ThemeProvider';
|
||||
|
||||
// Test component to access theme context
|
||||
function TestComponent() {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="current-theme">{theme}</div>
|
||||
<div data-testid="resolved-theme">{resolvedTheme}</div>
|
||||
<button onClick={() => setTheme('light')}>Set Light</button>
|
||||
<button onClick={() => setTheme('dark')}>Set Dark</button>
|
||||
<button onClick={() => setTheme('system')}>Set System</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
let mockLocalStorage: { [key: string]: string };
|
||||
let mockMatchMedia: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock localStorage
|
||||
mockLocalStorage = {};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: jest.fn((key: string) => mockLocalStorage[key] || null),
|
||||
setItem: jest.fn((key: string, value: string) => {
|
||||
mockLocalStorage[key] = value;
|
||||
}),
|
||||
removeItem: jest.fn((key: string) => {
|
||||
delete mockLocalStorage[key];
|
||||
}),
|
||||
clear: jest.fn(() => {
|
||||
mockLocalStorage = {};
|
||||
}),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock matchMedia
|
||||
mockMatchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)' ? false : false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: mockMatchMedia,
|
||||
});
|
||||
|
||||
// Mock document.documentElement
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('defaults to system theme when no stored preference', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
|
||||
});
|
||||
|
||||
it('loads stored theme preference from localStorage', async () => {
|
||||
mockLocalStorage['theme'] = 'dark';
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('dark');
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores invalid theme values from localStorage', () => {
|
||||
mockLocalStorage['theme'] = 'invalid';
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Switching', () => {
|
||||
it('updates theme when setTheme is called', async () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const lightButton = screen.getByRole('button', { name: 'Set Light' });
|
||||
|
||||
await act(async () => {
|
||||
lightButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('light');
|
||||
});
|
||||
});
|
||||
|
||||
it('persists theme to localStorage when changed', async () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const darkButton = screen.getByRole('button', { name: 'Set Dark' });
|
||||
|
||||
await act(async () => {
|
||||
darkButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resolved Theme', () => {
|
||||
it('resolves light theme correctly', async () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const lightButton = screen.getByRole('button', { name: 'Set Light' });
|
||||
|
||||
await act(async () => {
|
||||
lightButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('light');
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves dark theme correctly', async () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const darkButton = screen.getByRole('button', { name: 'Set Dark' });
|
||||
|
||||
await act(async () => {
|
||||
darkButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(document.documentElement.classList.contains('light')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves system theme to light when system prefers light', async () => {
|
||||
mockMatchMedia.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)' ? false : false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const systemButton = screen.getByRole('button', { name: 'Set System' });
|
||||
|
||||
await act(async () => {
|
||||
systemButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
|
||||
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('light');
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves system theme to dark when system prefers dark', async () => {
|
||||
mockMatchMedia.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)' ? true : false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const systemButton = screen.getByRole('button', { name: 'Set System' });
|
||||
|
||||
await act(async () => {
|
||||
systemButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('system');
|
||||
expect(screen.getByTestId('resolved-theme')).toHaveTextContent('dark');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOM Updates', () => {
|
||||
it('applies theme class to document element', async () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
const lightButton = screen.getByRole('button', { name: 'Set Light' });
|
||||
|
||||
await act(async () => {
|
||||
lightButton.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes previous theme class when switching', async () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Set to light
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: 'Set Light' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
});
|
||||
|
||||
// Switch to dark
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: 'Set Dark' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(document.documentElement.classList.contains('light')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('throws error when useTheme is used outside provider', () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponent />);
|
||||
}).toThrow('useTheme must be used within ThemeProvider');
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('System Preference Changes', () => {
|
||||
it('listens to system preference changes', () => {
|
||||
const mockAddEventListener = jest.fn();
|
||||
|
||||
mockMatchMedia.mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: mockAddEventListener,
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(mockAddEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
});
|
||||
|
||||
it('cleans up event listener on unmount', () => {
|
||||
const mockRemoveEventListener = jest.fn();
|
||||
|
||||
mockMatchMedia.mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: mockRemoveEventListener,
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
const { unmount } = render(
|
||||
<ThemeProvider>
|
||||
<TestComponent />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockRemoveEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
186
frontend/tests/components/theme/ThemeToggle.test.tsx
Normal file
186
frontend/tests/components/theme/ThemeToggle.test.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Tests for ThemeToggle
|
||||
* Verifies theme toggle button functionality and dropdown menu
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeToggle } from '@/components/theme/ThemeToggle';
|
||||
import { ThemeProvider, useTheme } from '@/components/theme/ThemeProvider';
|
||||
|
||||
// Mock theme provider for controlled testing
|
||||
jest.mock('@/components/theme/ThemeProvider', () => {
|
||||
const actual = jest.requireActual('@/components/theme/ThemeProvider');
|
||||
return {
|
||||
...actual,
|
||||
useTheme: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('ThemeToggle', () => {
|
||||
const mockSetTheme = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock return value
|
||||
(useTheme as jest.Mock).mockReturnValue({
|
||||
theme: 'system',
|
||||
setTheme: mockSetTheme,
|
||||
resolvedTheme: 'light',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders theme toggle button', () => {
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays sun icon when resolved theme is light', () => {
|
||||
(useTheme as jest.Mock).mockReturnValue({
|
||||
theme: 'light',
|
||||
setTheme: mockSetTheme,
|
||||
resolvedTheme: 'light',
|
||||
});
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
// Sun icon should be visible
|
||||
expect(button.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays moon icon when resolved theme is dark', () => {
|
||||
(useTheme as jest.Mock).mockReturnValue({
|
||||
theme: 'dark',
|
||||
setTheme: mockSetTheme,
|
||||
resolvedTheme: 'dark',
|
||||
});
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
// Moon icon should be visible
|
||||
expect(button.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown Menu', () => {
|
||||
it('opens dropdown menu when button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('menuitem', { name: /light/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /dark/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /system/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setTheme with "light" when light option is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
await user.click(button);
|
||||
|
||||
const lightOption = await screen.findByRole('menuitem', { name: /light/i });
|
||||
await user.click(lightOption);
|
||||
|
||||
expect(mockSetTheme).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('calls setTheme with "dark" when dark option is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
await user.click(button);
|
||||
|
||||
const darkOption = await screen.findByRole('menuitem', { name: /dark/i });
|
||||
await user.click(darkOption);
|
||||
|
||||
expect(mockSetTheme).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('calls setTheme with "system" when system option is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
await user.click(button);
|
||||
|
||||
const systemOption = await screen.findByRole('menuitem', { name: /system/i });
|
||||
await user.click(systemOption);
|
||||
|
||||
expect(mockSetTheme).toHaveBeenCalledWith('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active Theme Indicator', () => {
|
||||
it('shows checkmark for light theme when active', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
(useTheme as jest.Mock).mockReturnValue({
|
||||
theme: 'light',
|
||||
setTheme: mockSetTheme,
|
||||
resolvedTheme: 'light',
|
||||
});
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
await user.click(button);
|
||||
|
||||
const lightOption = await screen.findByRole('menuitem', { name: /light/i });
|
||||
expect(lightOption).toHaveTextContent('✓');
|
||||
});
|
||||
|
||||
it('shows checkmark for dark theme when active', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
(useTheme as jest.Mock).mockReturnValue({
|
||||
theme: 'dark',
|
||||
setTheme: mockSetTheme,
|
||||
resolvedTheme: 'dark',
|
||||
});
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
await user.click(button);
|
||||
|
||||
const darkOption = await screen.findByRole('menuitem', { name: /dark/i });
|
||||
expect(darkOption).toHaveTextContent('✓');
|
||||
});
|
||||
|
||||
it('shows checkmark for system theme when active', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
(useTheme as jest.Mock).mockReturnValue({
|
||||
theme: 'system',
|
||||
setTheme: mockSetTheme,
|
||||
resolvedTheme: 'light',
|
||||
});
|
||||
|
||||
render(<ThemeToggle />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /toggle theme/i });
|
||||
await user.click(button);
|
||||
|
||||
const systemOption = await screen.findByRole('menuitem', { name: /system/i });
|
||||
expect(systemOption).toHaveTextContent('✓');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user