Skip to content

Commit

Permalink
test: unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
TinsFox committed Nov 2, 2024
1 parent cc0f238 commit e9a311c
Show file tree
Hide file tree
Showing 11 changed files with 1,043 additions and 61 deletions.
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
"release": "release-it",
"start": "tsc && vite build && vite preview",
"storybook": "storybook dev -p 6006",
"test": "vitest"
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:watch": "vitest watch"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
Expand Down Expand Up @@ -111,16 +114,22 @@
"@t3-oss/env-core": "^0.11.1",
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query-devtools": "^5.56.2",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.16.6",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"@vitest/coverage-v8": "^1.3.1",
"@vitest/ui": "^1.3.1",
"autoprefixer": "^10.4.20",
"click-to-react-component": "^1.1.0",
"eslint": "^9.11.1",
"eslint-config-hyoban": "^3.1.6",
"eslint-plugin-storybook": "^0.8.0",
"jsdom": "^24.0.0",
"lint-staged": "^15.2.10",
"postcss": "^8.4.47",
"release-it": "^17.6.0",
Expand Down
798 changes: 779 additions & 19 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

11 changes: 0 additions & 11 deletions src/__tests__/setup.ts

This file was deleted.

16 changes: 0 additions & 16 deletions src/__tests__/user.test.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/components/theme/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState } from "react"

type Theme = "dark" | "light" | "system"
export type Theme = "dark" | "light" | "system"

type ThemeProviderProps = {
children: React.ReactNode
Expand Down
129 changes: 129 additions & 0 deletions src/hooks/use-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

import { useTheme } from "@/components/theme/theme-provider"
import { act, renderHook } from "@/test/test-utils"

describe("useTheme", () => {
beforeEach(() => {
// 清理 localStorage
localStorage.clear()
// 清理 document root 的主题类
document.documentElement.classList.remove("light", "dark")
// 重置 matchMedia
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}))
})

it("should start with system theme and apply light theme when system prefers light", async () => {
const { result } = renderHook(() => useTheme())

expect(result.current.theme).toBe("system")
// 等待 useEffect 执行完成
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.documentElement.classList.contains("light")).toBe(true)
})

it("should apply dark theme when system prefers dark", async () => {
// Mock system dark theme preference
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}))

const { result } = renderHook(() => useTheme())

expect(result.current.theme).toBe("system")
// 等待 useEffect 执行完成
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.documentElement.classList.contains("dark")).toBe(true)
})

it("should load theme from localStorage if available", async () => {
localStorage.setItem("vite-ui-theme", "dark")

const { result } = renderHook(() => useTheme())

expect(result.current.theme).toBe("dark")
// 等待 useEffect 执行完成
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.documentElement.classList.contains("dark")).toBe(true)
})

it("should change theme to dark", async () => {
const { result } = renderHook(() => useTheme())

act(() => {
result.current.setTheme("dark")
})

// 等待 useEffect 执行完成
await new Promise((resolve) => setTimeout(resolve, 0))
expect(result.current.theme).toBe("dark")
expect(localStorage.getItem("vite-ui-theme")).toBe("dark")
expect(document.documentElement.classList.contains("dark")).toBe(true)
})

it("should change theme to light", () => {
const { result } = renderHook(() => useTheme())

act(() => {
result.current.setTheme("light")
})

expect(result.current.theme).toBe("light")
expect(localStorage.getItem("vite-ui-theme")).toBe("light")
expect(document.documentElement.classList.contains("light")).toBe(true)
expect(document.documentElement.classList.contains("dark")).toBe(false)
})

it("should change theme to system", async () => {
localStorage.setItem("vite-ui-theme", "dark")

// 模拟系统主题为 light
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: query === "(prefers-color-scheme: light)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}))

const { result } = renderHook(() => useTheme())

act(() => {
result.current.setTheme("system")
})

// 等待 useEffect 执行完成
await new Promise((resolve) => setTimeout(resolve, 0))

expect(result.current.theme).toBe("system")
expect(localStorage.getItem("vite-ui-theme")).toBe("system")
expect(document.documentElement.classList.contains("light")).toBe(true)
})

it("should throw error when used outside ThemeProvider", () => {
const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => children,
})

expect(() => result.current).toThrow("useTheme must be used within a ThemeProvider")
})
})
10 changes: 10 additions & 0 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest"

import { cn } from "./utils"

describe("cn utility", () => {
it("merges class names correctly", () => {
const result = cn("base-class", "additional", { conditional: true })
expect(result).toBe("base-class additional conditional")
})
})
21 changes: 21 additions & 0 deletions src/pages/(main)/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest"

import { render, screen } from "@/test/test-utils"

import { Component as HomePage } from "./index"

describe("HomePage", () => {
it("renders main heading", () => {
render(<HomePage />)
expect(screen.getByText("Shadcn UI Boilerplate")).toBeInTheDocument()
})

it("contains link to documentation", () => {
render(<HomePage />)
const link = screen.getByText("Getting Started guide")
expect(link).toHaveAttribute(
"href",
"https://shadcnui-boilerplate.pages.dev/guide/what-is-shadcn-ui-boilerplate",
)
})
})
9 changes: 9 additions & 0 deletions src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import "@testing-library/jest-dom"

import { cleanup } from "@testing-library/react"
import { afterEach } from "vitest"

// 每个测试后清理
afterEach(() => {
cleanup()
})
58 changes: 58 additions & 0 deletions src/test/test-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { render as rtlRender, renderHook as rtlRenderHook } from "@testing-library/react"
import { I18nextProvider } from "react-i18next"
import { BrowserRouter } from "react-router-dom"

import type { Theme } from "@/components/theme/theme-provider"
import { ThemeProvider } from "@/components/theme/theme-provider"
import { i18n } from "@/i18n"

type RenderHookOptions = {
initialProps?: {
defaultTheme?: Theme
}
wrapper?: React.ComponentType<{ children: React.ReactNode }>
}

export const renderHook = <Result,>(
hook: () => Result,
options: RenderHookOptions = {},
) => {
return rtlRenderHook(hook, {
wrapper: ({ children }) =>
options.wrapper ?
(
<options.wrapper>{children}</options.wrapper>
) :
(
<ThemeProvider defaultTheme={options.initialProps?.defaultTheme}>
{children}
</ThemeProvider>
),
})
}

function render(ui: React.ReactElement, { route = "/" } = {}) {
window.history.pushState({}, "Test page", route)

const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})

return rtlRender(
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<BrowserRouter>
{ui}
</BrowserRouter>
</I18nextProvider>
</QueryClientProvider>,
)
}

export * from "@testing-library/react"
export { render }
39 changes: 26 additions & 13 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
import { fileURLToPath } from "node:url"

import react from "@vitejs/plugin-react-swc"
import { mergeConfig } from "vite"
import { configDefaults, defineConfig } from "vitest/config"

import viteConfig from "./vite.config"

export default defineConfig((configEnv) => mergeConfig(
viteConfig(configEnv),
defineConfig({
test: {
environment: "jsdom",
exclude: [...configDefaults.exclude, "e2e/*"],
root: fileURLToPath(new URL("./", import.meta.url)),
setupFiles: ["./__test__/setup.ts"],
},
}),
))
export default defineConfig((configEnv) =>
mergeConfig(
viteConfig(configEnv),
defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.ts",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
...(configDefaults.exclude ?? []),
"node_modules/",
"src/test/",
"**/*.d.ts",
"**/*.config.*",
"**/.*",
],
},
},
}),
),
)

0 comments on commit e9a311c

Please sign in to comment.