/** * Test Template for React Components * * WHY THIS STRUCTURE? * - Organized sections make tests easy to navigate and maintain * - Mocks at top ensure consistent test isolation * - Factory functions reduce duplication and improve readability * - describe blocks group related scenarios for better debugging * * INSTRUCTIONS: * 1. Replace `ComponentName` with your component name * 2. Update import path * 3. Add/remove test sections based on component features (use analyze-component) * 4. Follow AAA pattern: Arrange → Act → Assert * * RUN FIRST: pnpm analyze-component to identify required test scenarios */ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' // import ComponentName from './index' // ============================================================================ // Mocks // ============================================================================ // WHY: Mocks must be hoisted to top of file (Jest requirement). // They run BEFORE imports, so keep them before component imports. // i18n (automatically mocked) // WHY: Shared mock at web/__mocks__/react-i18next.ts is auto-loaded by Jest // No explicit mock needed - it returns translation keys as-is // Override only if custom translations are required: // jest.mock('react-i18next', () => ({ // useTranslation: () => ({ // t: (key: string) => { // const customTranslations: Record = { // 'my.custom.key': 'Custom Translation', // } // return customTranslations[key] || key // }, // }), // })) // Router (if component uses useRouter, usePathname, useSearchParams) // WHY: Isolates tests from Next.js routing, enables testing navigation behavior // const mockPush = jest.fn() // jest.mock('next/navigation', () => ({ // useRouter: () => ({ push: mockPush }), // usePathname: () => '/test-path', // })) // API services (if component fetches data) // WHY: Prevents real network calls, enables testing all states (loading/success/error) // jest.mock('@/service/api') // import * as api from '@/service/api' // const mockedApi = api as jest.Mocked // Shared mock state (for portal/dropdown components) // WHY: Portal components like PortalToFollowElem need shared state between // parent and child mocks to correctly simulate open/close behavior // let mockOpenState = false // ============================================================================ // Test Data Factories // ============================================================================ // WHY FACTORIES? // - Avoid hard-coded test data scattered across tests // - Easy to create variations with overrides // - Type-safe when using actual types from source // - Single source of truth for default test values // const createMockProps = (overrides = {}) => ({ // // Default props that make component render successfully // ...overrides, // }) // const createMockItem = (overrides = {}) => ({ // id: 'item-1', // name: 'Test Item', // ...overrides, // }) // ============================================================================ // Test Helpers // ============================================================================ // const renderComponent = (props = {}) => { // return render() // } // ============================================================================ // Tests // ============================================================================ describe('ComponentName', () => { // WHY beforeEach with clearAllMocks? // - Ensures each test starts with clean slate // - Prevents mock call history from leaking between tests // - MUST be beforeEach (not afterEach) to reset BEFORE assertions like toHaveBeenCalledTimes beforeEach(() => { jest.clearAllMocks() // Reset shared mock state if used (CRITICAL for portal/dropdown tests) // mockOpenState = false }) // -------------------------------------------------------------------------- // Rendering Tests (REQUIRED - Every component MUST have these) // -------------------------------------------------------------------------- // WHY: Catches import errors, missing providers, and basic render issues describe('Rendering', () => { it('should render without crashing', () => { // Arrange - Setup data and mocks // const props = createMockProps() // Act - Render the component // render() // Assert - Verify expected output // Prefer getByRole for accessibility; it's what users "see" // expect(screen.getByRole('...')).toBeInTheDocument() }) it('should render with default props', () => { // WHY: Verifies component works without optional props // render() // expect(screen.getByText('...')).toBeInTheDocument() }) }) // -------------------------------------------------------------------------- // Props Tests (REQUIRED - Every component MUST test prop behavior) // -------------------------------------------------------------------------- // WHY: Props are the component's API contract. Test them thoroughly. describe('Props', () => { it('should apply custom className', () => { // WHY: Common pattern in Dify - components should merge custom classes // render() // expect(screen.getByTestId('component')).toHaveClass('custom-class') }) it('should use default values for optional props', () => { // WHY: Verifies TypeScript defaults work at runtime // render() // expect(screen.getByRole('...')).toHaveAttribute('...', 'default-value') }) }) // -------------------------------------------------------------------------- // User Interactions (if component has event handlers - on*, handle*) // -------------------------------------------------------------------------- // WHY: Event handlers are core functionality. Test from user's perspective. describe('User Interactions', () => { it('should call onClick when clicked', async () => { // WHY userEvent over fireEvent? // - userEvent simulates real user behavior (focus, hover, then click) // - fireEvent is lower-level, doesn't trigger all browser events // const user = userEvent.setup() // const handleClick = jest.fn() // render() // // await user.click(screen.getByRole('button')) // // expect(handleClick).toHaveBeenCalledTimes(1) }) it('should call onChange when value changes', async () => { // const user = userEvent.setup() // const handleChange = jest.fn() // render() // // await user.type(screen.getByRole('textbox'), 'new value') // // expect(handleChange).toHaveBeenCalled() }) }) // -------------------------------------------------------------------------- // State Management (if component uses useState/useReducer) // -------------------------------------------------------------------------- // WHY: Test state through observable UI changes, not internal state values describe('State Management', () => { it('should update state on interaction', async () => { // WHY test via UI, not state? // - State is implementation detail; UI is what users see // - If UI works correctly, state must be correct // const user = userEvent.setup() // render() // // // Initial state - verify what user sees // expect(screen.getByText('Initial')).toBeInTheDocument() // // // Trigger state change via user action // await user.click(screen.getByRole('button')) // // // New state - verify UI updated // expect(screen.getByText('Updated')).toBeInTheDocument() }) }) // -------------------------------------------------------------------------- // Async Operations (if component fetches data - useSWR, useQuery, fetch) // -------------------------------------------------------------------------- // WHY: Async operations have 3 states users experience: loading, success, error describe('Async Operations', () => { it('should show loading state', () => { // WHY never-resolving promise? // - Keeps component in loading state for assertion // - Alternative: use fake timers // mockedApi.fetchData.mockImplementation(() => new Promise(() => {})) // render() // // expect(screen.getByText(/loading/i)).toBeInTheDocument() }) it('should show data on success', async () => { // WHY waitFor? // - Component updates asynchronously after fetch resolves // - waitFor retries assertion until it passes or times out // mockedApi.fetchData.mockResolvedValue({ items: ['Item 1'] }) // render() // // await waitFor(() => { // expect(screen.getByText('Item 1')).toBeInTheDocument() // }) }) it('should show error on failure', async () => { // mockedApi.fetchData.mockRejectedValue(new Error('Network error')) // render() // // await waitFor(() => { // expect(screen.getByText(/error/i)).toBeInTheDocument() // }) }) }) // -------------------------------------------------------------------------- // Edge Cases (REQUIRED - Every component MUST handle edge cases) // -------------------------------------------------------------------------- // WHY: Real-world data is messy. Components must handle: // - Null/undefined from API failures or optional fields // - Empty arrays/strings from user clearing data // - Boundary values (0, MAX_INT, special characters) describe('Edge Cases', () => { it('should handle null value', () => { // WHY test null specifically? // - API might return null for missing data // - Prevents "Cannot read property of null" in production // render() // expect(screen.getByText(/no data/i)).toBeInTheDocument() }) it('should handle undefined value', () => { // WHY test undefined separately from null? // - TypeScript treats them differently // - Optional props are undefined, not null // render() // expect(screen.getByText(/no data/i)).toBeInTheDocument() }) it('should handle empty array', () => { // WHY: Empty state often needs special UI (e.g., "No items yet") // render() // expect(screen.getByText(/empty/i)).toBeInTheDocument() }) it('should handle empty string', () => { // WHY: Empty strings are truthy in JS but visually empty // render() // expect(screen.getByText(/placeholder/i)).toBeInTheDocument() }) }) // -------------------------------------------------------------------------- // Accessibility (optional but recommended for Dify's enterprise users) // -------------------------------------------------------------------------- // WHY: Dify has enterprise customers who may require accessibility compliance describe('Accessibility', () => { it('should have accessible name', () => { // WHY getByRole with name? // - Tests that screen readers can identify the element // - Enforces proper labeling practices // render() // expect(screen.getByRole('button', { name: /test label/i })).toBeInTheDocument() }) it('should support keyboard navigation', async () => { // WHY: Some users can't use a mouse // const user = userEvent.setup() // render() // // await user.tab() // expect(screen.getByRole('button')).toHaveFocus() }) }) })