Skip to content

Commit 429a6a2

Browse files
committed
Add tests for custom hooks
1 parent c20790b commit 429a6a2

File tree

4 files changed

+314
-9
lines changed

4 files changed

+314
-9
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { render, act } from '@testing-library/react';
2+
import { fireEvent } from '@testing-library/react';
3+
import { useClickOutside } from '../src/utils/useClickOutside'; // adjust the import path as needed
4+
import React, { useRef } from 'react';
5+
import { jest, afterEach, beforeEach, describe, expect, test } from "@jest/globals";
6+
7+
// Test component that uses the hook
8+
const TestComponent = ({
9+
isActive,
10+
onClickOutside,
11+
useExcludeRef = false
12+
}: {
13+
isActive: boolean;
14+
onClickOutside: () => void;
15+
useExcludeRef?: boolean;
16+
}) => {
17+
const ref = useRef<HTMLDivElement>(null);
18+
const excludeRef = useRef<HTMLButtonElement>(null);
19+
20+
useClickOutside(
21+
ref,
22+
onClickOutside,
23+
isActive,
24+
useExcludeRef ? excludeRef : undefined
25+
);
26+
27+
return (
28+
<div>
29+
<div data-testid="inside-element" ref={ref}>Inside Element</div>
30+
{useExcludeRef && (
31+
<button data-testid="excluded-element" ref={excludeRef}>
32+
Excluded Element
33+
</button>
34+
)}
35+
<div data-testid="outside-element">Outside Element</div>
36+
</div>
37+
);
38+
};
39+
40+
describe('useClickOutside', () => {
41+
// Mock callback function
42+
const mockOnClickOutside = jest.fn();
43+
44+
beforeEach(() => {
45+
// Reset mocks
46+
mockOnClickOutside.mockReset();
47+
48+
// Spy on addEventListener and removeEventListener
49+
jest.spyOn(document, 'addEventListener');
50+
jest.spyOn(document, 'removeEventListener');
51+
});
52+
53+
afterEach(() => {
54+
// Restore spies
55+
jest.restoreAllMocks();
56+
});
57+
58+
test('should add event listener when isActive is true', () => {
59+
render(<TestComponent isActive={true} onClickOutside={mockOnClickOutside} />);
60+
61+
expect(document.addEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function));
62+
});
63+
64+
test('should not add event listener when isActive is false', () => {
65+
render(<TestComponent isActive={false} onClickOutside={mockOnClickOutside} />);
66+
67+
expect(document.addEventListener).not.toHaveBeenCalled();
68+
});
69+
70+
test('should remove event listener when isActive changes from true to false', () => {
71+
const { rerender } = render(
72+
<TestComponent isActive={true} onClickOutside={mockOnClickOutside} />
73+
);
74+
75+
rerender(<TestComponent isActive={false} onClickOutside={mockOnClickOutside} />);
76+
77+
expect(document.removeEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function));
78+
});
79+
80+
test('should call onClickOutside when clicking outside the ref element', () => {
81+
const { getByTestId } = render(
82+
<TestComponent isActive={true} onClickOutside={mockOnClickOutside} />
83+
);
84+
85+
// Simulate clicking outside
86+
fireEvent.mouseDown(getByTestId('outside-element'));
87+
88+
expect(mockOnClickOutside).toHaveBeenCalledTimes(1);
89+
});
90+
91+
test('should not call onClickOutside when clicking inside the ref element', () => {
92+
const { getByTestId } = render(
93+
<TestComponent isActive={true} onClickOutside={mockOnClickOutside} />
94+
);
95+
96+
// Simulate clicking inside
97+
fireEvent.mouseDown(getByTestId('inside-element'));
98+
99+
expect(mockOnClickOutside).not.toHaveBeenCalled();
100+
});
101+
102+
test('should not call onClickOutside when clicking inside the excluded element', () => {
103+
const { getByTestId } = render(
104+
<TestComponent
105+
isActive={true}
106+
onClickOutside={mockOnClickOutside}
107+
useExcludeRef={true}
108+
/>
109+
);
110+
111+
// Simulate clicking on the excluded element
112+
fireEvent.mouseDown(getByTestId('excluded-element'));
113+
114+
expect(mockOnClickOutside).not.toHaveBeenCalled();
115+
});
116+
117+
test('should clean up event listener when unmounting', () => {
118+
const { unmount } = render(
119+
<TestComponent isActive={true} onClickOutside={mockOnClickOutside} />
120+
);
121+
122+
unmount();
123+
124+
expect(document.removeEventListener).toHaveBeenCalled();
125+
});
126+
127+
test('should re-attach event listener when dependencies change', () => {
128+
const newMockCallback = jest.fn();
129+
const { rerender } = render(
130+
<TestComponent isActive={true} onClickOutside={mockOnClickOutside} />
131+
);
132+
133+
// First, verify initial setup
134+
expect(document.addEventListener).toHaveBeenCalledTimes(1);
135+
136+
// Reset the spy counts to make verification clearer
137+
jest.clearAllMocks();
138+
139+
// Change callback
140+
rerender(<TestComponent isActive={true} onClickOutside={newMockCallback} />);
141+
142+
// Should remove old listener and add new one
143+
expect(document.removeEventListener).toHaveBeenCalledTimes(1);
144+
expect(document.addEventListener).toHaveBeenCalledTimes(1);
145+
});
146+
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { renderHook, act } from '@testing-library/react';
2+
import { useDebounceValue } from '../src/utils/useDebounce';
3+
import { jest, afterEach, beforeEach, describe, expect, test } from "@jest/globals";
4+
5+
describe('useDebounceValue', () => {
6+
beforeEach(() => {
7+
jest.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
jest.useRealTimers();
12+
});
13+
14+
test('should return the initial value immediately', () => {
15+
const initialValue = 'initial';
16+
const { result } = renderHook(() => useDebounceValue(initialValue, 500));
17+
18+
expect(result.current).toBe(initialValue);
19+
});
20+
21+
test('should delay updating the value until after the specified interval', () => {
22+
const initialValue = 'initial';
23+
const { result, rerender } = renderHook(
24+
({ value, interval }) => useDebounceValue(value, interval),
25+
{ initialProps: { value: initialValue, interval: 500 } }
26+
);
27+
28+
expect(result.current).toBe(initialValue);
29+
30+
// Change the value
31+
const newValue = 'updated';
32+
rerender({ value: newValue, interval: 500 });
33+
34+
// Value should not have changed yet
35+
expect(result.current).toBe(initialValue);
36+
37+
// Fast-forward time by 499ms (just before the debounce interval)
38+
act(() => {
39+
jest.advanceTimersByTime(499);
40+
});
41+
42+
// Value should still be the initial value
43+
expect(result.current).toBe(initialValue);
44+
45+
// Fast-forward the remaining 1ms to reach the debounce interval
46+
act(() => {
47+
jest.advanceTimersByTime(1);
48+
});
49+
50+
// Value should now be updated
51+
expect(result.current).toBe(newValue);
52+
});
53+
54+
test('should reset the debounce timer when the value changes again', () => {
55+
const initialValue = 'initial';
56+
const { result, rerender } = renderHook(
57+
({ value, interval }) => useDebounceValue(value, interval),
58+
{ initialProps: { value: initialValue, interval: 500 } }
59+
);
60+
61+
// Change the value once
62+
rerender({ value: 'intermediate', interval: 500 });
63+
64+
// Fast-forward time by 250ms (halfway through debounce interval)
65+
act(() => {
66+
jest.advanceTimersByTime(250);
67+
});
68+
69+
// Value should still be initial
70+
expect(result.current).toBe(initialValue);
71+
72+
// Change the value again
73+
rerender({ value: 'final', interval: 500 });
74+
75+
// Fast-forward another 250ms (would reach the first interval, but timer was reset)
76+
act(() => {
77+
jest.advanceTimersByTime(250);
78+
});
79+
80+
// Value should still be initial because timer was reset
81+
expect(result.current).toBe(initialValue);
82+
83+
// Fast-forward to reach the new timer completion
84+
act(() => {
85+
jest.advanceTimersByTime(250);
86+
});
87+
88+
// Value should now be the final value
89+
expect(result.current).toBe('final');
90+
});
91+
92+
test('should respect the new interval when interval changes', () => {
93+
const initialValue = 'initial';
94+
const { result, rerender } = renderHook(
95+
({ value, interval }) => useDebounceValue(value, interval),
96+
{ initialProps: { value: initialValue, interval: 500 } }
97+
);
98+
99+
// Change value and interval
100+
rerender({ value: 'updated', interval: 1000 });
101+
102+
// Fast-forward by 500ms (the original interval)
103+
act(() => {
104+
jest.advanceTimersByTime(500);
105+
});
106+
107+
// Value should still be initial because new interval is 1000ms
108+
expect(result.current).toBe(initialValue);
109+
110+
// Fast-forward by another 500ms to reach new 1000ms interval
111+
act(() => {
112+
jest.advanceTimersByTime(500);
113+
});
114+
115+
// Value should now be updated
116+
expect(result.current).toBe('updated');
117+
});
118+
119+
test('should work with different data types', () => {
120+
// Test with object
121+
const initialObject = { name: 'John' };
122+
const { result: objectResult, rerender: objectRerender } = renderHook(
123+
({ value, interval }) => useDebounceValue(value, interval),
124+
{ initialProps: { value: initialObject, interval: 200 } }
125+
);
126+
127+
const newObject = { name: 'Jane' };
128+
objectRerender({ value: newObject, interval: 200 });
129+
130+
act(() => {
131+
jest.advanceTimersByTime(200);
132+
});
133+
134+
expect(objectResult.current).toEqual(newObject);
135+
136+
// Test with number
137+
const { result: numberResult, rerender: numberRerender } = renderHook(
138+
({ value, interval }) => useDebounceValue(value, interval),
139+
{ initialProps: { value: 1, interval: 200 } }
140+
);
141+
142+
numberRerender({ value: 2, interval: 200 });
143+
144+
act(() => {
145+
jest.advanceTimersByTime(200);
146+
});
147+
148+
expect(numberResult.current).toBe(2);
149+
});
150+
151+
test('should clean up timeout on unmount', () => {
152+
const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout');
153+
const { unmount } = renderHook(() => useDebounceValue('test', 500));
154+
155+
unmount();
156+
157+
expect(clearTimeoutSpy).toHaveBeenCalled();
158+
});
159+
});

course-matrix/frontend/package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

course-matrix/frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"@eslint/js": "^9.17.0",
6060
"@jest/globals": "^29.7.0",
6161
"@testing-library/jest-dom": "^6.6.3",
62-
"@testing-library/react": "^16.0.0",
62+
"@testing-library/react": "^16.3.0",
6363
"@tsconfig/node20": "^20.1.4",
6464
"@types/jest": "^29.5.14",
6565
"@types/node": "^22.10.10",

0 commit comments

Comments
 (0)