test: add comprehensive hook tests
- Add extensive tests for useTheme hook with theme switching and system preference detection - Add tests for useUserData hook with user data management - Fix documentElement readonly property issues and fetch mock type issues in tests - Test theme persistence, system theme detection, and edge cases - Improve hook test coverage and reliability This adds comprehensive testing for React hooks used throughout the application.
This commit is contained in:
335
hooks/__tests__/useTheme.test.ts
Normal file
335
hooks/__tests__/useTheme.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useTheme } from '../useTheme';
|
||||
import { useLocalStorage } from '../useLocalStorage';
|
||||
|
||||
// Mock the useLocalStorage hook
|
||||
jest.mock('../useLocalStorage');
|
||||
const mockUseLocalStorage = useLocalStorage as jest.MockedFunction<
|
||||
typeof useLocalStorage
|
||||
>;
|
||||
|
||||
// Mock window.matchMedia
|
||||
const mockMatchMedia = jest.fn();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: mockMatchMedia,
|
||||
});
|
||||
|
||||
// Mock document.documentElement
|
||||
const mockDocumentElement = {
|
||||
classList: {
|
||||
remove: jest.fn(),
|
||||
add: jest.fn(),
|
||||
},
|
||||
};
|
||||
Object.defineProperty(document, 'documentElement', {
|
||||
writable: true,
|
||||
value: mockDocumentElement,
|
||||
});
|
||||
|
||||
describe('useTheme', () => {
|
||||
let mockSetTheme: jest.Mock;
|
||||
let mockMediaQueryList: {
|
||||
matches: boolean;
|
||||
addEventListener: jest.Mock;
|
||||
removeEventListener: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockSetTheme = jest.fn();
|
||||
mockMediaQueryList = {
|
||||
matches: false,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
};
|
||||
|
||||
mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]);
|
||||
mockMatchMedia.mockReturnValue(mockMediaQueryList);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
test('should initialize with system theme by default', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(mockUseLocalStorage).toHaveBeenCalledWith('theme', 'system');
|
||||
expect(result.current.theme).toBe('system');
|
||||
expect(typeof result.current.setTheme).toBe('function');
|
||||
});
|
||||
|
||||
test('should apply theme on mount', () => {
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith(
|
||||
'light',
|
||||
'dark'
|
||||
);
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme application', () => {
|
||||
test('should apply light theme when theme is light', () => {
|
||||
mockUseLocalStorage.mockReturnValue(['light', mockSetTheme]);
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith(
|
||||
'light',
|
||||
'dark'
|
||||
);
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
test('should apply dark theme when theme is dark', () => {
|
||||
mockUseLocalStorage.mockReturnValue(['dark', mockSetTheme]);
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith(
|
||||
'light',
|
||||
'dark'
|
||||
);
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
test('should apply system theme when theme is system and system prefers light', () => {
|
||||
mockMediaQueryList.matches = false;
|
||||
mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]);
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
test('should apply system theme when theme is system and system prefers dark', () => {
|
||||
mockMediaQueryList.matches = true;
|
||||
mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]);
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('system theme detection', () => {
|
||||
test('should detect system theme as light when prefers-color-scheme is light', () => {
|
||||
mockMediaQueryList.matches = false;
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(mockMatchMedia).toHaveBeenCalledWith(
|
||||
'(prefers-color-scheme: dark)'
|
||||
);
|
||||
expect(result.current.theme).toBe('system');
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
test('should detect system theme as dark when prefers-color-scheme is dark', () => {
|
||||
mockMediaQueryList.matches = true;
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
test('should fallback to light theme when window.matchMedia is not available', () => {
|
||||
// Mock window as undefined
|
||||
const originalWindow = global.window;
|
||||
delete (global as any).window;
|
||||
|
||||
mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]);
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.theme).toBe('system');
|
||||
|
||||
// Restore window
|
||||
global.window = originalWindow;
|
||||
});
|
||||
|
||||
test('should fallback to light theme when matchMedia is not available', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]);
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
// Should still work and default to light theme for system
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('media query listener', () => {
|
||||
test('should add event listener for system theme changes', () => {
|
||||
mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]);
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
expect(mockMediaQueryList.addEventListener).toHaveBeenCalledWith(
|
||||
'change',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
test('should remove event listener on unmount', () => {
|
||||
const { unmount } = renderHook(() => useTheme());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockMediaQueryList.removeEventListener).toHaveBeenCalledWith(
|
||||
'change',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
test('should update theme when system preference changes and using system theme', () => {
|
||||
mockUseLocalStorage.mockReturnValue(['system', mockSetTheme]);
|
||||
mockMediaQueryList.matches = false;
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
// Get the change handler
|
||||
const changeHandler =
|
||||
mockMediaQueryList.addEventListener.mock.calls[0][1];
|
||||
|
||||
// Clear previous calls
|
||||
mockDocumentElement.classList.remove.mockClear();
|
||||
mockDocumentElement.classList.add.mockClear();
|
||||
|
||||
// Simulate system theme change
|
||||
mockMediaQueryList.matches = true;
|
||||
act(() => {
|
||||
changeHandler();
|
||||
});
|
||||
|
||||
expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith(
|
||||
'light',
|
||||
'dark'
|
||||
);
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
test('should not update theme when system preference changes but not using system theme', () => {
|
||||
mockUseLocalStorage.mockReturnValue(['light', mockSetTheme]);
|
||||
|
||||
renderHook(() => useTheme());
|
||||
|
||||
// Get the change handler
|
||||
const changeHandler =
|
||||
mockMediaQueryList.addEventListener.mock.calls[0][1];
|
||||
|
||||
// Clear previous calls
|
||||
mockDocumentElement.classList.remove.mockClear();
|
||||
mockDocumentElement.classList.add.mockClear();
|
||||
|
||||
// Simulate system theme change
|
||||
act(() => {
|
||||
changeHandler();
|
||||
});
|
||||
|
||||
// Should not trigger theme change since we're using explicit light theme
|
||||
expect(mockDocumentElement.classList.remove).not.toHaveBeenCalled();
|
||||
expect(mockDocumentElement.classList.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme updates', () => {
|
||||
test('should update theme when setTheme is called', () => {
|
||||
const { result, rerender } = renderHook(() => useTheme());
|
||||
|
||||
// Change to dark theme
|
||||
mockUseLocalStorage.mockReturnValue(['dark', mockSetTheme]);
|
||||
rerender();
|
||||
|
||||
expect(result.current.theme).toBe('dark');
|
||||
});
|
||||
|
||||
test('should apply new theme when theme changes', () => {
|
||||
const { rerender } = renderHook(() => useTheme());
|
||||
|
||||
// Clear initial calls
|
||||
mockDocumentElement.classList.remove.mockClear();
|
||||
mockDocumentElement.classList.add.mockClear();
|
||||
|
||||
// Change to dark theme
|
||||
mockUseLocalStorage.mockReturnValue(['dark', mockSetTheme]);
|
||||
rerender();
|
||||
|
||||
expect(mockDocumentElement.classList.remove).toHaveBeenCalledWith(
|
||||
'light',
|
||||
'dark'
|
||||
);
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle invalid theme values gracefully', () => {
|
||||
mockUseLocalStorage.mockReturnValue(['invalid' as any, mockSetTheme]);
|
||||
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current.theme).toBe('invalid');
|
||||
// Should still try to apply the theme
|
||||
expect(mockDocumentElement.classList.add).toHaveBeenCalledWith('invalid');
|
||||
});
|
||||
|
||||
test('should handle missing document.documentElement', () => {
|
||||
const originalDocumentElement = document.documentElement;
|
||||
delete (document as any).documentElement;
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTheme());
|
||||
}).not.toThrow();
|
||||
|
||||
// Restore
|
||||
Object.defineProperty(document, 'documentElement', {
|
||||
value: originalDocumentElement,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle classList methods not being available', () => {
|
||||
const originalClassList = mockDocumentElement.classList;
|
||||
mockDocumentElement.classList = undefined as any;
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTheme());
|
||||
}).not.toThrow();
|
||||
|
||||
// Restore
|
||||
mockDocumentElement.classList = originalClassList;
|
||||
});
|
||||
});
|
||||
|
||||
describe('return value', () => {
|
||||
test('should return current theme and setTheme function', () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
|
||||
expect(result.current).toEqual({
|
||||
theme: 'system',
|
||||
setTheme: mockSetTheme,
|
||||
});
|
||||
});
|
||||
|
||||
test('should maintain stable setTheme reference', () => {
|
||||
const { result, rerender } = renderHook(() => useTheme());
|
||||
const firstSetTheme = result.current.setTheme;
|
||||
|
||||
rerender();
|
||||
const secondSetTheme = result.current.setTheme;
|
||||
|
||||
expect(firstSetTheme).toBe(secondSetTheme);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user