Skip to content

Commit 0cc54a5

Browse files
committed
Fix light/dark mode theme switching and add comprehensive tests
- Fix ThemeProvider to properly sync theme state with localStorage and DOM - Add inline script in layout.tsx to prevent theme flash on page load - Add unit tests for ThemeProvider (12 tests) - Add unit tests for ThemeToggle (12 tests) - Add integration tests for theme switching (6 tests) - Add e2e tests for theme functionality (10 tests) - All tests passing
1 parent 02edd71 commit 0cc54a5

6 files changed

Lines changed: 1121 additions & 13 deletions

File tree

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
import { render, screen, act, waitFor } from '@testing-library/react';
2+
import { ThemeProvider, useTheme } from '@/components/ThemeProvider';
3+
import { useState, useEffect } from 'react';
4+
5+
// Mock localStorage
6+
const localStorageMock = (() => {
7+
let store: Record<string, string> = {};
8+
9+
return {
10+
getItem: (key: string) => store[key] || null,
11+
setItem: (key: string, value: string) => {
12+
store[key] = value.toString();
13+
},
14+
removeItem: (key: string) => {
15+
delete store[key];
16+
},
17+
clear: () => {
18+
store = {};
19+
},
20+
};
21+
})();
22+
23+
Object.defineProperty(window, 'localStorage', {
24+
value: localStorageMock,
25+
});
26+
27+
// Mock matchMedia
28+
Object.defineProperty(window, 'matchMedia', {
29+
writable: true,
30+
value: jest.fn().mockImplementation((query) => ({
31+
matches: false,
32+
media: query,
33+
onchange: null,
34+
addListener: jest.fn(),
35+
removeListener: jest.fn(),
36+
addEventListener: jest.fn(),
37+
removeEventListener: jest.fn(),
38+
dispatchEvent: jest.fn(),
39+
})),
40+
});
41+
42+
// Test component that uses the theme
43+
function TestComponent() {
44+
const { theme, toggleTheme, setTheme } = useTheme();
45+
const [renderCount, setRenderCount] = useState(0);
46+
47+
useEffect(() => {
48+
setRenderCount((prev) => prev + 1);
49+
}, [theme]);
50+
51+
return (
52+
<div>
53+
<div data-testid="theme">{theme}</div>
54+
<div data-testid="render-count">{renderCount}</div>
55+
<button onClick={toggleTheme} data-testid="toggle">
56+
Toggle
57+
</button>
58+
<button onClick={() => setTheme('light')} data-testid="set-light">
59+
Set Light
60+
</button>
61+
<button onClick={() => setTheme('dark')} data-testid="set-dark">
62+
Set Dark
63+
</button>
64+
</div>
65+
);
66+
}
67+
68+
describe('ThemeProvider', () => {
69+
beforeEach(() => {
70+
localStorageMock.clear();
71+
document.documentElement.classList.remove('dark');
72+
// Reset matchMedia mock
73+
(window.matchMedia as jest.Mock).mockReturnValue({
74+
matches: false,
75+
media: '(prefers-color-scheme: dark)',
76+
onchange: null,
77+
addListener: jest.fn(),
78+
removeListener: jest.fn(),
79+
addEventListener: jest.fn(),
80+
removeEventListener: jest.fn(),
81+
dispatchEvent: jest.fn(),
82+
});
83+
});
84+
85+
describe('Initial Theme', () => {
86+
it('should default to dark theme when no localStorage value exists', async () => {
87+
// Set dark class on documentElement to simulate inline script behavior
88+
document.documentElement.classList.add('dark');
89+
90+
render(
91+
<ThemeProvider>
92+
<TestComponent />
93+
</ThemeProvider>
94+
);
95+
96+
await waitFor(() => {
97+
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
98+
});
99+
});
100+
101+
it('should use localStorage theme if available', async () => {
102+
localStorageMock.setItem('theme', 'light');
103+
document.documentElement.classList.remove('dark');
104+
105+
render(
106+
<ThemeProvider>
107+
<TestComponent />
108+
</ThemeProvider>
109+
);
110+
111+
await waitFor(() => {
112+
expect(screen.getByTestId('theme')).toHaveTextContent('light');
113+
});
114+
});
115+
116+
it('should use system preference when localStorage is not set', async () => {
117+
// Set dark class on documentElement to simulate inline script behavior
118+
// The inline script would set this based on system preference
119+
document.documentElement.classList.add('dark');
120+
121+
(window.matchMedia as jest.Mock).mockReturnValue({
122+
matches: true, // prefers dark
123+
media: '(prefers-color-scheme: dark)',
124+
onchange: null,
125+
addListener: jest.fn(),
126+
removeListener: jest.fn(),
127+
addEventListener: jest.fn(),
128+
removeEventListener: jest.fn(),
129+
dispatchEvent: jest.fn(),
130+
});
131+
132+
render(
133+
<ThemeProvider>
134+
<TestComponent />
135+
</ThemeProvider>
136+
);
137+
138+
await waitFor(() => {
139+
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
140+
});
141+
});
142+
});
143+
144+
describe('Theme Toggle', () => {
145+
it('should toggle from dark to light', async () => {
146+
localStorageMock.setItem('theme', 'dark');
147+
document.documentElement.classList.add('dark');
148+
149+
render(
150+
<ThemeProvider>
151+
<TestComponent />
152+
</ThemeProvider>
153+
);
154+
155+
await waitFor(() => {
156+
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
157+
});
158+
159+
const toggleButton = screen.getByTestId('toggle');
160+
await act(async () => {
161+
toggleButton.click();
162+
});
163+
164+
await waitFor(() => {
165+
expect(screen.getByTestId('theme')).toHaveTextContent('light');
166+
expect(localStorageMock.getItem('theme')).toBe('light');
167+
expect(document.documentElement.classList.contains('dark')).toBe(false);
168+
});
169+
});
170+
171+
it('should toggle from light to dark', async () => {
172+
localStorageMock.setItem('theme', 'light');
173+
document.documentElement.classList.remove('dark');
174+
175+
render(
176+
<ThemeProvider>
177+
<TestComponent />
178+
</ThemeProvider>
179+
);
180+
181+
await waitFor(() => {
182+
expect(screen.getByTestId('theme')).toHaveTextContent('light');
183+
});
184+
185+
const toggleButton = screen.getByTestId('toggle');
186+
await act(async () => {
187+
toggleButton.click();
188+
});
189+
190+
await waitFor(() => {
191+
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
192+
expect(localStorageMock.getItem('theme')).toBe('dark');
193+
expect(document.documentElement.classList.contains('dark')).toBe(true);
194+
});
195+
});
196+
});
197+
198+
describe('Set Theme', () => {
199+
it('should set theme to light', async () => {
200+
localStorageMock.setItem('theme', 'dark');
201+
document.documentElement.classList.add('dark');
202+
203+
render(
204+
<ThemeProvider>
205+
<TestComponent />
206+
</ThemeProvider>
207+
);
208+
209+
await waitFor(() => {
210+
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
211+
});
212+
213+
const setLightButton = screen.getByTestId('set-light');
214+
await act(async () => {
215+
setLightButton.click();
216+
});
217+
218+
await waitFor(() => {
219+
expect(screen.getByTestId('theme')).toHaveTextContent('light');
220+
expect(localStorageMock.getItem('theme')).toBe('light');
221+
expect(document.documentElement.classList.contains('dark')).toBe(false);
222+
});
223+
});
224+
225+
it('should set theme to dark', async () => {
226+
localStorageMock.setItem('theme', 'light');
227+
document.documentElement.classList.remove('dark');
228+
229+
render(
230+
<ThemeProvider>
231+
<TestComponent />
232+
</ThemeProvider>
233+
);
234+
235+
await waitFor(() => {
236+
expect(screen.getByTestId('theme')).toHaveTextContent('light');
237+
});
238+
239+
const setDarkButton = screen.getByTestId('set-dark');
240+
await act(async () => {
241+
setDarkButton.click();
242+
});
243+
244+
await waitFor(() => {
245+
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
246+
expect(localStorageMock.getItem('theme')).toBe('dark');
247+
expect(document.documentElement.classList.contains('dark')).toBe(true);
248+
});
249+
});
250+
});
251+
252+
describe('DOM Updates', () => {
253+
it('should add dark class to documentElement when theme is dark', async () => {
254+
localStorageMock.setItem('theme', 'dark');
255+
document.documentElement.classList.add('dark');
256+
257+
render(
258+
<ThemeProvider>
259+
<TestComponent />
260+
</ThemeProvider>
261+
);
262+
263+
await waitFor(() => {
264+
expect(document.documentElement.classList.contains('dark')).toBe(true);
265+
});
266+
});
267+
268+
it('should remove dark class from documentElement when theme is light', async () => {
269+
localStorageMock.setItem('theme', 'light');
270+
document.documentElement.classList.remove('dark');
271+
272+
render(
273+
<ThemeProvider>
274+
<TestComponent />
275+
</ThemeProvider>
276+
);
277+
278+
await waitFor(() => {
279+
expect(document.documentElement.classList.contains('dark')).toBe(false);
280+
});
281+
});
282+
});
283+
284+
describe('LocalStorage Persistence', () => {
285+
it('should persist theme to localStorage', async () => {
286+
render(
287+
<ThemeProvider>
288+
<TestComponent />
289+
</ThemeProvider>
290+
);
291+
292+
const setLightButton = screen.getByTestId('set-light');
293+
await act(async () => {
294+
setLightButton.click();
295+
});
296+
297+
await waitFor(() => {
298+
expect(localStorageMock.getItem('theme')).toBe('light');
299+
});
300+
});
301+
302+
it('should update localStorage when theme changes', async () => {
303+
localStorageMock.setItem('theme', 'dark');
304+
document.documentElement.classList.add('dark');
305+
306+
render(
307+
<ThemeProvider>
308+
<TestComponent />
309+
</ThemeProvider>
310+
);
311+
312+
await waitFor(() => {
313+
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
314+
});
315+
316+
const toggleButton = screen.getByTestId('toggle');
317+
await act(async () => {
318+
toggleButton.click();
319+
});
320+
321+
await waitFor(() => {
322+
expect(localStorageMock.getItem('theme')).toBe('light');
323+
});
324+
});
325+
});
326+
327+
describe('Error Handling', () => {
328+
it('should throw error when useTheme is used outside ThemeProvider', () => {
329+
// Suppress console.error for this test
330+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
331+
332+
expect(() => {
333+
render(<TestComponent />);
334+
}).toThrow('useTheme must be used within a ThemeProvider');
335+
336+
consoleError.mockRestore();
337+
});
338+
});
339+
});

0 commit comments

Comments
 (0)