diff --git a/README.md b/README.md
index 6ed6024a8..c2d472b56 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@ CoreUI is meant to be the UX game changer. Pure & transparent code is devoid of
* [Quick Start](#quick-start)
* [Installation](#installation)
* [Basic usage](#basic-usage)
+* [Testing](#testing)
* [What's included](#whats-included)
* [Documentation](#documentation)
* [Components](#components)
@@ -112,6 +113,96 @@ or
$ yarn build
```
+## Testing
+
+This project uses [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/react) for unit testing.
+
+### Running Tests
+
+```bash
+# Run tests in watch mode
+$ npm test
+
+# Run tests once
+$ npm test -- --run
+
+# Run tests with UI
+$ npm run test:ui
+
+# Run tests with coverage
+$ npm run test:coverage
+```
+
+### Test Structure
+
+Tests are located alongside their source files with the `.test.js` extension:
+
+```
+src/
+├── components/
+│ ├── AppHeader.js
+│ ├── AppHeader.test.js
+│ ├── AppFooter.js
+│ └── AppFooter.test.js
+├── App.js
+├── App.test.js
+└── store.test.js
+```
+
+### Writing Tests
+
+**Basic Component Test:**
+```javascript
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect } from 'vitest'
+import MyComponent from './MyComponent'
+
+describe('MyComponent', () => {
+ it('renders without crashing', () => {
+ render()
+ expect(screen.getByText('Hello')).toBeInTheDocument()
+ })
+})
+```
+
+**Testing with Redux:**
+```javascript
+import { Provider } from 'react-redux'
+import store from '../store'
+
+const renderWithProviders = (component) => {
+ return render(
+
+ {component}
+
+ )
+}
+```
+
+**Testing with Router:**
+```javascript
+import { BrowserRouter } from 'react-router-dom'
+
+const renderWithRouter = (component) => {
+ return render(
+
+ {component}
+
+ )
+}
+```
+
+### Current Test Coverage
+
+- ✅ AppFooter
+- ✅ AppHeader
+- ✅ AppSidebar
+- ✅ AppSidebarNav
+- ✅ AppBreadcrumb
+- ✅ AppContent
+- ✅ App
+- ✅ Store (Redux)
+
## What's included
Within the download you'll find the following directories and files, logically grouping common assets and providing both compiled and minified variations. You'll see something like this:
diff --git a/package.json b/package.json
index 52cf0815d..0e12394e1 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,10 @@
"build": "vite build",
"lint": "eslint",
"serve": "vite preview",
- "start": "vite"
+ "start": "vite",
+ "test": "vitest",
+ "test:ui": "vitest --ui",
+ "test:coverage": "vitest --coverage"
},
"dependencies": {
"@coreui/chartjs": "^4.1.0",
@@ -39,7 +42,12 @@
"simplebar-react": "^3.3.2"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/react": "^16.1.0",
+ "@testing-library/user-event": "^14.5.2",
"@vitejs/plugin-react": "^5.1.0",
+ "@vitest/coverage-v8": "^2.1.9",
+ "@vitest/ui": "^2.1.8",
"autoprefixer": "^10.4.21",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
@@ -47,9 +55,11 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.4.0",
+ "jsdom": "^25.0.1",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"sass": "^1.93.2",
- "vite": "^7.1.12"
+ "vite": "^7.1.12",
+ "vitest": "^2.1.8"
}
}
diff --git a/src/App.test.js b/src/App.test.js
new file mode 100644
index 000000000..c0dcd876e
--- /dev/null
+++ b/src/App.test.js
@@ -0,0 +1,34 @@
+import { render } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { describe, it, expect } from 'vitest'
+import App from './App'
+import store from './store'
+
+describe('App', () => {
+ it('renders without crashing', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ expect(container).toBeInTheDocument()
+ })
+
+ it('renders HashRouter', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ expect(container.querySelector('div')).toBeInTheDocument()
+ })
+
+ it('renders Suspense with fallback', () => {
+ const { container } = render(
+
+
+ ,
+ )
+ expect(container).toBeInTheDocument()
+ })
+})
diff --git a/src/components/AppBreadcrumb.test.js b/src/components/AppBreadcrumb.test.js
new file mode 100644
index 000000000..850c76c79
--- /dev/null
+++ b/src/components/AppBreadcrumb.test.js
@@ -0,0 +1,21 @@
+import { render } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import { describe, it, expect } from 'vitest'
+import AppBreadcrumb from './AppBreadcrumb'
+
+const renderWithRouter = (component) => {
+ return render({component})
+}
+
+describe('AppBreadcrumb', () => {
+ it('renders without crashing', () => {
+ const { container } = renderWithRouter()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('renders breadcrumb container', () => {
+ const { container } = renderWithRouter()
+ const breadcrumb = container.querySelector('.breadcrumb')
+ expect(breadcrumb).toBeInTheDocument()
+ })
+})
diff --git a/src/components/AppContent.test.js b/src/components/AppContent.test.js
new file mode 100644
index 000000000..2c8b3aa21
--- /dev/null
+++ b/src/components/AppContent.test.js
@@ -0,0 +1,32 @@
+import { render } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import { Provider } from 'react-redux'
+import { describe, it, expect } from 'vitest'
+import AppContent from './AppContent'
+import store from '../store'
+
+const renderWithProviders = (component) => {
+ return render(
+
+ {component}
+ ,
+ )
+}
+
+describe('AppContent', () => {
+ it('renders without crashing', () => {
+ const { container } = renderWithProviders()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('renders container', () => {
+ const { container } = renderWithProviders()
+ const contentContainer = container.querySelector('.container-lg')
+ expect(contentContainer).toBeInTheDocument()
+ })
+
+ it('renders Suspense fallback', () => {
+ const { container } = renderWithProviders()
+ expect(container).toBeInTheDocument()
+ })
+})
diff --git a/src/components/AppFooter.test.js b/src/components/AppFooter.test.js
new file mode 100644
index 000000000..3d812b165
--- /dev/null
+++ b/src/components/AppFooter.test.js
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react'
+import { describe, it, expect } from 'vitest'
+import AppFooter from './AppFooter'
+
+describe('AppFooter', () => {
+ it('renders without crashing', () => {
+ render()
+ expect(screen.getByText('CoreUI')).toBeInTheDocument()
+ })
+
+ it('displays copyright year 2025', () => {
+ render()
+ expect(screen.getByText(/2025 creativeLabs/)).toBeInTheDocument()
+ })
+
+ it('renders CoreUI link with correct attributes', () => {
+ render()
+ const link = screen.getByRole('link', { name: 'CoreUI' })
+ expect(link).toHaveAttribute('href', 'https://coreui.io')
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('renders CoreUI React Admin link', () => {
+ render()
+ const link = screen.getByRole('link', { name: /CoreUI React Admin/ })
+ expect(link).toHaveAttribute('href', 'https://coreui.io/react')
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('displays "Powered by" text', () => {
+ render()
+ expect(screen.getByText('Powered by')).toBeInTheDocument()
+ })
+
+ it('is memoized', () => {
+ expect(AppFooter).toBe(AppFooter)
+ })
+})
diff --git a/src/components/AppHeader.test.js b/src/components/AppHeader.test.js
new file mode 100644
index 000000000..44c5f92b6
--- /dev/null
+++ b/src/components/AppHeader.test.js
@@ -0,0 +1,66 @@
+import { render, screen } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { BrowserRouter } from 'react-router-dom'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import AppHeader from './AppHeader'
+import store from '../store'
+
+const renderWithProviders = (component) => {
+ return render(
+
+ {component}
+ ,
+ )
+}
+
+describe('AppHeader', () => {
+ let addEventListenerSpy
+ let removeEventListenerSpy
+
+ beforeEach(() => {
+ addEventListenerSpy = vi.spyOn(document, 'addEventListener')
+ removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('renders without crashing', () => {
+ const { container } = renderWithProviders()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('renders navigation items', () => {
+ renderWithProviders()
+ expect(screen.getByText('Dashboard')).toBeInTheDocument()
+ expect(screen.getByText('Users')).toBeInTheDocument()
+ expect(screen.getAllByText('Settings').length).toBeGreaterThan(0)
+ })
+
+ it('adds scroll event listener on mount', () => {
+ renderWithProviders()
+ expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
+ })
+
+ it('removes scroll event listener on unmount', () => {
+ const { unmount } = renderWithProviders()
+ const scrollHandler = addEventListenerSpy.mock.calls[0][1]
+
+ unmount()
+
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', scrollHandler)
+ })
+
+ it('renders theme switcher dropdown', () => {
+ renderWithProviders()
+ const dropdowns = document.querySelectorAll('.dropdown')
+ expect(dropdowns.length).toBeGreaterThan(0)
+ })
+
+ it('renders header icons', () => {
+ renderWithProviders()
+ const icons = document.querySelectorAll('.nav-link')
+ expect(icons.length).toBeGreaterThan(0)
+ })
+})
diff --git a/src/components/AppSidebar.test.js b/src/components/AppSidebar.test.js
new file mode 100644
index 000000000..7a48293fb
--- /dev/null
+++ b/src/components/AppSidebar.test.js
@@ -0,0 +1,43 @@
+import { render } from '@testing-library/react'
+import { Provider } from 'react-redux'
+import { BrowserRouter } from 'react-router-dom'
+import { describe, it, expect } from 'vitest'
+import AppSidebar from './AppSidebar'
+import store from '../store'
+
+const renderWithProviders = (component) => {
+ return render(
+
+ {component}
+ ,
+ )
+}
+
+describe('AppSidebar', () => {
+ it('renders without crashing', () => {
+ const { container } = renderWithProviders()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('renders sidebar brand', () => {
+ const { container } = renderWithProviders()
+ const brand = container.querySelector('.sidebar-brand')
+ expect(brand).toBeInTheDocument()
+ })
+
+ it('renders navigation items', () => {
+ const { container } = renderWithProviders()
+ const navItems = container.querySelectorAll('.nav-link')
+ expect(navItems.length).toBeGreaterThan(0)
+ })
+
+ it('renders sidebar toggler on large screens', () => {
+ const { container } = renderWithProviders()
+ const toggler = container.querySelector('.sidebar-toggler')
+ expect(toggler).toBeInTheDocument()
+ })
+
+ it('is memoized', () => {
+ expect(AppSidebar).toBe(AppSidebar)
+ })
+})
diff --git a/src/components/AppSidebarNav.test.js b/src/components/AppSidebarNav.test.js
new file mode 100644
index 000000000..319187197
--- /dev/null
+++ b/src/components/AppSidebarNav.test.js
@@ -0,0 +1,69 @@
+import { render } from '@testing-library/react'
+import { BrowserRouter } from 'react-router-dom'
+import { describe, it, expect } from 'vitest'
+import { AppSidebarNav } from './AppSidebarNav'
+import { CNavItem, CNavGroup } from '@coreui/react'
+
+const renderWithRouter = (component) => {
+ return render({component})
+}
+
+describe('AppSidebarNav', () => {
+ const mockItems = [
+ {
+ component: CNavItem,
+ name: 'Dashboard',
+ to: '/dashboard',
+ icon: Icon,
+ },
+ {
+ component: CNavGroup,
+ name: 'Base',
+ icon: Icon,
+ items: [
+ {
+ component: CNavItem,
+ name: 'Accordion',
+ to: '/base/accordion',
+ },
+ ],
+ },
+ ]
+
+ it('renders without crashing', () => {
+ const { container } = renderWithRouter()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('renders navigation items', () => {
+ const { getByText } = renderWithRouter()
+ expect(getByText('Dashboard')).toBeInTheDocument()
+ expect(getByText('Base')).toBeInTheDocument()
+ })
+
+ it('renders nested navigation items', () => {
+ const { getByText } = renderWithRouter()
+ expect(getByText('Accordion')).toBeInTheDocument()
+ })
+
+ it('renders with empty items array', () => {
+ const { container } = renderWithRouter()
+ expect(container).toBeInTheDocument()
+ })
+
+ it('renders badges when provided', () => {
+ const itemsWithBadge = [
+ {
+ component: CNavItem,
+ name: 'Dashboard',
+ to: '/dashboard',
+ badge: {
+ color: 'info',
+ text: 'NEW',
+ },
+ },
+ ]
+ const { getByText } = renderWithRouter()
+ expect(getByText('NEW')).toBeInTheDocument()
+ })
+})
diff --git a/src/setupTests.js b/src/setupTests.js
new file mode 100644
index 000000000..badc51c27
--- /dev/null
+++ b/src/setupTests.js
@@ -0,0 +1,25 @@
+import '@testing-library/jest-dom'
+
+// Mock window.matchMedia
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: (query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {}, // deprecated
+ removeListener: () => {}, // deprecated
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => {},
+ }),
+})
+
+// Mock window.getComputedStyle
+Object.defineProperty(window, 'getComputedStyle', {
+ value: () => ({
+ getPropertyValue: () => '',
+ width: '0px',
+ height: '0px',
+ }),
+})
diff --git a/src/store.test.js b/src/store.test.js
new file mode 100644
index 000000000..f3b1b2d30
--- /dev/null
+++ b/src/store.test.js
@@ -0,0 +1,65 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { legacy_createStore as createStore } from 'redux'
+
+// Import the reducer logic
+const initialState = {
+ sidebarShow: true,
+ theme: 'light',
+}
+
+const changeState = (state = initialState, { type, ...rest }) => {
+ switch (type) {
+ case 'set':
+ return { ...state, ...rest }
+ default:
+ return state
+ }
+}
+
+describe('Redux Store', () => {
+ let store
+
+ beforeEach(() => {
+ store = createStore(changeState)
+ })
+
+ it('has correct initial state', () => {
+ const state = store.getState()
+ expect(state.sidebarShow).toBe(true)
+ expect(state.theme).toBe('light')
+ })
+
+ it('updates sidebarShow with set action', () => {
+ store.dispatch({ type: 'set', sidebarShow: false })
+ expect(store.getState().sidebarShow).toBe(false)
+ })
+
+ it('updates theme with set action', () => {
+ store.dispatch({ type: 'set', theme: 'dark' })
+ expect(store.getState().theme).toBe('dark')
+ })
+
+ it('updates multiple properties with set action', () => {
+ store.dispatch({ type: 'set', sidebarShow: false, theme: 'dark' })
+ const state = store.getState()
+ expect(state.sidebarShow).toBe(false)
+ expect(state.theme).toBe('dark')
+ })
+
+ it('adds new properties with set action', () => {
+ store.dispatch({ type: 'set', sidebarUnfoldable: true })
+ expect(store.getState().sidebarUnfoldable).toBe(true)
+ })
+
+ it('returns current state for unknown action types', () => {
+ const stateBefore = store.getState()
+ store.dispatch({ type: 'UNKNOWN_ACTION' })
+ const stateAfter = store.getState()
+ expect(stateAfter).toEqual(stateBefore)
+ })
+
+ it('preserves existing state when updating', () => {
+ store.dispatch({ type: 'set', sidebarShow: false })
+ expect(store.getState().theme).toBe('light') // theme should remain unchanged
+ })
+})
diff --git a/vitest.config.mjs b/vitest.config.mjs
new file mode 100644
index 000000000..1acab7f61
--- /dev/null
+++ b/vitest.config.mjs
@@ -0,0 +1,34 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'node:path'
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/setupTests.js',
+ css: true,
+ },
+ esbuild: {
+ loader: 'jsx',
+ include: /src\/.*\.(js|jsx|ts|tsx)$/,
+ exclude: [],
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ loader: {
+ '.js': 'jsx',
+ },
+ },
+ },
+ resolve: {
+ alias: [
+ {
+ find: 'src/',
+ replacement: `${path.resolve(__dirname, 'src')}/`,
+ },
+ ],
+ extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.scss'],
+ },
+})