Skip to content

Commit ec0fcf8

Browse files
authored
Add tests for custom hooks (#150)
Co-authored-by: kevin-lann <[email protected]>
1 parent e48a6c6 commit ec0fcf8

File tree

4 files changed

+353
-9
lines changed

4 files changed

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

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)