mirror of https://github.com/langgenius/dify.git
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
/**
|
|
* 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 <path> 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 (Vitest requirement).
|
|
// They run BEFORE imports, so keep them before component imports.
|
|
|
|
// i18n (automatically mocked)
|
|
// WHY: Global mock in web/vitest.setup.ts is auto-loaded by Vitest setup
|
|
// No explicit mock needed - it returns translation keys as-is
|
|
// Override only if custom translations are required:
|
|
// vi.mock('react-i18next', () => ({
|
|
// useTranslation: () => ({
|
|
// t: (key: string) => {
|
|
// const customTranslations: Record<string, string> = {
|
|
// '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 = vi.fn()
|
|
// vi.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)
|
|
// vi.mock('@/service/api')
|
|
// import * as api from '@/service/api'
|
|
// const mockedApi = vi.mocked(api)
|
|
|
|
// 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(<ComponentName {...createMockProps(props)} />)
|
|
// }
|
|
|
|
// ============================================================================
|
|
// 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(() => {
|
|
vi.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(<ComponentName {...props} />)
|
|
|
|
// 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(<ComponentName />)
|
|
// 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(<ComponentName className="custom-class" />)
|
|
// expect(screen.getByTestId('component')).toHaveClass('custom-class')
|
|
})
|
|
|
|
it('should use default values for optional props', () => {
|
|
// WHY: Verifies TypeScript defaults work at runtime
|
|
// render(<ComponentName />)
|
|
// 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 = vi.fn()
|
|
// render(<ComponentName onClick={handleClick} />)
|
|
//
|
|
// await user.click(screen.getByRole('button'))
|
|
//
|
|
// expect(handleClick).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should call onChange when value changes', async () => {
|
|
// const user = userEvent.setup()
|
|
// const handleChange = vi.fn()
|
|
// render(<ComponentName onChange={handleChange} />)
|
|
//
|
|
// 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(<ComponentName />)
|
|
//
|
|
// // 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(<ComponentName />)
|
|
//
|
|
// 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(<ComponentName />)
|
|
//
|
|
// await waitFor(() => {
|
|
// expect(screen.getByText('Item 1')).toBeInTheDocument()
|
|
// })
|
|
})
|
|
|
|
it('should show error on failure', async () => {
|
|
// mockedApi.fetchData.mockRejectedValue(new Error('Network error'))
|
|
// render(<ComponentName />)
|
|
//
|
|
// 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(<ComponentName value={null} />)
|
|
// 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(<ComponentName value={undefined} />)
|
|
// 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(<ComponentName items={[]} />)
|
|
// expect(screen.getByText(/empty/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('should handle empty string', () => {
|
|
// WHY: Empty strings are truthy in JS but visually empty
|
|
// render(<ComponentName text="" />)
|
|
// 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(<ComponentName label="Test Label" />)
|
|
// 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(<ComponentName />)
|
|
//
|
|
// await user.tab()
|
|
// expect(screen.getByRole('button')).toHaveFocus()
|
|
})
|
|
})
|
|
})
|