refactor: create shared react-i18next mock to reduce duplication (#29711)

This commit is contained in:
yyh
2025-12-16 12:45:17 +08:00
committed by GitHub
parent 4cc6652424
commit eeb5129a17
40 changed files with 58 additions and 228 deletions

View File

@@ -76,7 +76,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
- [ ] **DO NOT mock base components** (`@/app/components/base/*`)
- [ ] `jest.clearAllMocks()` in `beforeEach` (not `afterEach`)
- [ ] Shared mock state reset in `beforeEach`
- [ ] i18n mock returns keys (not empty strings)
- [ ] i18n uses shared mock (auto-loaded); only override locally for custom translations
- [ ] Router mocks match actual Next.js API
- [ ] Mocks reflect actual component conditional behavior
- [ ] Only mock: API services, complex context providers, third-party libs

View File

@@ -318,3 +318,4 @@ For more detailed information, refer to:
- `web/jest.config.ts` - Jest configuration
- `web/jest.setup.ts` - Test environment setup
- `web/testing/analyze-component.js` - Component analysis tool
- `web/__mocks__/react-i18next.ts` - Shared i18n mock (auto-loaded by Jest, no explicit mock needed; override locally only for custom translations)

View File

@@ -46,12 +46,22 @@ Only mock these categories:
## Essential Mocks
### 1. i18n (Always Required)
### 1. i18n (Auto-loaded via Shared Mock)
A shared mock is available at `web/__mocks__/react-i18next.ts` and is auto-loaded by Jest.
**No explicit mock needed** for most tests - it returns translation keys as-is.
For tests requiring custom translations, override the mock:
```typescript
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
t: (key: string) => {
const translations: Record<string, string> = {
'my.custom.key': 'Custom translation',
}
return translations[key] || key
},
}),
}))
```
@@ -313,7 +323,7 @@ Need to use a component in test?
│ └─ YES → Mock it (next/navigation, external SDKs)
└─ Is it i18n?
└─ YES → Mock to return keys
└─ YES → Uses shared mock (auto-loaded). Override only for custom translations
```
## Factory Function Pattern

View File

@@ -0,0 +1,34 @@
/**
* Shared mock for react-i18next
*
* Jest automatically uses this mock when react-i18next is imported in tests.
* The default behavior returns the translation key as-is, which is suitable
* for most test scenarios.
*
* For tests that need custom translations, you can override with jest.mock():
*
* @example
* jest.mock('react-i18next', () => ({
* useTranslation: () => ({
* t: (key: string) => {
* if (key === 'some.key') return 'Custom translation'
* return key
* },
* }),
* }))
*/
export const useTranslation = () => ({
t: (key: string) => key,
i18n: {
language: 'en',
changeLanguage: jest.fn(),
},
})
export const Trans = ({ children }: { children?: React.ReactNode }) => children
export const initReactI18next = {
type: '3rdParty',
init: jest.fn(),
}

View File

@@ -4,12 +4,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import MailAndPasswordAuth from '@/app/(shareLayout)/webapp-signin/components/mail-and-password-auth'
import CheckCode from '@/app/(shareLayout)/webapp-signin/check-code/page'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const replaceMock = jest.fn()
const backMock = jest.fn()

View File

@@ -4,12 +4,6 @@ import '@testing-library/jest-dom'
import CommandSelector from '../../app/components/goto-anything/command-selector'
import type { ActionItem } from '../../app/components/goto-anything/actions/types'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('cmdk', () => ({
Command: {
Group: ({ children, className }: any) => <div className={className}>{children}</div>,

View File

@@ -3,13 +3,6 @@ import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import { OpikIconBig } from '@/app/components/base/icons/src/public/tracing'
// Mock dependencies to isolate the SVG rendering issue
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('SVG Attribute Error Reproduction', () => {
// Capture console errors
const originalError = console.error

View File

@@ -3,12 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import CSVUploader, { type Props } from './csv-uploader'
import { ToastContext } from '@/app/components/base/toast'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('CSVUploader', () => {
const notify = jest.fn()
const updateFile = jest.fn()

View File

@@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import OperationBtn from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@remixicon/react', () => ({
RiAddLine: (props: { className?: string }) => (
<svg data-testid='add-icon' className={props.className} />

View File

@@ -2,12 +2,6 @@ import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import ConfirmAddVar from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('../../base/var-highlight', () => ({
__esModule: true,
default: ({ name }: { name: string }) => <span data-testid="var-highlight">{name}</span>,

View File

@@ -3,12 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import EditModal from './edit-modal'
import type { ConversationHistoriesRole } from '@/models/debug'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/app/components/base/modal', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,

View File

@@ -2,12 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import HistoryPanel from './history-panel'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockDocLink = jest.fn(() => 'doc-link')
jest.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,

View File

@@ -6,12 +6,6 @@ import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config'
import { type PromptItem, PromptRole, type PromptVariable } from '@/models/debug'
import { AppModeEnum, ModelModeType } from '@/types/app'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
type DebugConfiguration = {
isAdvancedMode: boolean
currentAdvancedPrompt: PromptItem | PromptItem[]

View File

@@ -5,12 +5,6 @@ jest.mock('react-sortablejs', () => ({
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('ConfigSelect Component', () => {
const defaultProps = {
options: ['Option 1', 'Option 2'],

View File

@@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ContrlBtnGroup from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('ContrlBtnGroup', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@@ -51,12 +51,6 @@ const mockFiles: FileEntity[] = [
},
]
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/context/debug-configuration', () => ({
__esModule: true,
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),

View File

@@ -18,12 +18,6 @@ import type { App, AppIconType, AppModeEnum } from '@/types/app'
// Mocks
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockRouterPush = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({

View File

@@ -16,12 +16,6 @@ import type { QueryParam } from './index'
// Mocks
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockTrackEvent = jest.fn()
jest.mock('@/app/components/base/amplitude/utils', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),

View File

@@ -49,13 +49,6 @@ jest.mock('next/navigation', () => ({
}),
}))
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
jest.mock('next/link', () => ({
__esModule: true,
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,

View File

@@ -22,12 +22,6 @@ import { APP_PAGE_LIMIT } from '@/config'
// Mocks
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockRouterPush = jest.fn()
jest.mock('next/navigation', () => ({
useRouter: () => ({

View File

@@ -15,12 +15,6 @@ import { Theme } from '@/types/app'
// Mocks
// ============================================================================
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
let mockTheme = Theme.light
jest.mock('@/hooks/use-theme', () => ({
__esModule: true,

View File

@@ -3,13 +3,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import { AccessMode } from '@/models/access-control'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockPush = jest.fn()
jest.mock('next/navigation', () => ({

View File

@@ -2,13 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import Empty from './empty'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Empty', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@@ -2,13 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import Footer from './footer'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Footer', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@@ -1,13 +1,6 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Track mock calls
let documentTitleCalls: string[] = []
let educationInitCalls: number = 0

View File

@@ -2,13 +2,6 @@ import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockReplace = jest.fn()
const mockRouter = { replace: mockReplace }

View File

@@ -1,13 +1,6 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
// Mock react-i18next - return key as per testing skills
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockReplace = jest.fn()
jest.mock('next/navigation', () => ({

View File

@@ -6,13 +6,6 @@ import type { IDrawerProps } from './index'
// Capture dialog onClose for testing
let capturedDialogOnClose: (() => void) | null = null
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock @headlessui/react
jest.mock('@headlessui/react', () => ({
Dialog: ({ children, open, onClose, className, unmount }: {

View File

@@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Label from './label'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Label Component', () => {
const defaultProps = {
htmlFor: 'test-input',

View File

@@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { InputNumber } from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('InputNumber Component', () => {
const defaultProps = {
onChange: jest.fn(),

View File

@@ -1,12 +1,6 @@
import { render, screen } from '@testing-library/react'
import AnnotationFull from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,

View File

@@ -1,12 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AnnotationFullModal from './modal'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
let mockUsageProps: { className?: string } | null = null
jest.mock('./usage', () => ({
__esModule: true,

View File

@@ -5,12 +5,6 @@ import PlanUpgradeModal from './index'
const mockSetShowPricingModal = jest.fn()
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/app/components/base/modal', () => {
const MockModal = ({ isShow, children }: { isShow: boolean; children: React.ReactNode }) => (
isShow ? <div data-testid="plan-upgrade-modal">{children}</div> : null

View File

@@ -16,13 +16,6 @@ jest.mock('next/navigation', () => ({
}),
}))
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink hook
jest.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,

View File

@@ -15,13 +15,6 @@ jest.mock('next/navigation', () => ({
}),
}))
// Mock react-i18next
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink hook
jest.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,

View File

@@ -4,12 +4,6 @@ import AppCard, { type AppCardProps } from './index'
import type { App } from '@/models/explore'
import { AppModeEnum } from '@/types/app'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
jest.mock('@/app/components/base/app-icon', () => ({
__esModule: true,
default: ({ children }: any) => <div data-testid="app-icon">{children}</div>,

View File

@@ -2,12 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import NoData from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('NoData', () => {
beforeEach(() => {
jest.clearAllMocks()

View File

@@ -2,12 +2,6 @@ import React from 'react'
import { render, screen } from '@testing-library/react'
import ResDownload from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockType = { Link: 'mock-link' }
let capturedProps: Record<string, unknown> | undefined

View File

@@ -3,13 +3,6 @@ import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfirmModal from './index'
// Mock external dependencies as per guidelines
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Test utilities
const defaultProps = {
show: true,

View File

@@ -326,12 +326,19 @@ describe('ComponentName', () => {
### General
1. **i18n**: Always return key
1. **i18n**: Uses shared mock at `web/__mocks__/react-i18next.ts` (auto-loaded by Jest)
The shared mock returns translation keys as-is. For custom translations, override:
```typescript
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
t: (key: string) => {
const translations: Record<string, string> = {
'my.custom.key': 'Custom translation',
}
return translations[key] || key
},
}),
}))
```