Skip to content

Commit 0a7d7b1

Browse files
committed
chore: init tests
1 parent dc46978 commit 0a7d7b1

File tree

8 files changed

+575
-13
lines changed

8 files changed

+575
-13
lines changed

package-lock.json

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

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
"@makerx/prettier-config": "^2.0.0",
4848
"@makerx/ts-config": "^1.0.1",
4949
"@tauri-apps/cli": "^1.5.11",
50+
"@testing-library/react": "^14.2.2",
51+
"@testing-library/user-event": "^14.5.2",
5052
"@types/node": "^20.11.26",
5153
"@types/react": "^18.2.56",
5254
"@types/react-dom": "^18.2.19",
@@ -62,6 +64,7 @@
6264
"eslint-plugin-react-hooks": "^4.6.0",
6365
"eslint-plugin-react-refresh": "^0.4.5",
6466
"eslint-plugin-tailwindcss": "^3.15.1",
67+
"happy-dom": "^14.4.0",
6568
"npm-run-all": "^4.1.5",
6669
"postcss": "^8.4.35",
6770
"prettier": "^3.2.5",

src/features/theme/components/theme-toggle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function ThemeToggle() {
1010
return (
1111
<DropdownMenu>
1212
<DropdownMenuTrigger asChild>
13-
<Button variant="default" size="icon">
13+
<Button variant="default" size="icon" data-testid="theme-toggle-menu">
1414
<Sun className="size-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
1515
<Moon className="absolute size-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
1616
<span className="sr-only">Toggle theme</span>

src/tests/mock-match-media.tsx

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
interface MediaQueryList {
2+
readonly matches: boolean
3+
readonly media: string
4+
onchange: ((this: MediaQueryList, ev: MediaQueryListEvent) => unknown) | null
5+
/** @deprecated */
6+
addListener(callback: ((this: MediaQueryList, ev: MediaQueryListEvent) => unknown) | null): void
7+
/** @deprecated */
8+
removeListener(callback: ((this: MediaQueryList, ev: MediaQueryListEvent) => unknown) | null): void
9+
addEventListener<K extends keyof MediaQueryListEventMap>(
10+
type: K,
11+
listener: (this: MediaQueryList, ev: MediaQueryListEventMap[K]) => unknown,
12+
options?: boolean | AddEventListenerOptions
13+
): void
14+
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void
15+
removeEventListener<K extends keyof MediaQueryListEventMap>(
16+
type: K,
17+
listener: (this: MediaQueryList, ev: MediaQueryListEventMap[K]) => unknown,
18+
options?: boolean | EventListenerOptions
19+
): void
20+
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void
21+
}
22+
23+
type MediaQueryListener = (this: MediaQueryList, ev: MediaQueryListEvent) => void
24+
25+
export default class MatchMediaMock {
26+
private mediaQueries: {
27+
[key: string]: MediaQueryListener[]
28+
} = {}
29+
30+
private mediaQueryList!: MediaQueryList
31+
32+
private currentMediaQuery!: string
33+
34+
constructor() {
35+
Object.defineProperty(window, 'matchMedia', {
36+
writable: true,
37+
configurable: true,
38+
value: (query: string): MediaQueryList => {
39+
this.mediaQueryList = {
40+
matches: query === this.currentMediaQuery,
41+
media: query,
42+
onchange: null,
43+
addListener: (listener: MediaQueryListener) => {
44+
this.addListener(query, listener)
45+
},
46+
removeListener: (listener: MediaQueryListener) => {
47+
this.removeListener(query, listener)
48+
},
49+
addEventListener: (type: string, listener: EventListener) => {
50+
if (type !== 'change') return
51+
52+
this.addListener(query, listener)
53+
},
54+
removeEventListener: (type: string, listener: EventListener) => {
55+
if (type !== 'change') return
56+
57+
this.removeListener(query, listener)
58+
},
59+
}
60+
61+
return this.mediaQueryList
62+
},
63+
})
64+
}
65+
66+
/**
67+
* Adds a new listener function for the specified media query
68+
* @private
69+
*/
70+
private addListener(mediaQuery: string, listener: MediaQueryListener): void {
71+
if (!this.mediaQueries[mediaQuery]) {
72+
this.mediaQueries[mediaQuery] = []
73+
}
74+
75+
const query = this.mediaQueries[mediaQuery]
76+
const listenerIndex = query.indexOf(listener)
77+
78+
if (listenerIndex !== -1) return
79+
query.push(listener)
80+
}
81+
82+
/**
83+
* Removes a previously added listener function for the specified media query
84+
* @private
85+
*/
86+
private removeListener(mediaQuery: string, listener: MediaQueryListener): void {
87+
if (!this.mediaQueries[mediaQuery]) return
88+
89+
const query = this.mediaQueries[mediaQuery]
90+
const listenerIndex = query.indexOf(listener)
91+
92+
if (listenerIndex === -1) return
93+
query.splice(listenerIndex, 1)
94+
}
95+
96+
/**
97+
* Updates the currently used media query,
98+
* and calls previously added listener functions registered for this media query
99+
* @public
100+
*/
101+
public useMediaQuery(mediaQuery: string): never | void {
102+
if (typeof mediaQuery !== 'string') throw new Error('Media Query must be a string')
103+
104+
this.currentMediaQuery = mediaQuery
105+
106+
if (!this.mediaQueries[mediaQuery]) return
107+
108+
const mqListEvent: Partial<MediaQueryListEvent> = {
109+
matches: true,
110+
media: mediaQuery,
111+
}
112+
113+
this.mediaQueries[mediaQuery].forEach((listener) => {
114+
listener.call(this.mediaQueryList, mqListEvent as MediaQueryListEvent)
115+
})
116+
}
117+
118+
/**
119+
* Returns an array listing the media queries for which the matchMedia has registered listeners
120+
* @public
121+
*/
122+
public getMediaQueries(): string[] {
123+
return Object.keys(this.mediaQueries)
124+
}
125+
126+
/**
127+
* Returns a copy of the array of listeners for the specified media query
128+
* @public
129+
*/
130+
public getListeners(mediaQuery: string): MediaQueryListener[] {
131+
if (!this.mediaQueries[mediaQuery]) return []
132+
return this.mediaQueries[mediaQuery].slice()
133+
}
134+
135+
/**
136+
* Clears all registered media queries and their listeners
137+
* @public
138+
*/
139+
public clear(): void {
140+
this.mediaQueries = {}
141+
this.currentMediaQuery = ''
142+
}
143+
144+
/**
145+
* Clears all registered media queries and their listeners,
146+
* and destroys the implementation of `window.matchMedia`
147+
* @public
148+
*/
149+
public destroy(): void {
150+
this.clear()
151+
}
152+
}

src/tests/testing-library.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { LayoutProvider } from '@/features/layout/context/layout-provider'
2+
import { ThemeProvider } from '@/features/theme/context/theme-provider'
3+
import { TooltipProvider } from '@radix-ui/react-tooltip'
4+
import { queries, render, renderHook, screen, within } from '@testing-library/react'
5+
import type { createStore } from 'jotai'
6+
import { Provider as JotaiProvider } from 'jotai'
7+
import type { PropsWithChildren } from 'react'
8+
import { MemoryRouter } from 'react-router'
9+
10+
const allQueries = {
11+
...queries,
12+
}
13+
14+
const customScreen = {
15+
...within(document.body, allQueries),
16+
debug: screen.debug,
17+
}
18+
19+
type JotaiStore = ReturnType<typeof createStore>
20+
21+
// eslint-disable-next-line react-refresh/only-export-components
22+
const Providers =
23+
(store?: JotaiStore) =>
24+
({ children }: PropsWithChildren) => {
25+
return (
26+
<JotaiProvider store={store}>
27+
<ThemeProvider>
28+
<TooltipProvider>
29+
<LayoutProvider>
30+
<MemoryRouter>{children}</MemoryRouter>
31+
</LayoutProvider>
32+
</TooltipProvider>
33+
</ThemeProvider>
34+
</JotaiProvider>
35+
)
36+
}
37+
38+
const customRender = (ui: Parameters<typeof render>[0], options?: Parameters<typeof render>[1], store?: ReturnType<typeof createStore>) =>
39+
render(ui, { wrapper: Providers(store), ...options })
40+
41+
const customRenderHook = (
42+
ui: Parameters<typeof renderHook>[0],
43+
options?: Parameters<typeof renderHook>[1],
44+
store?: ReturnType<typeof createStore>
45+
) => renderHook(ui, { wrapper: Providers(store), ...options })
46+
47+
// re-export everything
48+
// eslint-disable-next-line react-refresh/only-export-components
49+
export * from '@testing-library/react'
50+
// override render method
51+
export { customRender as render, customRenderHook as renderHook, customScreen as screen }

tests/theme.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect, beforeAll } from 'vitest'
2+
import { render, waitFor, screen } from '../src/tests/testing-library'
3+
import MatchMediaMock from '@/tests/mock-match-media'
4+
import { afterEach } from 'node:test'
5+
import { ThemeToggle } from '@/features/theme/components/theme-toggle'
6+
import userEvent from '@testing-library/user-event'
7+
8+
describe('when using the default system light theme', () => {
9+
beforeAll(() => {
10+
render(<ThemeToggle />)
11+
})
12+
13+
it('the theme is set to light', async () => {
14+
await waitFor(() => expect(document.documentElement.classList.contains('light')).toBe(true))
15+
})
16+
17+
describe('when the theme is toggled to dark', () => {
18+
it('the theme is set to dark', async () => {
19+
userEvent.click(await screen.findByTestId('theme-toggle-menu'))
20+
userEvent.click(await screen.findByText('Dark'))
21+
22+
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(true))
23+
})
24+
})
25+
})
26+
27+
describe('when using the default system dark theme', () => {
28+
const matchMediaMock = new MatchMediaMock()
29+
30+
beforeAll(() => {
31+
// Set system theme to dark
32+
matchMediaMock.useMediaQuery('(prefers-color-scheme: dark)')
33+
render(<ThemeToggle />)
34+
})
35+
afterEach(() => matchMediaMock.clear())
36+
37+
it('the theme is set to dark', async () => {
38+
await waitFor(() => expect(document.documentElement.classList.contains('dark')).toBe(true))
39+
})
40+
})

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,6 @@
2525
"@/*": ["./src/*"]
2626
}
2727
},
28-
"include": ["src"],
28+
"include": ["src", "tests"],
2929
"references": [{ "path": "./tsconfig.node.json" }]
3030
}

vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default defineConfig({
88
plugins: [react()],
99
test: {
1010
root: 'tests',
11+
environment: 'happy-dom',
1112
},
1213

1314
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`

0 commit comments

Comments
 (0)