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'], + }, +})