chore: setup jest for unit test hooks and component from joi (#3540)

* chore: setup jest for unit test hooks and component from joi

* chore: update gitignore

* chore: exclude jest setup file from tsconfig
This commit is contained in:
Faisal Amir 2024-09-05 11:41:15 +07:00 committed by GitHub
parent edf5c77dd6
commit 1ffb7f213d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 626 additions and 15 deletions

2
.gitignore vendored
View File

@ -20,7 +20,7 @@ electron/themes
electron/playwright-report
server/pre-install
package-lock.json
coverage
*.log
core/lib/**

8
joi/jest.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.*'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom',
}

0
joi/jest.setup.js Normal file
View File

View File

@ -21,7 +21,8 @@
"bugs": "https://github.com/codecentrum/piksel/issues",
"scripts": {
"dev": "rollup -c -w",
"build": "rimraf ./dist && rollup -c"
"build": "rimraf ./dist && rollup -c",
"test": "jest"
},
"peerDependencies": {
"class-variance-authority": "^0.7.0",
@ -38,13 +39,22 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"tailwind-merge": "^2.2.0",
"@types/jest": "^29.5.12",
"autoprefixer": "10.4.16",
"tailwindcss": "^3.4.1"
"jest": "^29.7.0",
"tailwind-merge": "^2.2.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.12",
"jest-environment-jsdom": "^29.7.0",
"jest-transform-css": "^6.0.1",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.6",
"rollup": "^4.12.0",

View File

@ -0,0 +1,64 @@
import React from 'react'
import '@testing-library/jest-dom'
import { render, screen, fireEvent } from '@testing-library/react'
import { Accordion, AccordionItem } from './index'
// Mock the SCSS import
jest.mock('./styles.scss', () => ({}))
describe('Accordion', () => {
it('renders accordion with items', () => {
render(
<Accordion defaultValue={['item1']}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
<AccordionItem value="item2" title="Item 2">
Content 2
</AccordionItem>
</Accordion>
)
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
})
it('expands and collapses accordion items', () => {
render(
<Accordion defaultValue={[]}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
</Accordion>
)
const trigger = screen.getByText('Item 1')
// Initially, content should not be visible
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
// Click to expand
fireEvent.click(trigger)
expect(screen.getByText('Content 1')).toBeInTheDocument()
// Click to collapse
fireEvent.click(trigger)
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
})
it('respects defaultValue prop', () => {
render(
<Accordion defaultValue={['item2']}>
<AccordionItem value="item1" title="Item 1">
Content 1
</AccordionItem>
<AccordionItem value="item2" title="Item 2">
Content 2
</AccordionItem>
</Accordion>
)
expect(screen.queryByText('Content 1')).not.toBeInTheDocument()
expect(screen.getByText('Content 2')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,83 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Badge, badgeConfig } from './index'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
describe('@joi/core/Badge', () => {
it('renders with default props', () => {
render(<Badge>Test Badge</Badge>)
const badge = screen.getByText('Test Badge')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('badge')
expect(badge).toHaveClass('badge--primary')
expect(badge).toHaveClass('badge--medium')
expect(badge).toHaveClass('badge--solid')
})
it('applies custom className', () => {
render(<Badge className="custom-class">Test Badge</Badge>)
const badge = screen.getByText('Test Badge')
expect(badge).toHaveClass('custom-class')
})
it('renders with different themes', () => {
const themes = Object.keys(badgeConfig.variants.theme)
themes.forEach((theme) => {
render(<Badge theme={theme as any}>Test Badge {theme}</Badge>)
const badge = screen.getByText(`Test Badge ${theme}`)
expect(badge).toHaveClass(`badge--${theme}`)
})
})
it('renders with different variants', () => {
const variants = Object.keys(badgeConfig.variants.variant)
variants.forEach((variant) => {
render(<Badge variant={variant as any}>Test Badge {variant}</Badge>)
const badge = screen.getByText(`Test Badge ${variant}`)
expect(badge).toHaveClass(`badge--${variant}`)
})
})
it('renders with different sizes', () => {
const sizes = Object.keys(badgeConfig.variants.size)
sizes.forEach((size) => {
render(<Badge size={size as any}>Test Badge {size}</Badge>)
const badge = screen.getByText(`Test Badge ${size}`)
expect(badge).toHaveClass(`badge--${size}`)
})
})
it('fails when a new theme is added without updating the test', () => {
const expectedThemes = [
'primary',
'secondary',
'warning',
'success',
'info',
'destructive',
]
const actualThemes = Object.keys(badgeConfig.variants.theme)
expect(actualThemes).toEqual(expectedThemes)
})
it('fails when a new variant is added without updating the test', () => {
const expectedVariant = ['solid', 'soft', 'outline']
const actualVariants = Object.keys(badgeConfig.variants.variant)
expect(actualVariants).toEqual(expectedVariant)
})
it('fails when a new size is added without updating the test', () => {
const expectedSizes = ['small', 'medium', 'large']
const actualSizes = Object.keys(badgeConfig.variants.size)
expect(actualSizes).toEqual(expectedSizes)
})
it('fails when a new variant CVA is added without updating the test', () => {
const expectedVariantsCVA = ['theme', 'variant', 'size']
const actualVariant = Object.keys(badgeConfig.variants)
expect(actualVariant).toEqual(expectedVariantsCVA)
})
})

View File

@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge'
import './styles.scss'
const badgeVariants = cva('badge', {
export const badgeConfig = {
variants: {
theme: {
primary: 'badge--primary',
@ -28,11 +28,13 @@ const badgeVariants = cva('badge', {
},
},
defaultVariants: {
theme: 'primary',
size: 'medium',
variant: 'solid',
theme: 'primary' as const,
size: 'medium' as const,
variant: 'solid' as const,
},
})
}
const badgeVariants = cva('badge', badgeConfig)
export interface BadgeProps
extends HTMLAttributes<HTMLDivElement>,

View File

@ -0,0 +1,68 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Button, buttonConfig } from './index'
// Mock the styles
jest.mock('./styles.scss', () => ({}))
describe('Button', () => {
it('renders with default props', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toBeInTheDocument()
expect(button).toHaveClass('btn btn--primary btn--medium btn--solid')
})
it('renders as a child component when asChild is true', () => {
render(
<Button asChild>
<a href="/">Link Button</a>
</Button>
)
const link = screen.getByRole('link', { name: /link button/i })
expect(link).toBeInTheDocument()
expect(link).toHaveClass('btn btn--primary btn--medium btn--solid')
})
it.each(Object.keys(buttonConfig.variants.theme))(
'renders with theme %s',
(theme) => {
render(<Button theme={theme as any}>Theme Button</Button>)
const button = screen.getByRole('button', { name: /theme button/i })
expect(button).toHaveClass(`btn btn--${theme}`)
}
)
it.each(Object.keys(buttonConfig.variants.variant))(
'renders with variant %s',
(variant) => {
render(<Button variant={variant as any}>Variant Button</Button>)
const button = screen.getByRole('button', { name: /variant button/i })
expect(button).toHaveClass(`btn btn--${variant}`)
}
)
it.each(Object.keys(buttonConfig.variants.size))(
'renders with size %s',
(size) => {
render(<Button size={size as any}>Size Button</Button>)
const button = screen.getByRole('button', { name: /size button/i })
expect(button).toHaveClass(`btn btn--${size}`)
}
)
it('renders with block prop', () => {
render(<Button block>Block Button</Button>)
const button = screen.getByRole('button', { name: /block button/i })
expect(button).toHaveClass('btn btn--block')
})
it('merges custom className with generated classes', () => {
render(<Button className="custom-class">Custom Class Button</Button>)
const button = screen.getByRole('button', { name: /custom class button/i })
expect(button).toHaveClass(
'btn btn--primary btn--medium btn--solid custom-class'
)
})
})

View File

@ -7,7 +7,7 @@ import { twMerge } from 'tailwind-merge'
import './styles.scss'
const buttonVariants = cva('btn', {
export const buttonConfig = {
variants: {
theme: {
primary: 'btn--primary',
@ -30,12 +30,13 @@ const buttonVariants = cva('btn', {
},
},
defaultVariants: {
theme: 'primary',
size: 'medium',
variant: 'solid',
block: false,
theme: 'primary' as const,
size: 'medium' as const,
variant: 'solid' as const,
block: false as const,
},
})
}
const buttonVariants = cva('btn', buttonConfig)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,

View File

@ -0,0 +1,55 @@
import React from 'react'
import { render, fireEvent, act } from '@testing-library/react'
import { useClickOutside } from './index'
// Mock component to test the hook
const TestComponent: React.FC<{ onClickOutside: () => void }> = ({
onClickOutside,
}) => {
const ref = useClickOutside(onClickOutside)
return <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>
}
describe('@joi/hooks/useClickOutside', () => {
it('should call handler when clicking outside', () => {
const handleClickOutside = jest.fn()
const { container } = render(
<TestComponent onClickOutside={handleClickOutside} />
)
act(() => {
fireEvent.mouseDown(document.body)
})
expect(handleClickOutside).toHaveBeenCalledTimes(1)
})
it('should not call handler when clicking inside', () => {
const handleClickOutside = jest.fn()
const { getByText } = render(
<TestComponent onClickOutside={handleClickOutside} />
)
act(() => {
fireEvent.mouseDown(getByText('Test'))
})
expect(handleClickOutside).not.toHaveBeenCalled()
})
it('should work with custom events', () => {
const handleClickOutside = jest.fn()
const TestComponentWithCustomEvent: React.FC = () => {
const ref = useClickOutside(handleClickOutside, ['click'])
return <div ref={ref as React.RefObject<HTMLDivElement>}>Test</div>
}
render(<TestComponentWithCustomEvent />)
act(() => {
fireEvent.click(document.body)
})
expect(handleClickOutside).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,102 @@
import { renderHook, act } from '@testing-library/react'
import { useClipboard } from './index'
// Mock the navigator.clipboard
const mockClipboard = {
writeText: jest.fn(() => Promise.resolve()),
}
Object.assign(navigator, { clipboard: mockClipboard })
describe('@joi/hooks/useClipboard', () => {
beforeEach(() => {
jest.useFakeTimers()
jest.spyOn(window, 'setTimeout')
jest.spyOn(window, 'clearTimeout')
mockClipboard.writeText.mockClear()
})
afterEach(() => {
jest.useRealTimers()
jest.clearAllMocks()
})
it('should copy text to clipboard', async () => {
const { result } = renderHook(() => useClipboard())
await act(async () => {
result.current.copy('Test text')
})
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test text')
expect(result.current.copied).toBe(true)
expect(result.current.error).toBe(null)
})
it('should set error if clipboard write fails', async () => {
mockClipboard.writeText.mockRejectedValueOnce(
new Error('Clipboard write failed')
)
const { result } = renderHook(() => useClipboard())
await act(async () => {
result.current.copy('Test text')
})
expect(result.current.error).toBeInstanceOf(Error)
expect(result.current.error?.message).toBe('Clipboard write failed')
})
it('should set error if clipboard is not supported', async () => {
const originalClipboard = navigator.clipboard
// @ts-ignore
delete navigator.clipboard
const { result } = renderHook(() => useClipboard())
await act(async () => {
result.current.copy('Test text')
})
expect(result.current.error).toBeInstanceOf(Error)
expect(result.current.error?.message).toBe(
'useClipboard: navigator.clipboard is not supported'
)
// Restore clipboard support
Object.assign(navigator, { clipboard: originalClipboard })
})
it('should reset copied state after timeout', async () => {
const { result } = renderHook(() => useClipboard({ timeout: 1000 }))
await act(async () => {
result.current.copy('Test text')
})
expect(result.current.copied).toBe(true)
act(() => {
jest.advanceTimersByTime(1000)
})
expect(result.current.copied).toBe(false)
})
it('should reset state when reset is called', async () => {
const { result } = renderHook(() => useClipboard())
await act(async () => {
result.current.copy('Test text')
})
expect(result.current.copied).toBe(true)
act(() => {
result.current.reset()
})
expect(result.current.copied).toBe(false)
expect(result.current.error).toBe(null)
})
})

View File

@ -0,0 +1,90 @@
import { renderHook, act } from '@testing-library/react'
import { useMediaQuery } from './index'
describe('@joi/hooks/useMediaQuery', () => {
const matchMediaMock = jest.fn()
beforeAll(() => {
window.matchMedia = matchMediaMock
})
afterEach(() => {
matchMediaMock.mockClear()
})
it('should return initial value when getInitialValueInEffect is true', () => {
matchMediaMock.mockImplementation(() => ({
matches: true,
addListener: jest.fn(),
removeListener: jest.fn(),
}))
const { result } = renderHook(() =>
useMediaQuery('(min-width: 768px)', true, {
getInitialValueInEffect: true,
})
)
expect(result.current).toBe(true)
})
it('should return correct value based on media query', () => {
matchMediaMock.mockImplementation(() => ({
matches: true,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}))
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'))
expect(result.current).toBe(true)
})
it('should update value when media query changes', () => {
let listener: ((event: { matches: boolean }) => void) | null = null
matchMediaMock.mockImplementation(() => ({
matches: false,
addEventListener: (_, cb) => {
listener = cb
},
removeEventListener: jest.fn(),
}))
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'))
expect(result.current).toBe(false)
act(() => {
if (listener) {
listener({ matches: true })
}
})
expect(result.current).toBe(true)
})
it('should handle older browsers without addEventListener', () => {
let listener: ((event: { matches: boolean }) => void) | null = null
matchMediaMock.mockImplementation(() => ({
matches: false,
addListener: (cb) => {
listener = cb
},
removeListener: jest.fn(),
}))
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'))
expect(result.current).toBe(false)
act(() => {
if (listener) {
listener({ matches: true })
}
})
expect(result.current).toBe(true)
})
})

View File

@ -0,0 +1,39 @@
import { renderHook } from '@testing-library/react'
import { useOs } from './index'
const platforms = {
windows: [
'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0',
],
macos: [
'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0',
],
linux: [
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
],
ios: [
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1',
],
android: [
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36',
],
undetermined: ['UNKNOWN'],
} as const
describe('@joi/hooks/useOS', () => {
afterEach(() => {
jest.clearAllMocks()
})
Object.entries(platforms).forEach(([os, userAgents]) => {
it.each(userAgents)(`should detect %s platform on ${os}`, (userAgent) => {
jest
.spyOn(window.navigator, 'userAgent', 'get')
.mockReturnValueOnce(userAgent)
const { result } = renderHook(() => useOs())
expect(result.current).toBe(os)
})
})
})

View File

@ -0,0 +1,32 @@
import { renderHook } from '@testing-library/react'
import { fireEvent } from '@testing-library/react'
import { usePageLeave } from './index'
describe('@joi/hooks/usePageLeave', () => {
it('should call onPageLeave when mouse leaves the document', () => {
const onPageLeaveMock = jest.fn()
const { result } = renderHook(() => usePageLeave(onPageLeaveMock))
fireEvent.mouseLeave(document.documentElement)
expect(onPageLeaveMock).toHaveBeenCalledTimes(1)
})
it('should remove event listener on unmount', () => {
const onPageLeaveMock = jest.fn()
const removeEventListenerSpy = jest.spyOn(
document.documentElement,
'removeEventListener'
)
const { unmount } = renderHook(() => usePageLeave(onPageLeaveMock))
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'mouseleave',
expect.any(Function)
)
removeEventListenerSpy.mockRestore()
})
})

View File

@ -0,0 +1,56 @@
import { renderHook, act } from '@testing-library/react'
import { useTextSelection } from './index'
describe('@joi/hooks/useTextSelection', () => {
let mockSelection: Selection
beforeEach(() => {
mockSelection = {
toString: jest.fn(),
removeAllRanges: jest.fn(),
addRange: jest.fn(),
} as unknown as Selection
jest.spyOn(document, 'getSelection').mockReturnValue(mockSelection)
jest.spyOn(document, 'addEventListener')
jest.spyOn(document, 'removeEventListener')
})
afterEach(() => {
jest.restoreAllMocks()
})
it('should return the initial selection', () => {
const { result } = renderHook(() => useTextSelection())
expect(result.current).toBe(mockSelection)
})
it('should add and remove event listener', () => {
const { unmount } = renderHook(() => useTextSelection())
expect(document.addEventListener).toHaveBeenCalledWith(
'selectionchange',
expect.any(Function)
)
unmount()
expect(document.removeEventListener).toHaveBeenCalledWith(
'selectionchange',
expect.any(Function)
)
})
it('should update selection when selectionchange event is triggered', () => {
const { result } = renderHook(() => useTextSelection())
const newMockSelection = { toString: jest.fn() } as unknown as Selection
jest.spyOn(document, 'getSelection').mockReturnValue(newMockSelection)
act(() => {
document.dispatchEvent(new Event('selectionchange'))
})
expect(result.current).toBe(newMockSelection)
})
})

View File

@ -3,6 +3,7 @@
"target": "esnext",
"declaration": true,
"declarationDir": "dist/types",
"types": ["jest", "@testing-library/jest-dom"],
"module": "esnext",
"lib": ["es6", "dom", "es2016", "es2017"],
"sourceMap": true,