← back to Angels Flowers
components/__tests__/ThemeProvider.test.tsx
474 lines
import { render, screen, act, waitFor } from '@/test-utils';
import { renderHook } from '@testing-library/react';
import ThemeProvider, { useTheme } from '../ThemeProvider';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: jest.fn((key: string) => store[key] || null),
setItem: jest.fn((key: string, value: string) => {
store[key] = value.toString();
}),
removeItem: jest.fn((key: string) => {
delete store[key];
}),
clear: jest.fn(() => {
store = {};
}),
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
// Mock matchMedia
const createMatchMediaMock = (matches: boolean) => {
return jest.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
};
describe('ThemeProvider', () => {
beforeEach(() => {
// Clear and reset localStorage mock completely
localStorageMock.clear();
localStorageMock.getItem.mockClear();
localStorageMock.setItem.mockClear();
localStorageMock.removeItem.mockClear();
localStorageMock.getItem.mockReturnValue(null); // Reset to default return value
jest.clearAllMocks();
// Default matchMedia mock (light mode)
window.matchMedia = createMatchMediaMock(false);
// Mock document.documentElement
document.documentElement.setAttribute = jest.fn();
document.documentElement.classList.add = jest.fn();
document.documentElement.classList.remove = jest.fn();
});
describe('ThemeProvider renders children correctly', () => {
it('should render children without crashing', () => {
render(
<ThemeProvider>
<div>Test Child</div>
</ThemeProvider>
);
expect(screen.getByText('Test Child')).toBeInTheDocument();
});
it('should render multiple children', () => {
render(
<ThemeProvider>
<div>First Child</div>
<div>Second Child</div>
</ThemeProvider>
);
expect(screen.getByText('First Child')).toBeInTheDocument();
expect(screen.getByText('Second Child')).toBeInTheDocument();
});
});
describe('useTheme hook returns correct initial theme', () => {
it('should return light theme by default', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('light');
expect(typeof result.current.toggleTheme).toBe('function');
});
it('should throw error when useTheme is used outside ThemeProvider', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
expect(() => {
renderHook(() => useTheme());
}).toThrow('useTheme must be used within a ThemeProvider');
consoleSpy.mockRestore();
});
it('should return saved theme from localStorage', async () => {
localStorageMock.getItem.mockReturnValue('dark');
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
});
it('should ignore invalid localStorage values', async () => {
localStorageMock.getItem.mockReturnValueOnce('invalid-theme');
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('light');
});
});
});
describe('toggleTheme function switches between light and dark', () => {
it('should toggle from light to dark', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('light');
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
});
it('should toggle from dark to light', async () => {
localStorageMock.getItem.mockReturnValue('dark');
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(result.current.theme).toBe('light');
});
});
it('should toggle multiple times correctly', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('light');
// Toggle to dark
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
// Toggle back to light
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(result.current.theme).toBe('light');
});
// Toggle to dark again
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
});
});
describe('localStorage persistence on theme change', () => {
it('should persist theme to localStorage on toggle', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'dark');
});
});
it('should persist light theme to localStorage', async () => {
localStorageMock.getItem.mockReturnValue('dark');
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'light');
});
});
it('should handle localStorage errors gracefully', async () => {
localStorageMock.setItem.mockImplementationOnce(() => {
throw new Error('localStorage is not available');
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
// Should not throw error
expect(() => {
act(() => {
result.current.toggleTheme();
});
}).not.toThrow();
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
});
});
describe('system preference detection', () => {
it('should detect dark system preference when no saved theme', async () => {
window.matchMedia = createMatchMediaMock(true);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
});
it('should detect light system preference when no saved theme', async () => {
window.matchMedia = createMatchMediaMock(false);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('light');
});
});
it('should prioritize saved theme over system preference', async () => {
localStorageMock.getItem.mockReturnValue('light');
window.matchMedia = createMatchMediaMock(true); // System prefers dark
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('light');
});
});
it('should listen for system theme changes when no saved preference', async () => {
let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn((event: string, handler: (e: MediaQueryListEvent) => void) => {
if (event === 'change') {
changeHandler = handler;
}
}),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
// Wait for mount and event listener setup
await waitFor(() => {
expect(changeHandler).not.toBeNull();
});
expect(result.current.theme).toBe('light');
// Simulate system theme change to dark
if (changeHandler) {
act(() => {
changeHandler({ matches: true } as MediaQueryListEvent);
});
}
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
});
it('should not update theme on system change if user has saved preference', async () => {
localStorageMock.getItem.mockReturnValue('light');
let changeHandler: ((e: MediaQueryListEvent) => void) | null = null;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn((event: string, handler: (e: MediaQueryListEvent) => void) => {
if (event === 'change') {
changeHandler = handler;
}
}),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('light');
});
// Simulate system theme change to dark
if (changeHandler) {
act(() => {
changeHandler({ matches: true } as MediaQueryListEvent);
});
}
// Should remain light because user has saved preference
await waitFor(() => {
expect(result.current.theme).toBe('light');
});
});
});
describe('DOM manipulation', () => {
it('should apply data-theme attribute to document root', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(document.documentElement.setAttribute).toHaveBeenCalledWith(
'data-theme',
'light'
);
});
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(document.documentElement.setAttribute).toHaveBeenCalledWith(
'data-theme',
'dark'
);
});
});
it('should add dark class in dark mode', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
});
});
it('should remove dark class in light mode', async () => {
localStorageMock.getItem.mockReturnValue('dark');
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ThemeProvider>{children}</ThemeProvider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
await waitFor(() => {
expect(result.current.theme).toBe('dark');
});
act(() => {
result.current.toggleTheme();
});
await waitFor(() => {
expect(document.documentElement.classList.remove).toHaveBeenCalledWith('dark');
});
});
});
});