-
Notifications
You must be signed in to change notification settings - Fork 0
🧪 Add tests for useCardSettings hook #458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,166 @@ | ||||||
| // @vitest-environment jsdom | ||||||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||||||
| import { renderHook, act } from "@testing-library/react"; | ||||||
| import { useCardSettings } from "../useCardSettings"; | ||||||
| import { DEFAULT_CARD_LAYOUT } from "@/lib/types"; | ||||||
| import { DEFAULT_DISPLAY_OPTIONS } from "@/lib/cardSettings"; | ||||||
| import * as cardSettingsLib from "@/lib/cardSettings"; | ||||||
|
|
||||||
| describe("useCardSettings", () => { | ||||||
| beforeEach(() => { | ||||||
| window.localStorage.clear(); | ||||||
| vi.clearAllMocks(); | ||||||
| }); | ||||||
|
|
||||||
| afterEach(() => { | ||||||
| window.localStorage.clear(); | ||||||
| vi.restoreAllMocks(); | ||||||
| }); | ||||||
|
|
||||||
| it("should initialize with default settings when localStorage is empty", () => { | ||||||
| const { result } = renderHook(() => useCardSettings(false)); | ||||||
|
|
||||||
| expect(result.current.layout).toEqual(DEFAULT_CARD_LAYOUT); | ||||||
| expect(result.current.displayOptions).toEqual(DEFAULT_DISPLAY_OPTIONS); | ||||||
| }); | ||||||
|
|
||||||
| it("should load settings from localStorage on initial render if available", () => { | ||||||
| const customOptions = { ...DEFAULT_DISPLAY_OPTIONS, showAvatar: false }; | ||||||
| window.localStorage.setItem("card-display-options", JSON.stringify(customOptions)); | ||||||
|
|
||||||
| const { result } = renderHook(() => useCardSettings(false)); | ||||||
|
|
||||||
| expect(result.current.displayOptions.showAvatar).toBe(false); | ||||||
| }); | ||||||
|
|
||||||
| it("should hydrate when mounted becomes true and not change state if options are identical", () => { | ||||||
| // Set exact default options to local storage so JSON.stringify(prev) === JSON.stringify(stored) | ||||||
| window.localStorage.setItem("card-display-options", JSON.stringify(DEFAULT_DISPLAY_OPTIONS)); | ||||||
| window.localStorage.setItem("card-layout", JSON.stringify(DEFAULT_CARD_LAYOUT)); | ||||||
|
|
||||||
| const { result, rerender } = renderHook(({ mounted }) => useCardSettings(mounted), { | ||||||
| initialProps: { mounted: false } | ||||||
| }); | ||||||
|
|
||||||
| const initialLayout = result.current.layout; | ||||||
| const initialOptions = result.current.displayOptions; | ||||||
|
|
||||||
| // Rerender as mounted | ||||||
| rerender({ mounted: true }); | ||||||
|
|
||||||
| // Ensure state references didn't change because values were identical | ||||||
| expect(result.current.layout).toBe(initialLayout); | ||||||
| expect(result.current.displayOptions).toBe(initialOptions); | ||||||
| }); | ||||||
|
|
||||||
| it("should hydrate when mounted becomes true and change state if options differ", () => { | ||||||
| // Change options so they differ from initial state to hit true branch of ternary | ||||||
| const customOptions = { ...DEFAULT_DISPLAY_OPTIONS, showAvatar: false }; | ||||||
| const customLayout = { blocks: [{ id: "profile", visible: false, column: "full" }] }; | ||||||
|
|
||||||
| const { result, rerender } = renderHook(({ mounted }) => useCardSettings(mounted), { | ||||||
| initialProps: { mounted: false } | ||||||
| }); | ||||||
|
|
||||||
| // Mock loadCardSettings to return new values during effect execution | ||||||
| vi.spyOn(cardSettingsLib, 'loadCardSettings').mockReturnValueOnce({ | ||||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||
| layout: customLayout as any, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
References
|
||||||
| options: customOptions | ||||||
| }); | ||||||
|
|
||||||
| rerender({ mounted: true }); | ||||||
|
|
||||||
| expect(result.current.displayOptions.showAvatar).toBe(false); | ||||||
| expect(result.current.layout).toEqual(customLayout); | ||||||
| }); | ||||||
|
|
||||||
| it("should not hydrate if already hydrated", () => { | ||||||
| // Spy on setItem to see if saveCardSettings was called again | ||||||
| const saveCardSettingsSpy = vi.spyOn(cardSettingsLib, 'saveCardSettings'); | ||||||
|
|
||||||
| const { rerender } = renderHook(({ mounted }) => useCardSettings(mounted), { | ||||||
| initialProps: { mounted: true } | ||||||
| }); | ||||||
|
|
||||||
| // Initial mount hydration complete | ||||||
| expect(saveCardSettingsSpy).toHaveBeenCalledTimes(1); | ||||||
|
|
||||||
| // Rerender again with mounted: true | ||||||
| rerender({ mounted: true }); | ||||||
|
|
||||||
| // It should not trigger hydration logic again, but saveCardSettings will trigger | ||||||
| // because of useEffect dependencies unless we verify state setter didn't fire again. | ||||||
| // Testing the early return in the first useEffect: | ||||||
|
Comment on lines
+72
to
+94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/hooks/__tests__/useCardSettings.test.ts
Line: 71-93
Comment:
**スパイの設定タイミングが遅すぎる**
`loadCardSettingsSpy` が2回目の `rerender` が完了した後(87行目)に設定されており、3回目の rerender の呼び出しのみを監視しています。2回目の rerender(85行目)で `loadCardSettings` が呼ばれたかどうかは一切検証されていないため、「既に hydrate済みの場合は早期リターンする」という動作を実際には証明できていません。スパイは `renderHook` よりも前に設定し、全 rerender 後のトータル呼び出し回数を検証するべきです。
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||
| const loadCardSettingsSpy = vi.spyOn(cardSettingsLib, 'loadCardSettings'); | ||||||
| rerender({ mounted: true }); | ||||||
|
|
||||||
| // The load shouldn't be called again since isHydrated is true | ||||||
| expect(loadCardSettingsSpy).toHaveBeenCalledTimes(0); | ||||||
| }); | ||||||
|
Comment on lines
+78
to
+100
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test case is a bit complex and mixes the concerns of persistence ( it("should not re-hydrate if already hydrated", () => {
const { rerender } = renderHook(() => useCardSettings(true));
const loadCardSettingsSpy = vi.spyOn(cardSettingsLib, 'loadCardSettings');
rerender();
expect(loadCardSettingsSpy).not.toHaveBeenCalled();
}); |
||||||
|
|
||||||
|
|
||||||
| it("should update display options when toggleDisplayOption is called", () => { | ||||||
| const { result } = renderHook(() => useCardSettings(true)); | ||||||
|
|
||||||
| expect(result.current.displayOptions.showAvatar).toBe(true); | ||||||
|
|
||||||
| act(() => { | ||||||
| result.current.toggleDisplayOption("showAvatar"); | ||||||
| }); | ||||||
|
|
||||||
| expect(result.current.displayOptions.showAvatar).toBe(false); | ||||||
|
|
||||||
| // Verify it was saved to storage | ||||||
| const savedOptions = JSON.parse(window.localStorage.getItem("card-display-options") || "{}"); | ||||||
| expect(savedOptions.showAvatar).toBe(false); | ||||||
| }); | ||||||
|
|
||||||
| it("should update layout when toggleMainBlockVisibility is called", () => { | ||||||
| const { result } = renderHook(() => useCardSettings(true)); | ||||||
|
|
||||||
| const profileBlock = result.current.layout.blocks.find(b => b.id === "profile"); | ||||||
| expect(profileBlock?.visible).toBe(true); | ||||||
|
|
||||||
| act(() => { | ||||||
| result.current.toggleMainBlockVisibility("profile"); | ||||||
| }); | ||||||
|
|
||||||
| const updatedProfileBlock = result.current.layout.blocks.find(b => b.id === "profile"); | ||||||
| expect(updatedProfileBlock?.visible).toBe(false); | ||||||
|
|
||||||
| // Verify it was saved to storage | ||||||
| const savedLayout = JSON.parse(window.localStorage.getItem("card-layout") || "{}"); | ||||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||
| const savedProfileBlock = savedLayout.blocks.find((b: any) => b.id === "profile"); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To improve type safety, you can provide a more specific type for the
Suggested change
References
|
||||||
| expect(savedProfileBlock.visible).toBe(false); | ||||||
| }); | ||||||
|
|
||||||
| it("should return correct visibility using isBlockVisible", () => { | ||||||
| const { result } = renderHook(() => useCardSettings(true)); | ||||||
|
|
||||||
| expect(result.current.isBlockVisible("profile")).toBe(true); | ||||||
|
|
||||||
| act(() => { | ||||||
| result.current.toggleMainBlockVisibility("profile"); | ||||||
| }); | ||||||
|
|
||||||
| expect(result.current.isBlockVisible("profile")).toBe(false); | ||||||
| // @ts-expect-error Testing invalid id | ||||||
| expect(result.current.isBlockVisible("invalid-id")).toBe(false); | ||||||
| }); | ||||||
|
|
||||||
| it("should not save settings to localStorage if not hydrated", () => { | ||||||
| // Spy on setItem | ||||||
| const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); | ||||||
|
|
||||||
| const { result } = renderHook(() => useCardSettings(false)); | ||||||
|
|
||||||
| act(() => { | ||||||
| result.current.toggleDisplayOption("showAvatar"); | ||||||
| }); | ||||||
|
|
||||||
| // It shouldn't have setItem because it's not mounted and hydrated | ||||||
| expect(setItemSpy).not.toHaveBeenCalled(); | ||||||
| }); | ||||||
| }); | ||||||
|
Comment on lines
+143
to
+166
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/hooks/__tests__/useCardSettings.test.ts
Line: 141-164
Comment:
**`setItem` スパイを `renderHook` より前に設定することを推奨**
`Storage.prototype.setItem` へのスパイはテスト本体内・`renderHook` の後に設定されています。`renderHook` より前にスパイを設定することで、フック初期化時の副作用も含めてすべての `setItem` 呼び出しを監視でき、将来的な副作用の追加を見逃すリスクを下げられます。
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
normalizeCardLayoutの変換後の値に依存しているこのテストは
DEFAULT_CARD_LAYOUTをそのままlocalStorageに保存し、「値が同一なら参照が変わらない」ことをtoBe(参照等価)で検証しています。しかしloadCardSettingsは内部でnormalizeCardLayout(parsedLayout)を呼ぶため、初期useStateで生成される参照はnormalizeCardLayoutの出力です。DEFAULT_CARD_LAYOUTがnormalizeCardLayoutの冪等入力でない場合、テストが突然失敗する可能性があります。Prompt To Fix With AI