Testing Studio React Components
The validation logic and access control functions you've tested so far run in Node.js with no UI. Custom input components are different—they render DOM elements, depend on Sanity UI's theming context, use Studio hooks like useFormValue and useClient, and respond to user interactions. Testing these components requires a browser-like environment, provider setup for Sanity UI, and tools to simulate user behavior like clicks and typing.
Testing components verifies not just that your code runs, but that content editors can successfully interact with your custom inputs. A permission check might pass its pure function test but fail when integrated into a component's readOnly callback. A date calculation might work in isolation but render incorrectly when formatted for display. Component tests catch these integration issues by exercising the full user interaction flow.
Install React Testing Library and jsdom:
npm install -D @testing-library/react @testing-library/user-event jsdom @testing-library/jest-domThese packages provide:
jsdom- A browser environment implementation for Node.js- @testing-library/react - Utilities for rendering and querying React components
- @testing-library/user-event - Functions that simulate realistic user interactions
- @testing-library/jest-dom - Additional matchers for DOM testing
jsdom simulates a browser environment in Node.js. It's not a real browser, so some browser-specific features (like layout calculations) won't work. For most Studio components, jsdom is sufficient.Your test suite now has different needs: pure functions run in Node, React components need a browser environment.
Update apps/studio/vitest.config.ts to define test-type projects (not to be confused with workspace projects):
import {defineProject} from 'vitest/config'
export default defineProject({ test: { name: 'studio', // Define test-type projects within the Studio workspace project projects: [ { test: { name: 'unit', environment: 'node', include: ['**/*.test.ts'], }, }, { test: { name: 'component', environment: 'jsdom', include: ['**/*.test.tsx'], setupFiles: ['./__tests__/setup.ts'], }, }, ], },})And we'll need to create the setup file for the component test suite:
import {cleanup} from '@testing-library/react'import {afterEach} from 'vitest'import '@testing-library/jest-dom/vitest'
afterEach(() => { cleanup()}).test.tsx extension.This configuration creates nested projects:
- Workspace project -
studio(defined at rootvitest.config.tswithprojects: ['apps/*']) - Test-type projects -
unitandcomponent(defined within Studio's config)
The component project:
- Uses
jsdomto provide DOM APIs that React needs - Only runs files with
.test.tsxextension (TypeScript with JSX) - Runs a setup file before each test
React components in Sanity Studio don't work in isolation—they expect certain contexts to exist. Sanity UI components read theme values from a ThemeProvider to render correctly (colors, spacing, typography). Without this provider, components throw errors or render incorrectly. Similarly, components that trigger toast notifications need a ToastProvider, and dialogs need a LayerProvider for z-index management.
In production, your Studio wraps everything with these providers automatically. In tests, you need to recreate this harness manually. Rather than wrap each component individually in every test, create a reusable fixture that provides all required contexts:
import {LayerProvider, ThemeProvider, ToastProvider} from '@sanity/ui'import {defaultTheme} from 'sanity'import {render, type RenderOptions} from '@testing-library/react'import type {ReactElement} from 'react'
export function TestProviders({children}: {children: React.ReactNode}) { return ( <ThemeProvider theme={defaultTheme}> <ToastProvider> <LayerProvider>{children}</LayerProvider> </ToastProvider> </ThemeProvider> )}
export function renderWithProviders( ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) { return render(ui, {wrapper: TestProviders, ...options})}FormBuilderProvider) only when your components require them. Keep provider setup minimal.The TestProviders component recreates the essential Studio provider hierarchy for these component tests:
ThemeProvider- Provides theme values (colors, spacing, typography)ToastProvider- Enables toast notifications (some inputs use these)LayerProvider- Manages z-index stacking for dialogs and popovers
These are the providers the DoorsOpenInput component needs. Other custom components might require additional providers—like FormBuilderProvider for components that use form-level hooks, or custom context providers for specialized features. Add providers to TestProviders as your test suite requires them.
The renderWithProviders() helper wraps React Testing Library's render() function, automatically including these providers for every component test. This fixture becomes your test harness for Sanity UI components—call it instead of render() and components work as they would in production.
The DoorsOpenInput component shows when doors open based on the event date and a minutes-before value. It uses:
useFormValuehook to read the event dateuseClienthook to access the Sanity client- Custom logic to calculate and display the doors open time
The DoorsOpenInput component displays when doors open for an event, calculated from the event date and a minutes-before value:
import {getPublishedId, type NumberInputProps, useClient, useFormValue} from 'sanity'import {Stack, Text} from '@sanity/ui'import {useEffect} from 'react'
// ..
export function DoorsOpenInput(props: NumberInputProps) { const date = useFormValue(['date']) as string | undefined const client = useClient({apiVersion: '2025-05-08'}) const documentId = useFormValue(['_id']) as string | undefined
// Query for document versions (demonstration purposes) useEffect(() => { if (!documentId) return // ... query logic }, [client, documentId])
return ( <Stack space={3}> {props.renderDefault(props)} {typeof props.value === 'number' && date ? ( <Text size={1}> Doors open{' '} {subtractMinutesFromDate(date, props.value).toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', })} </Text> ) : null} </Stack> )}The component uses Sanity hooks (useFormValue, useClient) to access form data and the Sanity client. Testing it requires mocking these hooks to control what values the component receives:
import {describe, it, expect, vi, beforeEach} from 'vitest'import {screen} from '@testing-library/react'import {useFormValue, useClient} from 'sanity'
import {renderWithProviders} from '../../../__tests__/fixtures/providers'import {DoorsOpenInput} from './DoorsOpenInput'
// Mock Sanity hooksvi.mock('sanity', async () => { const actual = await vi.importActual('sanity') return { ...actual, useFormValue: vi.fn(), useClient: vi.fn(), }})
describe('DoorsOpenInput', () => { const mockProps = { value: 60, onChange: vi.fn(), renderDefault: vi.fn((props) => <input type="number" {...props} />), } as any
beforeEach(() => { vi.clearAllMocks() })
it('shows doors open time when date and value exist', () => { vi.mocked(useFormValue).mockImplementation((path) => { if (path[0] === 'date') return '2025-06-15T20:00:00Z' if (path[0] === '_id') return 'event-1' return undefined }) vi.mocked(useClient).mockReturnValue({} as any)
renderWithProviders(<DoorsOpenInput {...mockProps} />)
expect(screen.getByText(/Doors open/i)).toBeInTheDocument() expect(screen.getByText(/June/i)).toBeInTheDocument() })
it('hides doors open time when date is missing', () => { vi.mocked(useFormValue).mockImplementation((path) => { if (path[0] === 'date') return undefined if (path[0] === '_id') return 'event-2' return undefined }) vi.mocked(useClient).mockReturnValue({} as any)
renderWithProviders(<DoorsOpenInput {...mockProps} />)
expect(screen.queryByText(/Doors open/i)).not.toBeInTheDocument() })
it('hides doors open time when value is missing', () => { vi.mocked(useFormValue).mockImplementation((path) => { if (path[0] === 'date') return '2025-06-15T20:00:00Z' if (path[0] === '_id') return 'event-3' return undefined }) vi.mocked(useClient).mockReturnValue({} as any)
const propsWithoutValue = {...mockProps, value: undefined} renderWithProviders(<DoorsOpenInput {...propsWithoutValue} />)
expect(screen.queryByText(/Doors open/i)).not.toBeInTheDocument() })})mockImplementation() in each test's setup rather than at the file level.This test mocks the entire sanity module to control what useFormValue and useClient return:
vi.mock('sanity')- Intercepts all imports from thesanitypackagevi.importActual()- Preserves original exports (types, utilities)useFormValue: vi.fn()- Replaces hook with mock functionmockImplementation()- Controls what the hook returns based on the path argumentbeforeEach()- Clears mocks between tests for isolation
The mockImplementation() function lets you return different values based on which form field is being accessed. When the component calls useFormValue(['date']), the mock checks if path[0] === 'date' and returns the appropriate value. This pattern works for any component that reads multiple form fields through useFormValue.
Focus on behavior that matters to content editors and enforced data integrity:
Test:
- Components render with expected content
- User interactions trigger correct callbacks
- Interactive components mutate documents accurately
- Accessibility attributes are present
Don't test:
- Implementation details (state variable names, hooks used)
- Sanity internals (we ensure test coverage for our own code)
- CSS styles and exact positioning
- Third-party library behavior (they should be testing it)
Custom inputs that modify documents deserve thorough testing. A bug in a read-only component might confuse an editor temporarily, but a bug in a component that writes data to the published document rather than the current draft would be a headache. Test these rigorously to ensure they write the correct values to the intended fields in the intended document.
onChange when the user modifies the input value. Use userEvent.type() to simulate typing.You've learned to test React components in Sanity Studio:
- Setting up
jsdomfor browser-like testing - Creating provider fixtures for Studio context
- Rendering components with React Testing Library
- Simulating user interactions with
userEvent - Mocking Sanity hooks like
useFormValueanduseClient - Using accessible queries to find elements
In the next lesson, you'll integrate tests into GitHub Actions, implement coverage reporting, and develop a testing strategy that scales with your Studio.